feat(agent): agent_prepare_context tool + context_scout subagent (#3949)

This commit is contained in:
Steven Enamakel
2026-06-23 10:22:38 -07:00
committed by GitHub
parent 8cf289a904
commit 4c2277070f
42 changed files with 1729 additions and 1 deletions
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import type { ToolTimelineEntry } from '../store/chatRuntimeSlice';
import { formatTimelineEntry, formatToolName } from './toolTimelineFormatting';
function entry(partial: Partial<ToolTimelineEntry> & { name: string }): ToolTimelineEntry {
return { id: 'e1', round: 0, status: 'running', ...partial };
}
describe('toolTimelineFormatting — agent_prepare_context / context_scout', () => {
it('labels the agent_prepare_context tool name', () => {
expect(formatToolName('agent_prepare_context')).toBe('Preparing context');
});
it('formats the agent_prepare_context tool entry with the question as detail', () => {
const result = formatTimelineEntry(
entry({
name: 'agent_prepare_context',
argsBuffer: JSON.stringify({ question: 'what should I focus on?' }),
})
);
expect(result.title).toBe('Preparing context');
expect(result.detail).toBe('what should I focus on?');
});
it('formats the context_scout subagent rows', () => {
expect(formatTimelineEntry(entry({ name: 'subagent:context_scout', detail: 'd' })).title).toBe(
'Scouting context'
);
expect(formatTimelineEntry(entry({ name: 'context_scout' })).title).toBe('Scouting context');
});
});
+8
View File
@@ -11,6 +11,7 @@ interface ParsedToolArgs {
pattern?: string;
query?: string;
tool_name?: string;
question?: string;
}
const TOOL_DISPLAY_NAMES: Record<string, string> = {
@@ -69,6 +70,7 @@ const TOOL_DISPLAY_NAMES: Record<string, string> = {
audio_email_podcast: 'Emailing podcast',
audio_generate_and_email_podcast: 'Generating & emailing podcast',
composio_list_connections: 'Viewing your Connections',
agent_prepare_context: 'Preparing context',
};
/**
@@ -107,6 +109,12 @@ export function formatTimelineEntry(entry: ToolTimelineEntry): { title: string;
if (entry.name === 'subagent:researcher' || entry.name === 'researcher') {
return { title: 'Researching', detail: entry.detail };
}
if (entry.name === 'agent_prepare_context') {
return { title: 'Preparing context', detail: parsedArgs?.question?.trim() || entry.detail };
}
if (entry.name === 'subagent:context_scout' || entry.name === 'context_scout') {
return { title: 'Scouting context', detail: entry.detail };
}
if (entry.name === 'composio_list_connections') {
return { title: 'Viewing your Connections', detail: entry.detail };
}
@@ -0,0 +1,650 @@
#!/usr/bin/env node
// Live audit for the `agent_prepare_context` tool + `context_scout` subagent.
//
// Drives real agent turns through JSON-RPC against an authenticated core, forces
// the orchestrator to call `agent_prepare_context`, then reads the resulting
// session transcripts to surface — per query — the returned [context_bundle],
// the scout's step-by-step turns ("thoughts"), and tokens in/out/cached + cost.
//
// Unlike harness-cache-audit.mjs (which deliberately hides bodies), this script
// PRINTS bundle + thought content so you can iterate on the context_scout
// prompt. Run it against a core built from this branch (so the tool exists) and
// signed into your account (so the LLM calls bill to you).
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { once } from "node:events";
import { statSync } from "node:fs";
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { homedir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const DEFAULT_RPC_URL = "http://127.0.0.1:7788/rpc";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const SCOUT_AGENT = "context_scout";
// Default audit queries, each aimed at a different context source.
const DEFAULT_CASES = [
{ name: "memory/projects", query: "What do you know about my current projects and what should I work on next?" },
{ name: "goals/profile", query: "Based on my stated goals, what should I prioritise this week?" },
{ name: "web/fresh-fact", query: "What is the latest stable Rust release and one notable change in it?" },
{ name: "integrations/email", query: "Summarise my most important unread emails from the last day." },
{ name: "mixed/plan", query: "Plan my day: combine my goals, recent activity, and anything time-sensitive." },
];
function usage() {
return `Usage: node scripts/debug/agent-prepare-context-audit.mjs [options]
Audits the agent_prepare_context tool live: forces it per query, then prints the
returned context bundle, the scout's turns, and tokens/cache/cost.
Options:
--core-url <url> JSON-RPC endpoint (default: OPENHUMAN_CORE_RPC_URL or ${DEFAULT_RPC_URL})
--token <token> RPC bearer (default: OPENHUMAN_CORE_TOKEN or <workspace>/core.token)
--workspace <path> Workspace whose session_raw transcripts are read
--model <model> Optional model_override passed to openhuman.inference_agent_chat
--query <text> Add a custom query (repeatable). Replaces the defaults.
--raw Send the query unwrapped (let the orchestrator decide
whether to call the tool) instead of forcing the call.
--scout-prompt-file <f> Override the context_scout system prompt with this file
(writes a temporary workspace agent override; restored
after the run unless --keep-workspace). Test your prompt.
--thread-prefix <s> Thread id prefix to isolate transcripts (default: random)
--max-print-chars <n> Truncate printed bundle/thought blocks (default: 4000)
--rpc-timeout-ms <n> Per-RPC timeout (default: 600000)
--spawn-core Start \`cargo run --bin openhuman-core\` for the audit
--keep-workspace Keep any temp override files written for --scout-prompt-file
--json Print a machine-readable JSON summary at the end
--verbose Stream spawned core logs
-h, --help Show this help
Examples:
node scripts/debug/agent-prepare-context-audit.mjs --spawn-core
node scripts/debug/agent-prepare-context-audit.mjs --query "what are my goals?" --model claude-sonnet-4-6
node scripts/debug/agent-prepare-context-audit.mjs --scout-prompt-file /tmp/my-scout.md
`;
}
function parseArgs(argv) {
const opts = {
coreUrl: process.env.OPENHUMAN_CORE_RPC_URL || DEFAULT_RPC_URL,
token: process.env.OPENHUMAN_CORE_TOKEN || "",
workspace: process.env.OPENHUMAN_WORKSPACE || "",
model: "",
queries: [],
raw: false,
scoutPromptFile: "",
threadPrefix: `apc-audit-${randomBytes(3).toString("hex")}`,
maxPrintChars: 4000,
rpcTimeoutMs: 600_000,
spawnCore: false,
keepWorkspace: false,
json: false,
verbose: false,
coreUrlExplicit: Boolean(process.env.OPENHUMAN_CORE_RPC_URL),
workspaceExplicit: Boolean(process.env.OPENHUMAN_WORKSPACE),
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
const next = () => {
const value = argv[++i];
if (value === undefined) throw new Error(`missing value for ${arg}`);
return value;
};
switch (arg) {
case "--core-url": opts.coreUrl = next(); opts.coreUrlExplicit = true; break;
case "--token": opts.token = next(); break;
case "--workspace": opts.workspace = next(); opts.workspaceExplicit = true; break;
case "--model": opts.model = next(); break;
case "--query": opts.queries.push(next()); break;
case "--raw": opts.raw = true; break;
case "--scout-prompt-file": opts.scoutPromptFile = next(); break;
case "--thread-prefix": opts.threadPrefix = next(); break;
case "--max-print-chars": opts.maxPrintChars = parsePositiveInt(next(), "--max-print-chars"); break;
case "--rpc-timeout-ms": opts.rpcTimeoutMs = parsePositiveInt(next(), "--rpc-timeout-ms"); break;
case "--spawn-core": opts.spawnCore = true; break;
case "--keep-workspace": opts.keepWorkspace = true; break;
case "--json": opts.json = true; break;
case "--verbose": opts.verbose = true; break;
case "-h":
case "--help":
console.log(usage());
process.exit(0);
default:
throw new Error(`unknown option: ${arg}`);
}
}
return opts;
}
function parsePositiveInt(raw, label) {
const value = Number(raw);
if (!Number.isInteger(value) || value < 1) throw new Error(`${label} must be a positive integer`);
return value;
}
function defaultOpenhumanDir() {
if (process.env.OPENHUMAN_APP_ENV === "staging") {
return path.join(homedir(), ".openhuman-staging");
}
if (process.env.OPENHUMAN_APP_ENV) {
return path.join(homedir(), ".openhuman");
}
// APP_ENV unset: the core (launched from a shell that may export
// OPENHUMAN_APP_ENV=staging) and this script can disagree. Auto-pick the
// dir whose active_user.toml was touched most recently so transcript reads
// land in the same env the core actually uses. Falls back to prod.
const prod = path.join(homedir(), ".openhuman");
const staging = path.join(homedir(), ".openhuman-staging");
const mtime = (p) => {
try {
return statSync(path.join(p, "active_user.toml")).mtimeMs;
} catch {
return -1;
}
};
return mtime(staging) > mtime(prod) ? staging : prod;
}
async function defaultWorkspace() {
if (process.env.OPENHUMAN_WORKSPACE) return process.env.OPENHUMAN_WORKSPACE;
const openhumanDir = defaultOpenhumanDir();
try {
const active = await readFile(path.join(openhumanDir, "active_user.toml"), "utf8");
const match = active.match(/^\s*user_id\s*=\s*"([^"]+)"\s*$/m);
if (match?.[1]) return path.join(openhumanDir, "users", match[1], "workspace");
} catch {
// fall through to legacy root
}
return openhumanDir;
}
async function readToken(opts) {
if (opts.token.trim()) return opts.token.trim();
const tokenPath = path.join(opts.workspace || (await defaultWorkspace()), "core.token");
try {
return (await readFile(tokenPath, "utf8")).trim();
} catch {
throw new Error(
`RPC token not provided and ${tokenPath} could not be read. Pass --token or set OPENHUMAN_CORE_TOKEN.`,
);
}
}
async function rpc(coreUrl, token, method, params, timeoutMs = 600_000) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
let res;
try {
res = await fetch(coreUrl, {
method: "POST",
signal: controller.signal,
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({
jsonrpc: "2.0",
id: `apc-${Date.now()}-${Math.random().toString(16).slice(2)}`,
method,
params,
}),
});
} catch (err) {
if (err?.name === "AbortError") throw new Error(`RPC ${method} timed out after ${timeoutMs}ms`);
throw err;
} finally {
clearTimeout(timeout);
}
const bodyText = await res.text();
let body;
try {
body = JSON.parse(bodyText);
} catch {
throw new Error(`RPC ${method} returned non-JSON HTTP ${res.status}: ${bodyText.slice(0, 200)}`);
}
if (!res.ok) throw new Error(`RPC ${method} HTTP ${res.status}`);
if (body.error) throw new Error(`RPC ${method} error: ${JSON.stringify(body.error).slice(0, 300)}`);
return body.result;
}
// ── Transcript reading ──────────────────────────────────────────────────────
async function walkJsonl(dir) {
const out = [];
async function walk(current) {
let entries;
try {
entries = await readdir(current, { withFileTypes: true });
} catch {
return;
}
await Promise.all(
entries.map(async (entry) => {
const full = path.join(current, entry.name);
if (entry.isDirectory()) return walk(full);
if (entry.isFile() && entry.name.endsWith(".jsonl")) out.push(full);
}),
);
}
await walk(dir);
return out;
}
function num(value) {
return Number.isFinite(Number(value)) ? Number(value) : 0;
}
async function readTranscript(file) {
const data = await readFile(file, "utf8");
const lines = data.split(/\r?\n/).filter((l) => l.trim());
if (lines.length === 0) throw new Error("empty transcript");
const meta = (JSON.parse(lines[0])._meta) || {};
const messages = [];
for (const line of lines.slice(1)) {
try {
const m = JSON.parse(line);
if (typeof m.role === "string") messages.push(m);
} catch {
// skip malformed line
}
}
return {
file,
agent: String(meta.agent || "(unknown)"),
threadId: meta.thread_id || null,
isSubagent: path.basename(file).includes("__"),
input: num(meta.input_tokens),
output: num(meta.output_tokens),
cached: num(meta.cached_input_tokens),
charged: num(meta.charged_amount_usd),
messages,
};
}
async function snapshot(workspace) {
const files = await walkJsonl(path.join(workspace, "session_raw"));
const map = new Map();
await Promise.all(
files.map(async (file) => {
try {
map.set(file, await readTranscript(file));
} catch {
// ignore partial writes
}
}),
);
return map;
}
// Transcripts created or grown since `before`, scoped to a thread id.
function changedForThread(before, after, threadId) {
const rows = [];
for (const [file, cur] of after.entries()) {
if (threadId && cur.threadId && cur.threadId !== threadId) continue;
const prior = before.get(file);
const grew = !prior || cur.input !== prior.input || cur.output !== prior.output || cur.messages.length !== prior.messages.length;
if (grew) rows.push(cur);
}
return rows;
}
// ── Bundle parsing ──────────────────────────────────────────────────────────
function extractBundle(text) {
if (typeof text !== "string") return null;
const open = text.indexOf("[context_bundle]");
if (open === -1) return null;
const close = text.indexOf("[/context_bundle]", open);
const inner = text.slice(open + "[context_bundle]".length, close === -1 ? undefined : close).trim();
const hasEnough = /has_enough_context\s*:\s*(true|false)/i.exec(inner)?.[1] ?? "(unspecified)";
// summary: everything between `summary:` and `recommended_tool_calls:`
const sumMatch = /summary\s*:\s*([\s\S]*?)(?:\n\s*recommended_tool_calls\s*:|$)/i.exec(inner);
const summary = (sumMatch?.[1] || "").trim();
const recIdx = inner.search(/recommended_tool_calls\s*:/i);
const recBlock = recIdx === -1 ? "" : inner.slice(recIdx).replace(/^[^\n]*\n?/, "");
const tools = [];
for (const m of recBlock.matchAll(/(?:^|\n)\s*-?\s*tool\s*:\s*([^\n]+)/gi)) {
tools.push(m[1].trim());
}
return { raw: text.slice(open, close === -1 ? undefined : close + "[/context_bundle]".length), hasEnough, summary, tools, full: inner };
}
function clip(text, max) {
if (typeof text !== "string") return "";
if (text.length <= max) return text;
return `${text.slice(0, max)}\n…[truncated ${text.length - max} chars]`;
}
// ── Prompt shaping ──────────────────────────────────────────────────────────
function forcedPrompt(query) {
return `Call the \`agent_prepare_context\` tool now, passing \`question\` set to the user request below. \
Do not answer the request yourself first — scout context first. After the tool returns, reply with the \
returned context bundle verbatim, then one short line on how you'd proceed.
User request: ${query}`;
}
// ── Scout prompt override (optional) ─────────────────────────────────────────
function scoutOverrideToml(inlinePrompt) {
// A full context_scout definition mirroring the builtin, with the system
// prompt swapped for the user's file. Workspace overrides replace the builtin
// on id collision, so every field the runtime relies on must be present.
const esc = inlinePrompt.replace(/\\/g, "\\\\").replace(/"""/g, '\\"\\"\\"');
return `id = "context_scout"
display_name = "Context Scout (override)"
when_to_use = "Pre-flight context collector (prompt override)."
temperature = 0.3
max_iterations = 6
iteration_policy = "extended"
max_result_chars = 4000
sandbox_mode = "read_only"
agent_tier = "worker"
omit_identity = true
omit_memory_context = true
omit_safety_preamble = true
omit_skills_catalog = true
omit_profile = false
omit_memory_md = false
[model]
hint = "agentic"
[system_prompt]
inline = """
${esc}
"""
[tools]
# Retrieval-only surface, matching the builtin context_scout. memory_tree is
# intentionally excluded: it bundles a write mode (ingest_document) under a
# ReadOnly wrapper, so it must not be reachable by the read-only scout — even
# via a prompt-override used in a "read-only" audit.
named = ["memory_recall", "web_search_tool", "web_fetch"]
`;
}
// ── Core spawn helpers (mirrors harness-cache-audit.mjs) ─────────────────────
async function pickFreePort() {
return await new Promise((resolve, reject) => {
const server = createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
async function startCore(opts) {
const token = opts.token || `apc-${randomBytes(24).toString("hex")}`;
const env = { ...process.env, OPENHUMAN_CORE_TOKEN: token };
// Only pin OPENHUMAN_WORKSPACE when the user explicitly asked for one.
// Setting it to the auto-resolved active-user workspace makes the core
// create a *nested* `.openhuman/` config dir without the signed-in
// session (→ SESSION_EXPIRED). Leaving it unset lets the core resolve the
// active user from ~/.openhuman/active_user.toml and load its live session;
// transcripts then land in that same workspace the script reads.
if (opts.workspaceExplicit && opts.workspace) env.OPENHUMAN_WORKSPACE = opts.workspace;
const port = new URL(opts.coreUrl).port || "7788";
env.OPENHUMAN_CORE_PORT = port;
env.OPENHUMAN_CORE_RPC_URL = opts.coreUrl;
const args = ["run", "--host", "127.0.0.1", "--port", port, "--jsonrpc-only"];
const child = spawn("cargo", ["run", "--quiet", "--bin", "openhuman-core", "--", ...args], {
cwd: path.resolve(SCRIPT_DIR, "../.."),
env,
stdio: opts.verbose ? ["ignore", "inherit", "inherit"] : ["ignore", "ignore", "pipe"],
});
let stderr = "";
if (child.stderr) {
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
if (stderr.length > 8000) stderr = stderr.slice(-8000);
});
}
await waitForCore(opts.coreUrl, token, child, () => stderr);
return { child, token };
}
async function waitForCore(coreUrl, token, child, stderrFn) {
const deadline = Date.now() + 180_000;
while (Date.now() < deadline) {
if (child.exitCode !== null) throw new Error(`spawned core exited with ${child.exitCode}\n${stderrFn()}`);
try {
await rpc(coreUrl, token, "core.ping", {}, 10_000);
return;
} catch {
await new Promise((r) => setTimeout(r, 750));
}
}
throw new Error(`timed out waiting for spawned core at ${coreUrl}\n${stderrFn()}`);
}
async function stopChild(child) {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
const exited = await Promise.race([
once(child, "exit").then(() => true),
new Promise((r) => setTimeout(() => r(false), 5_000)),
]);
if (exited || child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGKILL");
await Promise.race([once(child, "exit"), new Promise((r) => setTimeout(r, 2_000))]);
}
// ── Reporting ────────────────────────────────────────────────────────────────
function printCase(opts, caseInfo, scout, root, ms) {
console.log(`\n${"═".repeat(78)}`);
console.log(`▶ case: ${caseInfo.name} (${ms}ms)`);
console.log(` query: ${caseInfo.query}`);
if (!scout) {
console.log(" ⚠ no context_scout transcript found — tool was not invoked.");
console.log(" (Is the core built from this branch? Is agent_prepare_context allowlisted?)");
return;
}
// Scout turns ("thoughts" — the model's step-by-step text + tool results).
const assistantTurns = scout.messages.filter((m) => m.role === "assistant");
const toolTurns = scout.messages.filter((m) => m.role === "tool");
console.log(`\n ── scout thoughts (${assistantTurns.length} assistant turn(s), ${toolTurns.length} tool result(s)) ──`);
scout.messages
.filter((m) => m.role === "assistant" || m.role === "tool")
.forEach((m, i) => {
const label = m.role === "assistant" ? "think" : "tool ←";
console.log(` [${i}] ${label}: ${clip(m.content, opts.maxPrintChars).replace(/\n/g, "\n ")}`);
});
// Parsed bundle from the scout's final assistant message.
const finalText = assistantTurns.at(-1)?.content || "";
const bundle = extractBundle(finalText) || extractBundle(scout.messages.at(-1)?.content || "");
console.log("\n ── parsed context bundle ──");
if (bundle) {
console.log(` has_enough_context: ${bundle.hasEnough}`);
console.log(` summary: ${clip(bundle.summary, opts.maxPrintChars).replace(/\n/g, "\n ")}`);
console.log(` recommended_tool_calls (${bundle.tools.length}): ${bundle.tools.join(", ") || "(none)"}`);
} else {
console.log(" ⚠ scout output did not contain a [context_bundle] envelope. Raw final text:");
console.log(` ${clip(finalText, opts.maxPrintChars).replace(/\n/g, "\n ")}`);
}
// Token / cost breakdown.
const rows = [];
rows.push(usageRow("context_scout", scout));
if (root) rows.push(usageRow("orchestrator", root));
rows.push({
session: "TOTAL",
in: (scout.input + (root?.input || 0)),
out: (scout.output + (root?.output || 0)),
cached: (scout.cached + (root?.cached || 0)),
"cache%": cachePct(scout.input + (root?.input || 0), scout.cached + (root?.cached || 0)),
cost_usd: round6(scout.charged + (root?.charged || 0)),
});
console.log("\n ── tokens & cost ──");
console.table(rows);
}
function usageRow(label, t) {
return {
session: label,
in: t.input,
out: t.output,
cached: t.cached,
"cache%": cachePct(t.input, t.cached),
cost_usd: round6(t.charged),
};
}
function cachePct(input, cached) {
return input > 0 ? `${((cached / input) * 100).toFixed(1)}%` : "0.0%";
}
function round6(n) {
return Number(n.toFixed(6));
}
// ── Main ──────────────────────────────────────────────────────────────────────
async function main() {
const opts = parseArgs(process.argv.slice(2));
if (!opts.workspace) opts.workspace = await defaultWorkspace();
const cases = (opts.queries.length > 0 ? opts.queries.map((q, i) => ({ name: `custom-${i + 1}`, query: q })) : DEFAULT_CASES);
// Write the optional context_scout prompt override BEFORE the core starts,
// so a spawned core loads it (the override is read at boot). For attached
// mode the caller must have already started the core after writing it — but
// writing first here still beats writing after, and we warn below.
let overridePath = "";
if (opts.scoutPromptFile) {
const promptBody = await readFile(opts.scoutPromptFile, "utf8");
const agentsDir = path.join(opts.workspace, "agents");
await mkdir(agentsDir, { recursive: true });
overridePath = path.join(agentsDir, "context_scout.toml");
await writeFile(overridePath, scoutOverrideToml(promptBody.trim()));
console.log(`[apc-audit] wrote scout prompt override → ${overridePath}`);
if (!opts.spawnCore) {
console.log(
"[apc-audit] NOTE: attached mode — restart your core now so it loads the override before audits run.",
);
}
}
let spawned;
if (opts.spawnCore) {
if (!opts.coreUrlExplicit) {
const port = await pickFreePort();
opts.coreUrl = `http://127.0.0.1:${port}/rpc`;
}
spawned = await startCore(opts);
opts.token = spawned.token;
} else {
opts.token = await readToken(opts);
}
console.log("[apc-audit] starting live agent_prepare_context audit");
console.log(` rpc: ${opts.coreUrl}`);
console.log(` workspace: ${opts.workspace}`);
console.log(` mode: ${opts.spawnCore ? "spawned-core (this branch)" : "attached-core"}`);
console.log(` model: ${opts.model || "(account default)"}`);
console.log(` cases: ${cases.length}${opts.raw ? " (raw — not forcing the tool)" : " (forcing the tool)"}`);
const caseResults = [];
try {
for (let i = 0; i < cases.length; i += 1) {
const c = cases[i];
const threadId = `${opts.threadPrefix}-${i}`;
const before = await snapshot(opts.workspace);
const params = { message: opts.raw ? c.query : forcedPrompt(c.query), thread_id: threadId };
if (opts.model) params.model_override = opts.model;
const started = Date.now();
let rpcError = "";
try {
await rpc(opts.coreUrl, opts.token, "openhuman.inference_agent_chat", params, opts.rpcTimeoutMs);
} catch (err) {
rpcError = err.message;
}
const ms = Date.now() - started;
const after = await snapshot(opts.workspace);
const changed = changedForThread(before, after, threadId);
const scout = changed.find((t) => t.isSubagent && t.agent === SCOUT_AGENT) || changed.find((t) => t.agent === SCOUT_AGENT);
const root = changed.find((t) => !t.isSubagent);
if (rpcError) console.log(`\n▶ case: ${c.name} — RPC error: ${rpcError}`);
printCase(opts, c, scout, root, ms);
const finalText = (scout?.messages.filter((m) => m.role === "assistant").at(-1)?.content) || "";
const bundle = scout ? extractBundle(finalText) : null;
caseResults.push({
name: c.name,
query: c.query,
ms,
invoked: Boolean(scout),
hasEnough: bundle?.hasEnough ?? null,
recommended: bundle?.tools ?? [],
scout: scout ? pick(scout) : null,
orchestrator: root ? pick(root) : null,
rpcError: rpcError || null,
});
}
} finally {
if (spawned?.child) await stopChild(spawned.child);
if (overridePath && !opts.keepWorkspace) {
await rm(overridePath, { force: true });
console.log(`\n[apc-audit] removed scout prompt override ${overridePath}`);
}
}
// Aggregate.
const agg = caseResults.reduce(
(a, r) => {
a.invoked += r.invoked ? 1 : 0;
a.in += (r.scout?.input || 0) + (r.orchestrator?.input || 0);
a.out += (r.scout?.output || 0) + (r.orchestrator?.output || 0);
a.cached += (r.scout?.cached || 0) + (r.orchestrator?.cached || 0);
a.cost += (r.scout?.charged || 0) + (r.orchestrator?.charged || 0);
return a;
},
{ invoked: 0, in: 0, out: 0, cached: 0, cost: 0 },
);
console.log(`\n${"═".repeat(78)}`);
console.log("[apc-audit] summary");
console.table(
caseResults.map((r) => ({
case: r.name,
invoked: r.invoked ? "yes" : "NO",
enough: r.hasEnough ?? "-",
rec_tools: r.recommended.length,
ms: r.ms,
cost_usd: round6((r.scout?.charged || 0) + (r.orchestrator?.charged || 0)),
})),
);
console.log(` tool invoked: ${agg.invoked}/${caseResults.length}`);
console.log(` total tokens: ${agg.in} in / ${agg.out} out / ${agg.cached} cached (${cachePct(agg.in, agg.cached)} hit)`);
console.log(` total cost: $${round6(agg.cost)}`);
if (opts.json) {
console.log(`\n[apc-audit] json\n${JSON.stringify({ cases: caseResults, totals: { ...agg, cost: round6(agg.cost) } }, null, 2)}`);
}
const everInvoked = agg.invoked > 0;
if (!everInvoked) {
console.error("\n[apc-audit] FAIL: agent_prepare_context was never invoked (no context_scout transcript).");
process.exit(1);
}
console.log("\n[apc-audit] done");
}
function pick(t) {
return { input: t.input, output: t.output, cached: t.cached, charged: t.charged, file: t.file };
}
main().catch((err) => {
console.error(`[apc-audit] ERROR: ${err.message}`);
process.exit(1);
});
+6
View File
@@ -24,6 +24,9 @@ Commands:
Inspect saved debug-log files. `last` shows the most recent.
harness-cache-audit [options]
Run live harness turns over JSON-RPC and summarize transcript token/cache deltas.
agent-prepare-context-audit [options]
Live-audit the agent_prepare_context tool: force it per query, print the
returned context bundle, scout thoughts, and tokens/cache/cost.
goals-live [options]
Live-test the memory_goals flow (list/add/edit/delete + reflect enrichment),
printing the goals_agent's thoughts, tool calls, token usage and cost.
@@ -49,6 +52,9 @@ case "$cmd" in
harness-cache-audit)
exec node "$here/harness-cache-audit.mjs" "$@"
;;
agent-prepare-context-audit)
exec node "$here/agent-prepare-context-audit.mjs" "$@"
;;
goals-live)
exec node "$here/goals-live.mjs" "$@"
;;
@@ -52,6 +52,16 @@ pub struct ParentExecutionContext {
/// provider for prefix-cache reuse.
pub all_tool_specs: Arc<Vec<ToolSpec>>,
/// Names of the tools the parent actually advertises and will execute
/// this turn (the visibility-filtered subset of `all_tools`, including
/// runtime-synthesised `delegate_*` tools). Tools call sites that need
/// to reason about what the parent can *actually* invoke — e.g.
/// `agent_prepare_context` recommending next tool calls — must consult
/// this, not `all_tool_specs` (which is the full registry, including
/// hidden direct-exec/spawn tools the parent never advertises). Empty
/// means "unknown" — callers should treat that as "no restriction".
pub visible_tool_names: std::collections::HashSet<String>,
/// Model name the parent is currently using (after classification).
pub model_name: String,
@@ -120,6 +120,17 @@ impl Agent {
provider: Arc::clone(&self.provider),
all_tools: Arc::clone(&self.tools),
all_tool_specs: Arc::clone(&self.tool_specs),
// Names of the tools the parent actually advertises this turn —
// taken from the *policy-filtered* `visible_tool_specs` (after
// ToolPolicySession drops tools above the channel's permission),
// not the raw `visible_tool_names` (pre-policy). This is the exact
// set the parent provider receives, so consumers like
// `agent_prepare_context` never surface a tool the parent can't call.
visible_tool_names: self
.visible_tool_specs
.iter()
.map(|spec| spec.name.clone())
.collect(),
model_name: self.model_name.clone(),
temperature: self.temperature,
workspace_dir: self.workspace_dir.clone(),
@@ -37,6 +37,21 @@ Do not include facts in Answer that are not supported by Evidence used or Action
If a tool result was truncated, partial, or too large to inspect fully, say so under Open uncertainties and do not treat it as complete.\n";
pub(crate) fn append_subagent_role_contract(base_prompt: String, agent_id: &str) -> String {
// `context_scout` defines its own strict output contract (emit a single
// `[context_bundle]` and nothing else). The generic Result Contract here
// (Answer / Evidence used / Actions taken / …) directly conflicts with
// that and can make the scout emit the generic headings instead of the
// bundle — leaving the orchestrator without `has_enough_context` /
// `recommended_tool_calls`. Skip the suffix for the scout; its prompt.md
// already carries the sub-agent framing it needs.
if agent_id == "context_scout" {
tracing::debug!(
agent_id = %agent_id,
"[subagent_runner] skipping role-contract suffix — agent defines its own output contract"
);
return base_prompt;
}
if base_prompt.contains(SUBAGENT_ROLE_CONTRACT_SUFFIX.trim()) {
tracing::debug!(
agent_id = %agent_id,
@@ -104,3 +119,25 @@ pub(crate) fn dedup_tool_specs_by_name(agent_id: &str, specs: Vec<ToolSpec>) ->
}
deduped
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn context_scout_skips_role_contract_suffix() {
// The scout owns its own [context_bundle] output contract; the generic
// result contract must not be appended (it conflicts).
let base = "scout prompt body".to_string();
let out = append_subagent_role_contract(base.clone(), "context_scout");
assert_eq!(out, base);
assert!(!out.contains("Sub-agent Result Contract"));
}
#[test]
fn other_agents_get_role_contract_suffix() {
let out = append_subagent_role_contract("body".to_string(), "researcher");
assert!(out.contains("Sub-agent Result Contract"));
assert!(out.contains("Recommended next step"));
}
}
@@ -339,6 +339,7 @@ fn make_parent(provider: Arc<dyn Provider>, tools: Vec<Box<dyn Tool>>) -> Parent
provider,
all_tools: Arc::new(tools),
all_tool_specs: Arc::new(tool_specs),
visible_tool_names: std::collections::HashSet::new(),
model_name: "test-model".into(),
temperature: 0.5,
workspace_dir: std::env::temp_dir(),
@@ -105,6 +105,12 @@ pub(crate) fn build_text_mode_tool_instructions(_specs: &[ToolSpec]) -> String {
/// emits `delegate_researcher`, `delegate_planner`, …).
/// * custom delegate names that intentionally do not use the `delegate_*`
/// prefix, currently `use_tinyplace`.
/// * `agent_prepare_context` — the context-scout entry point. It reads the
/// *parent's* visible catalog/session via `current_parent()`, which inside a
/// nested run is still the top-level orchestrator (the runner does not
/// install a child-scoped parent context). A wildcard or named sub-agent
/// calling it would therefore scout against the orchestrator's surface, not
/// its own. Context preparation is a top-level concern only.
///
/// Kept as a tight prefix/exact match rather than a registry lookup so
/// the strip is cheap to run inside [`super::ops::run_typed_mode`]'s
@@ -112,7 +118,10 @@ pub(crate) fn build_text_mode_tool_instructions(_specs: &[ToolSpec]) -> String {
/// this function and the corresponding generator in
/// `orchestrator_tools.rs` together.
pub(super) fn is_subagent_spawn_tool(name: &str) -> bool {
name == "spawn_subagent" || name.starts_with("delegate_") || name == "use_tinyplace"
name == "spawn_subagent"
|| name.starts_with("delegate_")
|| name == "use_tinyplace"
|| name == "agent_prepare_context"
}
/// Returns indices into `parent_tools` for the tools the sub-agent may
@@ -185,6 +194,10 @@ mod tests {
assert!(is_subagent_spawn_tool("spawn_subagent"));
assert!(is_subagent_spawn_tool("delegate_researcher"));
assert!(is_subagent_spawn_tool("use_tinyplace"));
// Context scouting is top-level only — never visible to sub-agents
// (incl. wildcard agents), which would otherwise scout the wrong
// parent context. See #3949 review.
assert!(is_subagent_spawn_tool("agent_prepare_context"));
assert!(!is_subagent_spawn_tool("tinyplace_directory_resolve"));
}
}
+1
View File
@@ -275,6 +275,7 @@ async fn dispatch_target_agent(agent_id: &str, prompt: &str) -> anyhow::Result<S
provider: agent.provider_arc(),
all_tools: agent.tools_arc(),
all_tool_specs: agent.tool_specs_arc(),
visible_tool_names: std::collections::HashSet::new(),
model_name: agent.model_name().to_string(),
temperature: agent.temperature(),
workspace_dir: agent.workspace_dir().to_path_buf(),
@@ -134,6 +134,7 @@ fn mock_parent(provider: Arc<dyn Provider>) -> ParentExecutionContext {
provider,
all_tools: Arc::new(Vec::<Box<dyn Tool>>::new()),
all_tool_specs: Arc::new(Vec::<ToolSpec>::new()),
visible_tool_names: std::collections::HashSet::new(),
model_name: "test-model".to_string(),
temperature: 0.0,
workspace_dir: std::env::temp_dir(),
@@ -81,6 +81,7 @@ fn parent_context(provider: Arc<dyn Provider>) -> ParentExecutionContext {
provider,
all_tools: Arc::new(Vec::<Box<dyn Tool>>::new()),
all_tool_specs: Arc::new(Vec::<ToolSpec>::new()),
visible_tool_names: std::collections::HashSet::new(),
model_name: "test-model".to_string(),
temperature: 0.2,
workspace_dir: std::env::temp_dir(),
@@ -89,6 +89,9 @@ pub(crate) async fn build_root_parent(
provider: agent.provider_arc(),
all_tools: agent.tools_arc(),
all_tool_specs: agent.tool_specs_arc(),
// No visibility filter for this spawned/background builder — empty means
// "unknown" and callers fall back to the full registry (see field doc).
visible_tool_names: HashSet::new(),
model_name: agent.model_name().to_string(),
temperature: agent.temperature(),
workspace_dir: agent.workspace_dir().to_path_buf(),
@@ -1,3 +1,5 @@
#[path = "tools/agent_prepare_context.rs"]
mod agent_prepare_context;
#[path = "tools/archetype_delegation.rs"]
mod archetype_delegation;
#[path = "tools/close_subagent.rs"]
@@ -30,6 +32,7 @@ mod worker_thread;
pub(crate) use dispatch::dispatch_subagent;
pub use agent_prepare_context::AgentPrepareContextTool;
pub use archetype_delegation::ArchetypeDelegationTool;
pub use close_subagent::CloseSubagentTool;
pub use continue_subagent::ContinueSubagentTool;
@@ -0,0 +1,414 @@
//! Tool: `agent_prepare_context` — "plan mode as a subagent".
//!
//! Before answering or delegating a non-trivial request, the parent agent
//! (orchestrator / planner) calls `agent_prepare_context`. This runs the
//! read-only `context_scout` sub-agent inline (blocking), which gathers
//! context from memory, the user's goals/profile, connected integrations, and
//! the web, then returns a tight `[context_bundle]` envelope: whether there's
//! enough context to act, a compact context summary, and an ordered set of
//! recommended next tool calls drawn from the *parent's own* tool catalogue.
//!
//! The scout's output is bounded by `context_scout`'s `max_result_chars`
//! (≈1000 tokens) so the parent's context only grows by a bounded amount.
use crate::core::event_bus::{publish_global, DomainEvent};
use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry;
use crate::openhuman::agent::harness::fork_context::current_parent;
use crate::openhuman::agent::harness::subagent_runner::{
run_subagent, SubagentRunOptions, SubagentRunStatus,
};
use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
use async_trait::async_trait;
use serde_json::json;
use std::fmt::Write as _;
/// The sub-agent archetype this tool drives.
const SCOUT_AGENT_ID: &str = "context_scout";
/// Spawns the `context_scout` sub-agent to collect context and propose a plan.
pub struct AgentPrepareContextTool;
impl Default for AgentPrepareContextTool {
fn default() -> Self {
Self::new()
}
}
impl AgentPrepareContextTool {
pub fn new() -> Self {
Self
}
/// Render the parent agent's tool catalogue into a compact
/// `- name: description` list the scout can recommend *back* to the
/// parent. Excludes this tool itself (recommending another scout pass
/// would be circular). Returns an empty string when there's no parent
/// context (e.g. a direct CLI/RPC tool call outside an agent turn) — the
/// subsequent `run_subagent` call surfaces the no-parent error.
///
/// Restricted to the parent's **visible** tool set (what it actually
/// advertises and will execute this turn), not the full registry —
/// otherwise the scout could recommend hidden direct-exec/spawn tools
/// the parent can't call, which the runtime would reject or which would
/// bypass specialist routing. Falls back to the full registry only when
/// the visible set is unknown (empty), to preserve behaviour in contexts
/// that don't populate it.
fn render_parent_tool_catalog() -> String {
let Some(parent) = current_parent() else {
return String::new();
};
let visible = &parent.visible_tool_names;
let mut out = String::with_capacity(2048);
for spec in parent.all_tool_specs.iter() {
if spec.name == "agent_prepare_context" {
continue;
}
if !visible.is_empty() && !visible.contains(&spec.name) {
continue;
}
// One line per tool; trim the description to keep the catalogue
// from dwarfing the scout's own prompt.
let desc: String = spec
.description
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let desc = if desc.chars().count() > 160 {
let cut = desc
.char_indices()
.nth(160)
.map(|(i, _)| i)
.unwrap_or(desc.len());
format!("{}", &desc[..cut])
} else {
desc
};
let _ = writeln!(out, "- {}: {}", spec.name, desc);
}
out
}
/// Build the scout's task prompt: the request, optional focus, and the
/// parent tool catalogue the scout draws its recommendations from.
fn build_scout_prompt(question: &str, focus: Option<&str>, tool_catalog: &str) -> String {
let mut prompt = String::with_capacity(question.len() + tool_catalog.len() + 512);
let _ = writeln!(prompt, "[Request]\n{question}\n");
if let Some(focus) = focus.filter(|f| !f.trim().is_empty()) {
let _ = writeln!(prompt, "[Focus]\n{}\n", focus.trim());
}
if tool_catalog.trim().is_empty() {
prompt.push_str(
"[Orchestrator tools]\n(none available — return an empty \
recommended_tool_calls list)\n",
);
} else {
let _ = writeln!(
prompt,
"[Orchestrator tools]\nThese are the tools the orchestrator can call next. \
Every `recommended_tool_calls[].tool` MUST be one of these exact names:\n{tool_catalog}"
);
}
prompt.push_str(
"\nGather what you need, then emit the single [context_bundle] … \
[/context_bundle] block as specified. Do not answer the request yourself.",
);
prompt
}
}
#[async_trait]
impl Tool for AgentPrepareContextTool {
fn name(&self) -> &str {
"agent_prepare_context"
}
fn description(&self) -> &str {
"Before answering or delegating, scout existing context. Runs a fast \
read-only context-collector that checks memory, your goals/profile, \
connected integrations, and the web, then returns whether there's \
enough context to answer, a compact context summary, and an ordered \
list of recommended next tool calls (your own tools, by exact name, \
with args). Use at the start of non-trivial turns."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"required": ["question"],
"properties": {
"question": {
"type": "string",
"description": "The user's request or goal to gather context for. Be specific — the scout has no memory of your conversation."
},
"focus": {
"type": "string",
"description": "Optional hint that narrows what to scout (e.g. a platform, time window, or sub-question)."
}
}
})
}
fn permission_level(&self) -> PermissionLevel {
// ReadOnly, not Execute: this tool only ever runs the read-only
// `context_scout` (read_only sandbox, no write/exec tools). Marking it
// Execute would make `ToolPolicyEngine` strip it from the provider-
// visible set on a `ReadOnly`-capped channel, which would hide the
// orchestrator's mandatory first-turn context-prep call and either
// skip the pass or surface an unavailable-tool error.
PermissionLevel::ReadOnly
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let question = args
.get("question")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
let focus = args
.get("focus")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
tracing::info!(
target: "agent_prepare_context",
question_chars = question.chars().count(),
has_focus = focus.as_deref().map(|f| !f.trim().is_empty()).unwrap_or(false),
"[agent_prepare_context] invoked"
);
if question.is_empty() {
return Ok(ToolResult::error(
"agent_prepare_context: `question` is required",
));
}
let registry = match AgentDefinitionRegistry::global() {
Some(reg) => reg,
None => {
return Ok(ToolResult::error(
"agent_prepare_context: AgentDefinitionRegistry has not been initialised.",
));
}
};
let definition = match registry.get(SCOUT_AGENT_ID) {
Some(def) => def,
None => {
return Ok(ToolResult::error(format!(
"agent_prepare_context: built-in agent `{SCOUT_AGENT_ID}` is not registered.",
)));
}
};
let tool_catalog = Self::render_parent_tool_catalog();
let catalog_tool_count = tool_catalog.lines().filter(|l| !l.is_empty()).count();
let scout_prompt = Self::build_scout_prompt(&question, focus.as_deref(), &tool_catalog);
tracing::debug!(
target: "agent_prepare_context",
catalog_tool_count,
scout_prompt_chars = scout_prompt.chars().count(),
"[agent_prepare_context] spawning context_scout (blocking)"
);
let task_id = format!("ctx-{}", uuid::Uuid::new_v4());
let parent_session = current_parent()
.map(|p| p.session_id.clone())
.unwrap_or_else(|| "standalone".into());
let progress_sink = current_parent().and_then(|p| p.on_progress.clone());
// Surface the scout as a live subagent row in the parent thread. The
// child's own iterations/tool-calls already stream to this sink from
// inside run_subagent; we bookend them with spawned/completed so the
// UI opens and closes the card. Best-effort — a closed sink is fine.
publish_global(DomainEvent::SubagentSpawned {
parent_session: parent_session.clone(),
agent_id: definition.id.clone(),
mode: "typed".to_string(),
task_id: task_id.clone(),
prompt_chars: scout_prompt.chars().count(),
});
if let Some(ref tx) = progress_sink {
let _ = tx
.send(AgentProgress::SubagentSpawned {
agent_id: definition.id.clone(),
task_id: task_id.clone(),
mode: "typed".to_string(),
dedicated_thread: false,
prompt_chars: scout_prompt.chars().count(),
worker_thread_id: None,
display_name: Some(definition.display_name().to_string()),
})
.await;
}
let options = SubagentRunOptions {
task_id: Some(task_id.clone()),
..Default::default()
};
match run_subagent(definition, &scout_prompt, options).await {
Ok(outcome) => match &outcome.status {
SubagentRunStatus::Completed => {
tracing::info!(
target: "agent_prepare_context",
task_id = %outcome.task_id,
elapsed_ms = outcome.elapsed.as_millis() as u64,
iterations = outcome.iterations,
output_chars = outcome.output.chars().count(),
"[agent_prepare_context] context bundle ready"
);
publish_global(DomainEvent::SubagentCompleted {
parent_session: parent_session.clone(),
task_id: outcome.task_id.clone(),
agent_id: outcome.agent_id.clone(),
elapsed_ms: outcome.elapsed.as_millis() as u64,
output_chars: outcome.output.chars().count(),
iterations: outcome.iterations,
});
if let Some(ref tx) = progress_sink {
let _ = tx
.send(AgentProgress::SubagentCompleted {
agent_id: outcome.agent_id.clone(),
task_id: outcome.task_id.clone(),
elapsed_ms: outcome.elapsed.as_millis() as u64,
iterations: outcome.iterations as u32,
output_chars: outcome.output.chars().count(),
worktree_path: None,
changed_files: Vec::new(),
dirty_status: None,
})
.await;
}
Ok(ToolResult::success(outcome.output))
}
// The scout has no `ask_user_clarification` tool, so this
// branch should not fire — handle defensively rather than
// leaking a confusing checkpoint envelope to the parent.
SubagentRunStatus::AwaitingUser { question, .. } => {
tracing::warn!(
target: "agent_prepare_context",
task_id = %outcome.task_id,
"[agent_prepare_context] scout unexpectedly awaited user input"
);
// Close the domain-event lifecycle too — a SubagentSpawned
// was already published, so emit Completed to avoid a
// dangling spawned state for event-bus consumers.
publish_global(DomainEvent::SubagentCompleted {
parent_session: parent_session.clone(),
task_id: outcome.task_id.clone(),
agent_id: outcome.agent_id.clone(),
elapsed_ms: outcome.elapsed.as_millis() as u64,
output_chars: 0,
iterations: outcome.iterations,
});
if let Some(ref tx) = progress_sink {
let _ = tx
.send(AgentProgress::SubagentCompleted {
agent_id: outcome.agent_id.clone(),
task_id: outcome.task_id.clone(),
elapsed_ms: outcome.elapsed.as_millis() as u64,
iterations: outcome.iterations as u32,
output_chars: 0,
worktree_path: None,
changed_files: Vec::new(),
dirty_status: None,
})
.await;
}
Ok(ToolResult::success(format!(
"[context_bundle]\nhas_enough_context: false\n\
summary: The context scout could not complete without clarification: {question}\n\
recommended_tool_calls:\n[/context_bundle]"
)))
}
},
Err(err) => {
let message = err.to_string();
let error_kind = message
.split(':')
.next()
.map(str::trim)
.unwrap_or("unknown");
tracing::error!(
target: "agent_prepare_context",
error_kind = %error_kind,
"[agent_prepare_context] context_scout run failed"
);
publish_global(DomainEvent::SubagentFailed {
parent_session: parent_session.clone(),
task_id: task_id.clone(),
agent_id: definition.id.clone(),
error: message.clone(),
});
if let Some(ref tx) = progress_sink {
let _ = tx
.send(AgentProgress::SubagentFailed {
agent_id: definition.id.clone(),
task_id: task_id.clone(),
error: message.clone(),
})
.await;
}
Ok(ToolResult::error(format!(
"agent_prepare_context failed: {message}"
)))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_requires_question_and_makes_focus_optional() {
let tool = AgentPrepareContextTool::new();
let schema = tool.parameters_schema();
let required = schema
.get("required")
.and_then(|v| v.as_array())
.expect("schema has required array");
assert!(required.iter().any(|v| v.as_str() == Some("question")));
assert!(
required.iter().all(|v| v.as_str() != Some("focus")),
"focus must be optional"
);
let props = schema.get("properties").expect("schema has properties");
assert!(props.get("question").is_some());
assert!(props.get("focus").is_some());
}
#[test]
fn build_scout_prompt_includes_request_focus_and_catalog() {
let prompt = AgentPrepareContextTool::build_scout_prompt(
"summarise my unread gmail",
Some("last 24h"),
"- delegate_to_integrations_agent: route to a connected integration\n",
);
assert!(prompt.contains("[Request]"));
assert!(prompt.contains("summarise my unread gmail"));
assert!(prompt.contains("[Focus]"));
assert!(prompt.contains("last 24h"));
assert!(prompt.contains("[Orchestrator tools]"));
assert!(prompt.contains("delegate_to_integrations_agent"));
assert!(prompt.contains("[context_bundle]"));
}
#[test]
fn build_scout_prompt_handles_empty_catalog() {
let prompt = AgentPrepareContextTool::build_scout_prompt("do a thing", None, "");
assert!(prompt.contains("(none available"));
assert!(!prompt.contains("[Focus]"));
}
#[tokio::test]
async fn missing_question_returns_error() {
let tool = AgentPrepareContextTool::new();
let result = tool.execute(json!({})).await.unwrap();
assert!(result.is_error);
assert!(result.output().contains("question"));
}
}
@@ -243,6 +243,7 @@ mod tests {
provider: Arc::new(NoopProvider),
all_tools: Arc::new(Vec::new()),
all_tool_specs: Arc::new(Vec::new()),
visible_tool_names: std::collections::HashSet::new(),
model_name: "test-model".into(),
temperature: 0.0,
workspace_dir: workspace_dir.to_path_buf(),
@@ -275,6 +275,7 @@ fn parent_context_with_provider(
provider,
all_tools: Arc::new(Vec::new()),
all_tool_specs: Arc::new(Vec::new()),
visible_tool_names: std::collections::HashSet::new(),
model_name: "test-model".into(),
temperature: 0.2,
workspace_dir: std::env::temp_dir(),
@@ -387,6 +387,7 @@ mod tests {
channel: "test".into(),
all_tools: Arc::new(vec![]),
all_tool_specs: Arc::new(vec![]),
visible_tool_names: std::collections::HashSet::new(),
workflows: Arc::new(vec![]),
memory_context: std::sync::Arc::new(None),
connected_integrations: vec![],
@@ -189,6 +189,7 @@ fn parent_context(
provider,
all_tools: Arc::new(Vec::new()),
all_tool_specs: Arc::new(Vec::new()),
visible_tool_names: std::collections::HashSet::new(),
model_name: "test-model".into(),
temperature: 0.2,
workspace_dir: workspace_dir.to_path_buf(),
@@ -187,6 +187,7 @@ fn mock_parent(provider: Arc<dyn Provider>) -> ParentExecutionContext {
provider,
all_tools: Arc::new(Vec::<Box<dyn Tool>>::new()),
all_tool_specs: Arc::new(Vec::<ToolSpec>::new()),
visible_tool_names: std::collections::HashSet::new(),
model_name: "test-model".to_string(),
temperature: 0.2,
workspace_dir: std::env::temp_dir(),
@@ -0,0 +1,48 @@
id = "context_scout"
display_name = "Context Scout"
when_to_use = "Pre-flight context collector. Reads memory, the user's goals/profile, connected integrations, and the web, then returns a tight context bundle plus a recommended plan of next tool calls. Read-only; produces a structured bundle, not actions."
temperature = 0.3
max_iterations = 6
iteration_policy = "extended"
# ~1000-token cap on the returned bundle. The runner truncates the final
# output to this many characters (char-safe) before handing it back to the
# parent, so the orchestrator's context only ever grows by a bounded amount.
max_result_chars = 4000
sandbox_mode = "read_only"
# Spawn hierarchy: leaf worker. The scout gathers context and stops — it
# never delegates onward. (No `[subagents]` block; loader rejects any
# subagents on a worker tier.)
agent_tier = "worker"
omit_identity = true
omit_memory_context = true
omit_safety_preamble = true
omit_skills_catalog = true
# Keep PROFILE.md (onboarding enrichment — the user's stated goals) and
# MEMORY.md (archivist-curated long-term memory) IN the prompt: the scout's
# whole job is to ground the orchestrator in who the user is and what they
# want, so it must be able to read goals/profile directly.
omit_profile = false
omit_memory_md = false
[model]
# Multi-step gathering loop (recall → maybe fetch → assess), so the agentic
# tier fits — not the slow reasoning tier (the scout is meant to be cheap).
hint = "agentic"
[tools]
# Strictly read-only context-gathering surface. No writes, no shell, no
# delegation — the scout collects and reports.
named = [
# Targeted recall over the memory namespaces (global, background, …).
# NOTE: `memory_tree` is intentionally NOT here. That tool bundles a
# write mode (`ingest_document` → MemoryTreeIngestDocumentTool) under a
# ReadOnly-declared wrapper, so an auto-run, prompt-injectable scout could
# mutate memory ("remember this document") despite running read-only.
# Retrieval-only `memory_recall` covers the scout's needs safely.
"memory_recall",
# Fresh external facts when the question needs them.
"web_search_tool",
"web_fetch",
]
@@ -0,0 +1 @@
pub mod prompt;
@@ -0,0 +1,53 @@
You are the **Context Scout** — a fast, read-only pre-flight agent. The
orchestrator calls you *before* it answers or delegates a non-trivial request.
Your job is to gather just enough context to act, then return a compact bundle
the orchestrator can read at a glance — and tell it which of its own tools to
call next.
## What you do
1. Read the request (and any `[Focus]` the orchestrator passed).
2. Gather only what's actually needed to act on it, drawing on:
- **Memory** — `memory_recall` for relevant facts (search by namespace +
query). This is read-only; you cannot and must not write to memory.
- **Goals / profile** — the user's `PROFILE.md` (their stated goals and
preferences) and `MEMORY.md` are already in your prompt below. Mine them.
- **Connected integrations** — the Connected Integrations section below tells
you which platforms (gmail, notion, slack, …) are actually wired up.
- **The web** — `web_search_tool` / `web_fetch` for fresh external facts the
request genuinely depends on. Skip the web when memory/goals already cover
it; you are meant to be cheap.
3. Stop as soon as you have enough. Do **not** try to answer the request or
perform the task — that is the orchestrator's job.
## What you return
Emit a **single** `[context_bundle] … [/context_bundle]` block and nothing
outside it. No preamble, no closing prose. Use exactly this shape:
```text
[context_bundle]
has_enough_context: true|false
summary: <≤ ~700 tokens of distilled, source-attributed context. Lead with what
matters. Attribute facts: (memory), (profile), (web: <url>), (integrations).>
recommended_tool_calls:
- tool: <exact orchestrator tool name from the "Orchestrator tools" list>
args: <concrete arg values or a tight sketch>
why: <one line>
[/context_bundle]
```
Rules for the bundle:
- `has_enough_context` is `true` when the orchestrator could act now without
more gathering; `false` when key facts are still missing (say which in the
summary).
- Every `recommended_tool_calls[].tool` MUST be an **exact name** from the
"Orchestrator tools" list injected below — these are the tools the
*orchestrator* can call, not the tools you used. Order them in the sequence
the orchestrator should run them.
- If no further tool calls are needed (you already have enough and the answer is
knowledge-based), return an empty `recommended_tool_calls:` list and set
`has_enough_context: true`.
- Keep it tight. The whole bundle is capped — spend the budget on the summary
and the plan, not on hedging.
@@ -0,0 +1,187 @@
//! System prompt builder for the `context_scout` built-in agent.
//!
//! The scout is a read-only pre-flight context collector. Its prompt is the
//! role markdown ([`prompt.md`]) followed by the user-file injection
//! (PROFILE.md = goals, MEMORY.md = curated long-term memory — both kept in
//! because grounding the orchestrator in *who the user is and what they want*
//! is the scout's whole job), the scout's own read-only tool catalogue, and
//! the workspace block. The orchestrator's tool catalogue (the tools the scout
//! recommends *back* to the parent) is injected at spawn time by
//! `AgentPrepareContextTool`, not here — this builder only describes the
//! scout's own gathering surface.
use crate::openhuman::context::prompt::{
render_tools, render_user_files, render_workspace, ConnectedIntegration, PromptContext,
};
use anyhow::Result;
use std::fmt::Write as _;
const ARCHETYPE: &str = include_str!("prompt.md");
/// Render a compact `## Connected Integrations` block listing the platforms
/// the user actually has connected. The scout's role prompt tells it to use
/// this to decide whether a request is serviceable by a connected app, so the
/// builder must emit it (the orchestrator surfaces the same data via its own
/// delegation guide). Empty when nothing is connected.
fn render_connected_integrations(integrations: &[ConnectedIntegration]) -> String {
let connected: Vec<&ConnectedIntegration> =
integrations.iter().filter(|ci| ci.connected).collect();
tracing::trace!(
target: "context_scout",
total = integrations.len(),
connected = connected.len(),
"[context_scout] rendering connected integrations block"
);
if connected.is_empty() {
tracing::trace!(
target: "context_scout",
"[context_scout] no connected integrations — omitting block"
);
return String::new();
}
let mut out = String::from(
"## Connected Integrations\n\nThese platforms are connected and reachable via the \
orchestrator's delegation tools — factor them into the plan when a request needs \
live data from one:\n",
);
for ci in connected {
let _ = writeln!(out, "- {}", ci.toolkit);
}
out
}
pub fn build(ctx: &PromptContext<'_>) -> Result<String> {
tracing::debug!(
target: "context_scout",
agent_id = %ctx.agent_id,
include_profile = ctx.include_profile,
include_memory_md = ctx.include_memory_md,
tool_count = ctx.tools.len(),
"[context_scout] building system prompt"
);
let mut out = String::with_capacity(4096);
out.push_str(ARCHETYPE.trim_end());
out.push_str("\n\n");
// PROFILE.md (goals) + MEMORY.md (long-term memory). Gated on
// `ctx.include_profile` / `ctx.include_memory_md`, which the runner sets
// from the definition's `omit_profile = false` / `omit_memory_md = false`.
let user_files = render_user_files(ctx)?;
if !user_files.trim().is_empty() {
out.push_str(user_files.trim_end());
out.push_str("\n\n");
}
let tools = render_tools(ctx)?;
if !tools.trim().is_empty() {
out.push_str(tools.trim_end());
out.push_str("\n\n");
}
let integrations = render_connected_integrations(ctx.connected_integrations);
if !integrations.trim().is_empty() {
tracing::debug!(
target: "context_scout",
"[context_scout] appended connected-integrations section"
);
out.push_str(integrations.trim_end());
out.push_str("\n\n");
} else {
tracing::debug!(
target: "context_scout",
"[context_scout] no connected-integrations section to append"
);
}
let workspace = render_workspace(ctx)?;
if !workspace.trim().is_empty() {
out.push_str(workspace.trim_end());
out.push('\n');
}
tracing::debug!(
target: "context_scout",
prompt_chars = out.chars().count(),
"[context_scout] system prompt built"
);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat};
use std::collections::HashSet;
fn test_ctx() -> PromptContext<'static> {
// Leak a HashSet so the &reference satisfies the 'static-ish lifetime
// the helper needs in this throwaway test context.
let visible: &'static HashSet<String> = Box::leak(Box::new(HashSet::new()));
PromptContext {
workspace_dir: std::path::Path::new("."),
model_name: "test",
agent_id: "context_scout",
tools: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: visible,
tool_call_format: ToolCallFormat::PFormat,
connected_integrations: &[],
connected_identities_md: String::new(),
include_profile: false,
include_memory_md: false,
curated_snapshot: None,
user_identity: None,
personality_soul_md: None,
personality_memory_md: None,
personality_roster: vec![],
}
}
#[test]
fn build_returns_nonempty_body() {
let body = build(&test_ctx()).unwrap();
assert!(!body.is_empty());
}
#[test]
fn body_describes_the_context_bundle_contract() {
let body = build(&test_ctx()).unwrap();
assert!(body.contains("[context_bundle]"));
assert!(body.contains("has_enough_context"));
assert!(body.contains("recommended_tool_calls"));
}
fn integration(toolkit: &str, connected: bool) -> ConnectedIntegration {
ConnectedIntegration {
toolkit: toolkit.to_string(),
description: String::new(),
tools: vec![],
gated_tools: vec![],
connected,
connections: vec![],
non_active_status: None,
}
}
#[test]
fn render_connected_integrations_lists_only_connected() {
let out = render_connected_integrations(&[
integration("gmail", true),
integration("notion", false),
]);
assert!(out.contains("## Connected Integrations"));
assert!(out.contains("- gmail"));
assert!(
!out.contains("notion"),
"unconnected toolkits must be omitted"
);
}
#[test]
fn render_connected_integrations_empty_when_none_connected() {
assert!(render_connected_integrations(&[integration("gmail", false)]).is_empty());
assert!(render_connected_integrations(&[]).is_empty());
}
}
@@ -153,6 +153,11 @@ pub const BUILTINS: &[BuiltinAgent] = &[
toml: include_str!("researcher/agent.toml"),
prompt_fn: super::researcher::prompt::build,
},
BuiltinAgent {
id: "context_scout",
toml: include_str!("context_scout/agent.toml"),
prompt_fn: super::context_scout::prompt::build,
},
BuiltinAgent {
id: "critic",
toml: include_str!("critic/agent.toml"),
@@ -921,6 +926,79 @@ mod tests {
assert_eq!(def.trigger_memory_agent, TriggerMemoryAgent::Never);
}
#[test]
fn orchestrator_exposes_agent_prepare_context_planner_does_not() {
// The orchestrator owns the first-message context-scout pass.
let orch = find("orchestrator");
match &orch.tools {
ToolScope::Named(tools) => assert!(
tools.iter().any(|t| t == "agent_prepare_context"),
"orchestrator must allowlist `agent_prepare_context`"
),
ToolScope::Wildcard => {}
}
// The planner must NOT: when invoked via delegate_plan it runs under
// the orchestrator's PARENT_CONTEXT, so a nested scout would render the
// wrong (orchestrator) visible catalog/session.
let planner = find("planner");
if let ToolScope::Named(tools) = &planner.tools {
assert!(
!tools.iter().any(|t| t == "agent_prepare_context"),
"planner must NOT allowlist `agent_prepare_context` (nested-context mismatch)"
);
}
// The scout itself must NOT see the tool (would be circular).
let scout = find("context_scout");
if let ToolScope::Named(tools) = &scout.tools {
assert!(!tools.iter().any(|t| t == "agent_prepare_context"));
}
}
#[test]
fn context_scout_is_read_only_worker_with_bounded_output() {
let def = find("context_scout");
assert_eq!(def.agent_tier, AgentTier::Worker);
assert_eq!(def.sandbox_mode, SandboxMode::ReadOnly);
// ~1000-token bundle cap — load-bearing for the parent's context budget.
assert_eq!(def.max_result_chars, Some(4000));
// Keeps goals/profile + long-term memory so it can ground the
// orchestrator in who the user is and what they want.
assert!(!def.omit_profile, "context_scout needs PROFILE.md (goals)");
assert!(!def.omit_memory_md, "context_scout needs MEMORY.md");
// Strictly read-only gathering surface — no writes / shell / delegation.
match &def.tools {
ToolScope::Named(tools) => {
for required in ["memory_recall", "web_search_tool", "web_fetch"] {
assert!(
tools.iter().any(|t| t == required),
"context_scout needs read-only gathering tool `{required}`"
);
}
for forbidden in [
"shell",
"file_write",
"spawn_subagent",
"spawn_async_subagent",
"agent_prepare_context",
// memory_tree bundles a write mode (ingest_document) under a
// ReadOnly wrapper — must not be reachable by the auto-run scout.
"memory_tree",
] {
assert!(
!tools.iter().any(|t| t == forbidden),
"context_scout must NOT have `{forbidden}` — it only gathers context"
);
}
}
ToolScope::Wildcard => panic!("context_scout must have a Named tool scope"),
}
// Worker leaf: no onward delegation.
assert!(
def.subagents.is_empty(),
"context_scout is a leaf and must not list subagents"
);
}
#[test]
fn researcher_has_curl_for_artifact_downloads() {
let def = find("researcher");
@@ -7,6 +7,7 @@ mod loader;
pub mod account_admin_agent;
pub mod archivist;
pub mod code_executor;
pub mod context_scout;
pub mod critic;
pub mod crypto_agent;
pub mod desktop_control_agent;
@@ -181,6 +181,11 @@ hint = "chat"
named = [
"read_workspace_state",
"ask_user_clarification",
# "Plan mode as a subagent": before answering or delegating a non-trivial
# request, the orchestrator calls `agent_prepare_context` to run the
# read-only `context_scout`, which returns a bounded context bundle plus a
# recommended plan of next tool calls drawn from this allowlist.
"agent_prepare_context",
"spawn_worker_thread",
"spawn_async_subagent",
"spawn_parallel_agents",
@@ -14,6 +14,11 @@ You are the **Orchestrator**, the senior agent in a multi-agent system. Your rol
Follow this sequence for every user message:
0. **First message of a new conversation? Prepare context first.**
- If this is the user's **first message in the thread** (there are no prior assistant turns above), call `agent_prepare_context` **once** with `question` set to the user's request, **before** doing anything else below.
- It runs a fast read-only scout over memory, your goals/profile, connected integrations, and the web, and returns a `[context_bundle]` with `has_enough_context`, a compact `summary`, and `recommended_tool_calls`.
- Use the `summary` to ground your reply, and treat `recommended_tool_calls` as a **suggested** plan — you decide whether to follow each one (route them through the normal delegation paths below; never bypass the approval gate).
- This is a one-time pass: call it **at most once per conversation**, only on the first message. On every later message skip straight to step 1.
1. **Can I answer directly without tools?**
- Yes: reply directly (small talk, simple Q&A, basic factual answers).
- No: continue.
@@ -41,6 +41,12 @@ hint = "reasoning"
# actions slip past the gate.
named = [
"file_read",
# NOTE: `agent_prepare_context` is intentionally NOT here. When the planner
# runs via `delegate_plan` it executes under the orchestrator's
# PARENT_CONTEXT (run_subagent filters the child tool surface but does not
# install a planner-scoped ParentExecutionContext), so a nested scout would
# render the orchestrator's visible catalog/session, not the planner's.
# Context scouting is the orchestrator's first-message job; keep it there.
# Read-only nav primitives from #1208. `edit`, `apply_patch`, `lsp`
# are intentionally NOT included — sandbox_mode = "read_only" above
# forbids workspace mutations, and downstream agents do the writing.
+6
View File
@@ -115,6 +115,12 @@ const RESOURCE_CATALOG: &[PromptResource] = &[
description: "Worker that searches the web and synthesises research findings.",
content: include_str!("../agent_registry/agents/researcher/prompt.md"),
},
PromptResource {
uri: "openhuman://prompts/agents/context_scout",
name: "context_scout",
description: "Read-only pre-flight worker that gathers context (memory, goals, integrations, web) and returns a bounded context bundle.",
content: include_str!("../agent_registry/agents/context_scout/prompt.md"),
},
PromptResource {
uri: "openhuman://prompts/agents/critic",
name: "critic",
+5
View File
@@ -167,6 +167,11 @@ pub fn all_tools_with_runtime(
// `agent::harness::subagent_runner` for the dispatch path.
Box::new(SpawnSubagentTool::new()),
Box::new(SpawnAsyncSubagentTool::new()),
// "Plan mode as a subagent": runs the read-only `context_scout`
// inline and returns a bounded context bundle + recommended next
// tool calls. Visible only to agents that allowlist it
// (orchestrator / planner).
Box::new(AgentPrepareContextTool::new()),
// Steer/list/close reusable async sub-agents and collect results by
// durable `subagent_session_id` (preferred) or transient `task_id`.
Box::new(ListSubagentsTool::new()),
@@ -277,6 +277,7 @@ fn parent_context(workspace: &Path, provider: Arc<ScriptedProvider>) -> ParentEx
provider,
all_tools: Arc::new(tools),
all_tool_specs: Arc::new(specs),
visible_tool_names: std::collections::HashSet::new(),
model_name: "round21-parent-model".to_string(),
temperature: 0.0,
workspace_dir: workspace.to_path_buf(),
+127
View File
@@ -795,6 +795,133 @@ async fn subagent_delegation_happy_path_inner() {
stack.shutdown();
}
// ─── agent_prepare_context: context-scout happy path ──────────────────────────
//
// Tool surface (src/openhuman/agent_orchestration/tools/agent_prepare_context.rs,
// src/openhuman/agent_registry/agents/context_scout/agent.toml):
// - `agent_prepare_context` is a DIRECT tool on the orchestrator's [tools].named
// list (not a delegate). It runs the read-only `context_scout` sub-agent
// inline and returns the scout's `[context_bundle]` envelope as the tool
// result, which the orchestrator then synthesizes from.
//
// Actual LLM request ordering (with registry init):
// request[0] = orchestrator → model returns { tool_calls: [agent_prepare_context(...)] }
// request[1] = context_scout subagent inner loop → model returns the bundle text
// request[2] = orchestrator synthesis → final text, having read the bundle
/// Orchestrator calls `agent_prepare_context`; the `context_scout` subagent runs
/// its own inner LLM call and returns a `[context_bundle]`; the final
/// orchestrator synthesis reads it. Three upstream requests prove the full
/// scout path ran, and the synthesis request carries the bundle as a tool result.
#[test]
fn agent_prepare_context_happy_path() {
run_on_agent_stack(
"agent_prepare_context_happy_path",
agent_prepare_context_happy_path_inner,
);
}
async fn agent_prepare_context_happy_path_inner() {
let _lock = env_lock();
let scout_bundle = "[context_bundle]\n\
has_enough_context: true\n\
summary: CTX_CANARY_7 — the user wants the marker phrase (memory).\n\
recommended_tool_calls:\n\
\x20 - tool: spawn_worker_thread\n\
\x20 args: {\"prompt\": \"act on the marker\"}\n\
\x20 why: execute the prepared plan\n\
[/context_bundle]";
reset_script(vec![
// request[0]: Orchestrator calls the direct `agent_prepare_context` tool.
tool_call_completion(
"agent_prepare_context",
json!({ "question": "find the marker phrase" }),
),
// request[1]: context_scout subagent inner LLM call returns the bundle.
text_completion(scout_bundle),
// request[2]: Orchestrator reads the bundle and synthesizes.
text_completion("Prepared. CTX_CANARY_7 noted; next I'd spawn a worker."),
]);
let stack = boot_stack().await;
let mut events = spawn_sse_collector(format!(
"{}/events?client_id=harness-prepctx",
stack.rpc_base
));
send_web_chat(
&stack.rpc_base,
400,
"harness-prepctx",
"thread-prepctx",
"prepare context for the marker",
)
.await;
let done = wait_for_terminal(&mut events, Duration::from_secs(120)).await;
assert_eq!(
done.get("event").and_then(Value::as_str),
Some("chat_done"),
"expected chat_done for agent_prepare_context: {done}"
);
let full_response = done
.get("full_response")
.and_then(Value::as_str)
.unwrap_or_else(|| panic!("chat_done missing 'full_response': {done}"));
assert!(
full_response.contains("CTX_CANARY_7"),
"final response missing scout canary; full_response: {full_response}\nevent: {done}"
);
let requests = with_captured(|c| c.clone());
assert!(
requests.len() >= 3,
"expected ≥3 upstream requests (orchestrator + context_scout + synthesis), got {};\n{}",
requests.len(),
serde_json::to_string_pretty(&requests).unwrap_or_default()
);
let all_serialized = serde_json::to_string(&requests).unwrap_or_default();
assert!(
!all_serialized.contains("Unknown tool:"),
"found 'Unknown tool:' — agent_prepare_context was not registered/visible; requests: {}",
serde_json::to_string_pretty(&requests).unwrap_or_default()
);
// The synthesis turn must carry the scout's bundle back as a tool result —
// proves the [context_bundle] flowed into the orchestrator's context.
let last_messages = serde_json::to_string(
requests
.last()
.and_then(|r| r.pointer("/body/messages"))
.unwrap_or(&Value::Null),
)
.unwrap_or_default();
assert!(
last_messages.contains("[context_bundle]") && last_messages.contains("CTX_CANARY_7"),
"synthesis request missing the scout bundle as a tool result; messages: {last_messages}"
);
// request[1] (context_scout) must build a different context than request[0]
// (orchestrator) — proves a genuinely separate scout agent ran.
let req0_sys = requests
.first()
.and_then(|r| r.pointer("/body/messages/0/content"))
.and_then(Value::as_str)
.unwrap_or("");
let req1_sys = requests
.get(1)
.and_then(|r| r.pointer("/body/messages/0/content"))
.and_then(Value::as_str)
.unwrap_or("");
assert_ne!(
req0_sys, req1_sys,
"request[0] and request[1] share identical first-message content — \
context_scout did not build its own context"
);
stack.shutdown();
}
// ─── Task 4: Subagent clarification flow ──────────────────────────────────────
//
// Exercises the ask_user_clarification path via scheduler_agent
@@ -385,6 +385,7 @@ fn parent_context(workspace: PathBuf, provider: Arc<ScriptedProvider>) -> Parent
provider,
all_tools: Arc::new(tools),
all_tool_specs: Arc::new(specs),
visible_tool_names: std::collections::HashSet::new(),
model_name: "round19-parent".to_string(),
temperature: 0.0,
workspace_dir: workspace,
+1
View File
@@ -133,6 +133,7 @@ fn stub_parent_context() -> ParentExecutionContext {
provider: Arc::new(StubProvider),
all_tools: Arc::new(vec![]),
all_tool_specs: Arc::new(vec![]),
visible_tool_names: std::collections::HashSet::new(),
model_name: "stub-model".into(),
temperature: 0.4,
workspace_dir: std::path::PathBuf::from("/tmp"),
+1
View File
@@ -277,6 +277,7 @@ fn parent_context(workspace: PathBuf, provider: Arc<ScriptedProvider>) -> Parent
provider,
all_tools: Arc::new(tools),
all_tool_specs: Arc::new(tool_specs),
visible_tool_names: std::collections::HashSet::new(),
model_name: "coverage-model".to_string(),
temperature: 0.0,
workspace_dir: workspace,
@@ -317,6 +317,7 @@ fn parent(workspace_dir: PathBuf, provider: Arc<ScriptedProvider>) -> ParentExec
provider,
all_tools: Arc::new(tools),
all_tool_specs: Arc::new(specs),
visible_tool_names: std::collections::HashSet::new(),
model_name: "round25-parent-model".to_string(),
temperature: 0.0,
workspace_dir,
@@ -283,6 +283,7 @@ fn parent(workspace: PathBuf, provider: Arc<ScriptedProvider>) -> ParentExecutio
provider,
all_tools: Arc::new(tools),
all_tool_specs: Arc::new(specs),
visible_tool_names: std::collections::HashSet::new(),
model_name: "round18-model".to_string(),
temperature: 0.0,
workspace_dir: workspace,
@@ -900,6 +900,7 @@ async fn subagent_runner_parent_context_filters_tools_caps_output_and_reports_er
provider: provider.clone(),
all_tools: Arc::new(all_tools),
all_tool_specs: Arc::new(all_specs),
visible_tool_names: std::collections::HashSet::new(),
model_name: "parent-model".to_string(),
temperature: 0.22,
workspace_dir: workspace_path.clone(),
+1
View File
@@ -167,6 +167,7 @@ async fn test_integrations_agent_has_current_date_context() -> Result<()> {
provider: provider.clone(),
all_tools: Arc::new(vec![Box::new(MockCalendarTool)]),
all_tool_specs: Arc::new(vec![MockCalendarTool.spec()]),
visible_tool_names: std::collections::HashSet::new(),
model_name: "test-model".into(),
temperature: 0.4,
workspace_dir: std::env::temp_dir(),
@@ -343,6 +343,7 @@ async fn drive_subagent() {
provider: provider.clone(),
all_tools: Arc::new(vec![]),
all_tool_specs: Arc::new(vec![]),
visible_tool_names: std::collections::HashSet::new(),
model_name: "test-model".into(),
temperature: 0.4,
workspace_dir: std::env::temp_dir(),
@@ -361,6 +361,7 @@ fn parent_context(workspace: PathBuf, provider: Arc<ScriptedProvider>) -> Parent
provider,
all_tools: Arc::new(tools),
all_tool_specs: Arc::new(tool_specs),
visible_tool_names: std::collections::HashSet::new(),
model_name: "round16-model".to_string(),
temperature: 0.0,
workspace_dir: workspace,