mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(agent): agent_prepare_context tool + context_scout subagent (#3949)
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user