From 4c2277070f64e1fd10c3bc1be76809a28d82b0ff Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:22:38 -0700 Subject: [PATCH] feat(agent): agent_prepare_context tool + context_scout subagent (#3949) --- ...lineFormatting.agentPrepareContext.test.ts | 32 + app/src/utils/toolTimelineFormatting.ts | 8 + scripts/debug/agent-prepare-context-audit.mjs | 650 ++++++++++++++++++ scripts/debug/cli.sh | 6 + src/openhuman/agent/harness/fork_context.rs | 10 + .../agent/harness/session/turn/tools.rs | 11 + .../harness/subagent_runner/ops/prompt.rs | 37 + .../harness/subagent_runner/ops_tests.rs | 1 + .../harness/subagent_runner/tool_prep.rs | 15 +- src/openhuman/agent/triage/escalation.rs | 1 + .../agent_teams/runtime_tests.rs | 1 + .../agent_orchestration/ops_tests.rs | 1 + .../agent_orchestration/parent_context/mod.rs | 3 + src/openhuman/agent_orchestration/tools.rs | 3 + .../tools/agent_prepare_context.rs | 414 +++++++++++ .../tools/close_subagent.rs | 1 + .../tools/spawn_parallel_agents_tests.rs | 1 + .../tools/spawn_worker_thread.rs | 1 + .../tools/tools_e2e_tests.rs | 1 + .../workflow_runs/engine_tests.rs | 1 + .../agents/context_scout/agent.toml | 48 ++ .../agents/context_scout/mod.rs | 1 + .../agents/context_scout/prompt.md | 53 ++ .../agents/context_scout/prompt.rs | 187 +++++ src/openhuman/agent_registry/agents/loader.rs | 78 +++ src/openhuman/agent_registry/agents/mod.rs | 1 + .../agents/orchestrator/agent.toml | 5 + .../agents/orchestrator/prompt.md | 5 + .../agent_registry/agents/planner/agent.toml | 6 + src/openhuman/mcp_server/resources.rs | 6 + src/openhuman/tools/ops.rs | 5 + ...rchivist_debug_round21_raw_coverage_e2e.rs | 1 + tests/agent_harness_e2e.rs | 127 ++++ ...gent_harness_leftovers_raw_coverage_e2e.rs | 1 + tests/agent_harness_public.rs | 1 + tests/agent_harness_raw_coverage_e2e.rs | 1 + tests/agent_large_round25_raw_coverage_e2e.rs | 1 + ...agent_prompts_subagent_raw_coverage_e2e.rs | 1 + tests/agent_session_turn_raw_coverage_e2e.rs | 1 + tests/calendar_grounding_e2e.rs | 1 + ...io_list_tools_stack_overflow_regression.rs | 1 + ...gent_credentials_state_raw_coverage_e2e.rs | 1 + 42 files changed, 1729 insertions(+), 1 deletion(-) create mode 100644 app/src/utils/toolTimelineFormatting.agentPrepareContext.test.ts create mode 100644 scripts/debug/agent-prepare-context-audit.mjs create mode 100644 src/openhuman/agent_orchestration/tools/agent_prepare_context.rs create mode 100644 src/openhuman/agent_registry/agents/context_scout/agent.toml create mode 100644 src/openhuman/agent_registry/agents/context_scout/mod.rs create mode 100644 src/openhuman/agent_registry/agents/context_scout/prompt.md create mode 100644 src/openhuman/agent_registry/agents/context_scout/prompt.rs diff --git a/app/src/utils/toolTimelineFormatting.agentPrepareContext.test.ts b/app/src/utils/toolTimelineFormatting.agentPrepareContext.test.ts new file mode 100644 index 000000000..1c95178b5 --- /dev/null +++ b/app/src/utils/toolTimelineFormatting.agentPrepareContext.test.ts @@ -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 & { 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'); + }); +}); diff --git a/app/src/utils/toolTimelineFormatting.ts b/app/src/utils/toolTimelineFormatting.ts index 4dbf427d6..16061a8f0 100644 --- a/app/src/utils/toolTimelineFormatting.ts +++ b/app/src/utils/toolTimelineFormatting.ts @@ -11,6 +11,7 @@ interface ParsedToolArgs { pattern?: string; query?: string; tool_name?: string; + question?: string; } const TOOL_DISPLAY_NAMES: Record = { @@ -69,6 +70,7 @@ const TOOL_DISPLAY_NAMES: Record = { 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 }; } diff --git a/scripts/debug/agent-prepare-context-audit.mjs b/scripts/debug/agent-prepare-context-audit.mjs new file mode 100644 index 000000000..cbb32e69b --- /dev/null +++ b/scripts/debug/agent-prepare-context-audit.mjs @@ -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 JSON-RPC endpoint (default: OPENHUMAN_CORE_RPC_URL or ${DEFAULT_RPC_URL}) + --token RPC bearer (default: OPENHUMAN_CORE_TOKEN or /core.token) + --workspace Workspace whose session_raw transcripts are read + --model Optional model_override passed to openhuman.inference_agent_chat + --query 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 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 Thread id prefix to isolate transcripts (default: random) + --max-print-chars Truncate printed bundle/thought blocks (default: 4000) + --rpc-timeout-ms 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); +}); diff --git a/scripts/debug/cli.sh b/scripts/debug/cli.sh index c29f8988e..2acb99014 100755 --- a/scripts/debug/cli.sh +++ b/scripts/debug/cli.sh @@ -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" "$@" ;; diff --git a/src/openhuman/agent/harness/fork_context.rs b/src/openhuman/agent/harness/fork_context.rs index 6c25e7b38..f82e080be 100644 --- a/src/openhuman/agent/harness/fork_context.rs +++ b/src/openhuman/agent/harness/fork_context.rs @@ -52,6 +52,16 @@ pub struct ParentExecutionContext { /// provider for prefix-cache reuse. pub all_tool_specs: Arc>, + /// 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, + /// Model name the parent is currently using (after classification). pub model_name: String, diff --git a/src/openhuman/agent/harness/session/turn/tools.rs b/src/openhuman/agent/harness/session/turn/tools.rs index 1720fe5e4..a960aef12 100644 --- a/src/openhuman/agent/harness/session/turn/tools.rs +++ b/src/openhuman/agent/harness/session/turn/tools.rs @@ -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(), diff --git a/src/openhuman/agent/harness/subagent_runner/ops/prompt.rs b/src/openhuman/agent/harness/subagent_runner/ops/prompt.rs index 1c9117b15..42f4d8103 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/prompt.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/prompt.rs @@ -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) -> } 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")); + } +} diff --git a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs index a7f8dc87d..49683926f 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs @@ -339,6 +339,7 @@ fn make_parent(provider: Arc, tools: Vec>) -> 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(), diff --git a/src/openhuman/agent/harness/subagent_runner/tool_prep.rs b/src/openhuman/agent/harness/subagent_runner/tool_prep.rs index 83d0da55a..6122ce7ce 100644 --- a/src/openhuman/agent/harness/subagent_runner/tool_prep.rs +++ b/src/openhuman/agent/harness/subagent_runner/tool_prep.rs @@ -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")); } } diff --git a/src/openhuman/agent/triage/escalation.rs b/src/openhuman/agent/triage/escalation.rs index 9524cea88..411a383a2 100644 --- a/src/openhuman/agent/triage/escalation.rs +++ b/src/openhuman/agent/triage/escalation.rs @@ -275,6 +275,7 @@ async fn dispatch_target_agent(agent_id: &str, prompt: &str) -> anyhow::Result) -> ParentExecutionContext { 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".to_string(), temperature: 0.0, workspace_dir: std::env::temp_dir(), diff --git a/src/openhuman/agent_orchestration/ops_tests.rs b/src/openhuman/agent_orchestration/ops_tests.rs index 35bc87387..589ee17f9 100644 --- a/src/openhuman/agent_orchestration/ops_tests.rs +++ b/src/openhuman/agent_orchestration/ops_tests.rs @@ -81,6 +81,7 @@ fn parent_context(provider: Arc) -> ParentExecutionContext { 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".to_string(), temperature: 0.2, workspace_dir: std::env::temp_dir(), diff --git a/src/openhuman/agent_orchestration/parent_context/mod.rs b/src/openhuman/agent_orchestration/parent_context/mod.rs index cb261d35d..50351ee21 100644 --- a/src/openhuman/agent_orchestration/parent_context/mod.rs +++ b/src/openhuman/agent_orchestration/parent_context/mod.rs @@ -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(), diff --git a/src/openhuman/agent_orchestration/tools.rs b/src/openhuman/agent_orchestration/tools.rs index 9f8656c6b..4bd27e30b 100644 --- a/src/openhuman/agent_orchestration/tools.rs +++ b/src/openhuman/agent_orchestration/tools.rs @@ -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; diff --git a/src/openhuman/agent_orchestration/tools/agent_prepare_context.rs b/src/openhuman/agent_orchestration/tools/agent_prepare_context.rs new file mode 100644 index 000000000..f9f95d227 --- /dev/null +++ b/src/openhuman/agent_orchestration/tools/agent_prepare_context.rs @@ -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::>() + .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 { + 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")); + } +} diff --git a/src/openhuman/agent_orchestration/tools/close_subagent.rs b/src/openhuman/agent_orchestration/tools/close_subagent.rs index dc83b29f2..dbd04af29 100644 --- a/src/openhuman/agent_orchestration/tools/close_subagent.rs +++ b/src/openhuman/agent_orchestration/tools/close_subagent.rs @@ -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(), diff --git a/src/openhuman/agent_orchestration/tools/spawn_parallel_agents_tests.rs b/src/openhuman/agent_orchestration/tools/spawn_parallel_agents_tests.rs index 098b42267..f59174d0e 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_parallel_agents_tests.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_parallel_agents_tests.rs @@ -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(), diff --git a/src/openhuman/agent_orchestration/tools/spawn_worker_thread.rs b/src/openhuman/agent_orchestration/tools/spawn_worker_thread.rs index 252a8376c..d6aa24306 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_worker_thread.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_worker_thread.rs @@ -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![], diff --git a/src/openhuman/agent_orchestration/tools/tools_e2e_tests.rs b/src/openhuman/agent_orchestration/tools/tools_e2e_tests.rs index 2a1aa85c4..ea7a4f2c0 100644 --- a/src/openhuman/agent_orchestration/tools/tools_e2e_tests.rs +++ b/src/openhuman/agent_orchestration/tools/tools_e2e_tests.rs @@ -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(), diff --git a/src/openhuman/agent_orchestration/workflow_runs/engine_tests.rs b/src/openhuman/agent_orchestration/workflow_runs/engine_tests.rs index 95724d2d6..5670454eb 100644 --- a/src/openhuman/agent_orchestration/workflow_runs/engine_tests.rs +++ b/src/openhuman/agent_orchestration/workflow_runs/engine_tests.rs @@ -187,6 +187,7 @@ fn mock_parent(provider: Arc) -> ParentExecutionContext { 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".to_string(), temperature: 0.2, workspace_dir: std::env::temp_dir(), diff --git a/src/openhuman/agent_registry/agents/context_scout/agent.toml b/src/openhuman/agent_registry/agents/context_scout/agent.toml new file mode 100644 index 000000000..c2bb5b2d6 --- /dev/null +++ b/src/openhuman/agent_registry/agents/context_scout/agent.toml @@ -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", +] diff --git a/src/openhuman/agent_registry/agents/context_scout/mod.rs b/src/openhuman/agent_registry/agents/context_scout/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent_registry/agents/context_scout/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/context_scout/prompt.md b/src/openhuman/agent_registry/agents/context_scout/prompt.md new file mode 100644 index 000000000..36acbf768 --- /dev/null +++ b/src/openhuman/agent_registry/agents/context_scout/prompt.md @@ -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: ), (integrations).> +recommended_tool_calls: + - tool: + args: + why: +[/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. diff --git a/src/openhuman/agent_registry/agents/context_scout/prompt.rs b/src/openhuman/agent_registry/agents/context_scout/prompt.rs new file mode 100644 index 000000000..aa3287acf --- /dev/null +++ b/src/openhuman/agent_registry/agents/context_scout/prompt.rs @@ -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 { + 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 = 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()); + } +} diff --git a/src/openhuman/agent_registry/agents/loader.rs b/src/openhuman/agent_registry/agents/loader.rs index b9d2da8e3..0f30801a7 100644 --- a/src/openhuman/agent_registry/agents/loader.rs +++ b/src/openhuman/agent_registry/agents/loader.rs @@ -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"); diff --git a/src/openhuman/agent_registry/agents/mod.rs b/src/openhuman/agent_registry/agents/mod.rs index 6c0f25c43..40b9be3ed 100644 --- a/src/openhuman/agent_registry/agents/mod.rs +++ b/src/openhuman/agent_registry/agents/mod.rs @@ -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; diff --git a/src/openhuman/agent_registry/agents/orchestrator/agent.toml b/src/openhuman/agent_registry/agents/orchestrator/agent.toml index b86fb70ab..0245629e4 100644 --- a/src/openhuman/agent_registry/agents/orchestrator/agent.toml +++ b/src/openhuman/agent_registry/agents/orchestrator/agent.toml @@ -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", diff --git a/src/openhuman/agent_registry/agents/orchestrator/prompt.md b/src/openhuman/agent_registry/agents/orchestrator/prompt.md index 0fc4422cf..a3e327564 100644 --- a/src/openhuman/agent_registry/agents/orchestrator/prompt.md +++ b/src/openhuman/agent_registry/agents/orchestrator/prompt.md @@ -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. diff --git a/src/openhuman/agent_registry/agents/planner/agent.toml b/src/openhuman/agent_registry/agents/planner/agent.toml index 4cf5d31d4..5d50505a9 100644 --- a/src/openhuman/agent_registry/agents/planner/agent.toml +++ b/src/openhuman/agent_registry/agents/planner/agent.toml @@ -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. diff --git a/src/openhuman/mcp_server/resources.rs b/src/openhuman/mcp_server/resources.rs index e529884e3..ba8df4e96 100644 --- a/src/openhuman/mcp_server/resources.rs +++ b/src/openhuman/mcp_server/resources.rs @@ -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", diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index f0dd59f7e..0c5cafca2 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -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()), diff --git a/tests/agent_archivist_debug_round21_raw_coverage_e2e.rs b/tests/agent_archivist_debug_round21_raw_coverage_e2e.rs index 40a9bf228..9cc8913bc 100644 --- a/tests/agent_archivist_debug_round21_raw_coverage_e2e.rs +++ b/tests/agent_archivist_debug_round21_raw_coverage_e2e.rs @@ -277,6 +277,7 @@ fn parent_context(workspace: &Path, provider: Arc) -> 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(), diff --git a/tests/agent_harness_e2e.rs b/tests/agent_harness_e2e.rs index 7e5ff5406..4a2d79bda 100644 --- a/tests/agent_harness_e2e.rs +++ b/tests/agent_harness_e2e.rs @@ -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 diff --git a/tests/agent_harness_leftovers_raw_coverage_e2e.rs b/tests/agent_harness_leftovers_raw_coverage_e2e.rs index 5bb55a24e..12a7a1682 100644 --- a/tests/agent_harness_leftovers_raw_coverage_e2e.rs +++ b/tests/agent_harness_leftovers_raw_coverage_e2e.rs @@ -385,6 +385,7 @@ fn parent_context(workspace: PathBuf, provider: Arc) -> 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, diff --git a/tests/agent_harness_public.rs b/tests/agent_harness_public.rs index cf6111f04..fe0d570cd 100644 --- a/tests/agent_harness_public.rs +++ b/tests/agent_harness_public.rs @@ -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"), diff --git a/tests/agent_harness_raw_coverage_e2e.rs b/tests/agent_harness_raw_coverage_e2e.rs index 3c844217e..d022813da 100644 --- a/tests/agent_harness_raw_coverage_e2e.rs +++ b/tests/agent_harness_raw_coverage_e2e.rs @@ -277,6 +277,7 @@ fn parent_context(workspace: PathBuf, provider: Arc) -> 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, diff --git a/tests/agent_large_round25_raw_coverage_e2e.rs b/tests/agent_large_round25_raw_coverage_e2e.rs index d9a5b738c..5d3750ca9 100644 --- a/tests/agent_large_round25_raw_coverage_e2e.rs +++ b/tests/agent_large_round25_raw_coverage_e2e.rs @@ -317,6 +317,7 @@ fn parent(workspace_dir: PathBuf, provider: Arc) -> 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, diff --git a/tests/agent_prompts_subagent_raw_coverage_e2e.rs b/tests/agent_prompts_subagent_raw_coverage_e2e.rs index 564dd5afd..f7beb6bd6 100644 --- a/tests/agent_prompts_subagent_raw_coverage_e2e.rs +++ b/tests/agent_prompts_subagent_raw_coverage_e2e.rs @@ -283,6 +283,7 @@ fn parent(workspace: PathBuf, provider: Arc) -> 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, diff --git a/tests/agent_session_turn_raw_coverage_e2e.rs b/tests/agent_session_turn_raw_coverage_e2e.rs index 437ff3648..9ba4b4eb0 100644 --- a/tests/agent_session_turn_raw_coverage_e2e.rs +++ b/tests/agent_session_turn_raw_coverage_e2e.rs @@ -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(), diff --git a/tests/calendar_grounding_e2e.rs b/tests/calendar_grounding_e2e.rs index 3adcd35d8..a08517b02 100644 --- a/tests/calendar_grounding_e2e.rs +++ b/tests/calendar_grounding_e2e.rs @@ -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(), diff --git a/tests/composio_list_tools_stack_overflow_regression.rs b/tests/composio_list_tools_stack_overflow_regression.rs index 6d7dc625c..1992f2c74 100644 --- a/tests/composio_list_tools_stack_overflow_regression.rs +++ b/tests/composio_list_tools_stack_overflow_regression.rs @@ -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(), diff --git a/tests/tools_agent_credentials_state_raw_coverage_e2e.rs b/tests/tools_agent_credentials_state_raw_coverage_e2e.rs index e4bb94b88..60c985077 100644 --- a/tests/tools_agent_credentials_state_raw_coverage_e2e.rs +++ b/tests/tools_agent_credentials_state_raw_coverage_e2e.rs @@ -361,6 +361,7 @@ fn parent_context(workspace: PathBuf, provider: Arc) -> 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,