From f3fb374f0df698b0e6e5a3395fe127f9d71969be Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 01:04:56 -0700 Subject: [PATCH] Return compact search_agents results by default Default output is id, description, and spawn metadata so a director lookup does not dump every profile body into the parent transcript. Pass include_body=true to include the loaded system prompt, still truncated. --- CHANGELOG.md | 7 ++ docs/ARCHITECTURE.md | 2 +- docs/PLUGINS.md | 5 +- src/agent/agent-search.test.ts | 141 ++++++++++++++++++++++++--------- src/agent/agent-search.ts | 32 +++++--- src/agent/prompts.ts | 4 +- src/subagent/agent-fleet.ts | 4 +- 7 files changed, 138 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33a22184e..10e8ba460 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ## [Unreleased] +### Changed + +- `search_agents` default results are id, description, and spawn metadata. + Pass `include_body=true` to include the loaded system prompt / body + (still truncated). + ### Fixed - Dry-fleet transcript and `/status` report the outcome tally only @@ -24,6 +30,7 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename `buzzing`, `grinding`, `thinking`, `doing`, `cooking`, `creating`, `imagining`, `inventing`) instead of going blank. + ## [0.3.19] - 2026-09-10 ### Security diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 364ce82ce..b532f898d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -216,7 +216,7 @@ Workers ask the spawning parent with **`ask_director`** (not the human). That pa When the parent TUI is not blocked in `wait_agents`, the runner publishes an authoritative snapshot of currently pending top-level questions on each store notification, including empty snapshots before fleet-count updates. During synchronous session rotation, a runner-owned barrier suppresses both publications before delivery-generation invalidation, transcript clearing, and worker cancellation; successful reset reconciles a fresh snapshot before resuming asynchronous backend rebuild. The bridge drops resolved, cancelled, replaced, terminal, and removed asks and delivers each session/question identity once while pending. A coalesced wake starts only when the parent is not processing and every operator gate is closed, including parent-idle fleet holds where the shell stays busy. Worker gates do not manufacture parent processing. Replies use `send_input`'s `target` field with the worker session ID, never its shared catalog ID. Synthetic wakes use `SessionPort.deliver` through queued-delivery's idle-send path without entering the user follow-up queue or composer `/feedback` capture. -When profiles exist (local `.agents/agents/` and/or enabled **`kind: "agent"`** plugins, including **data-only** markdown plugins with no `index.ts`), the chat model also receives **`search_agents`** — a lexical index over profile id, description, and role text so the model can discover ids before calling `spawn_agent(agent=...)`. Results include each match's full loaded system prompt / body so the parent can inspect plugin or Claude marketplace agents without `read_file` on paths outside the session cwd (path-escape blocks those roots by design; writes remain blocked). `spawn_agent` and `search_agents` are core tools on the primary session. +When profiles exist (local `.agents/agents/` and/or enabled **`kind: "agent"`** plugins, including **data-only** markdown plugins with no `index.ts`), the chat model also receives **`search_agents`** — a lexical index over profile id, description, and role text so the model can discover ids before calling `spawn_agent(agent=...)`. Default results are id, description, and spawn metadata (orchestrator flag, source). Pass `include_body=true` to include each match's loaded system prompt / body (truncated) so the parent can inspect plugin or Claude marketplace agents without `read_file` on paths outside the session cwd (path-escape blocks those roots by design; writes remain blocked). `spawn_agent` and `search_agents` are core tools on the primary session. Built-in directors with `spawn.maySpawn` may themselves call `spawn_agent` (one hop only): nested dispatch installs the mailbox-scoped fleet verbs (`spawn_agent`, `wait_agents`, `list_agents`, …) with `allowOrchestrator: false` so the tree bottoms out. Profile-sourced `orchestrator: true` is rejected before a session starts because it has no trusted tier/authority semantics today. Fleet discovery (`search_agents`) stays Tier 1 only. Unknown `agent` ids fail closed. diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index e582d76d5..11beedc4f 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -170,8 +170,9 @@ Relative install paths and paths outside that root are ignored so a poisoned registry cannot load project trees as origin `user`. Profile `source: "claude"`. Discovered modules still require `settings.plugins[id].enabled` before agents or tools wire into the session. `search_agents` labels those profiles with -`[source: claude]` and injects each profile's full loaded system prompt / body so -the parent never needs `read_file` on `~/.claude/plugins/...` (path-escape still +`[source: claude]`. Default results are id, description, and spawn metadata; +pass `include_body=true` to include the loaded system prompt / body so the +parent never needs `read_file` on `~/.claude/plugins/...` (path-escape still blocks those roots for path tools; writes/deletes outside cwd stay denied). JS Claude plugins (if any) stay on explicit `pluginPaths`. diff --git a/src/agent/agent-search.test.ts b/src/agent/agent-search.test.ts index 2782cc283..700cebc08 100644 --- a/src/agent/agent-search.test.ts +++ b/src/agent/agent-search.test.ts @@ -45,46 +45,75 @@ describe("createAgentIndex", () => { describe("formatAgentSearchResults", () => { test("includes spawn hint and ids", () => { - const text = formatAgentSearchResults([defined(fixtures[1])]); + const text = formatAgentSearchResults([defined(fixtures[1])], false); expect(text).toContain("critique"); expect(text).toContain("spawn_agent(agent="); }); test("includes source label when present", () => { - const text = formatAgentSearchResults([ - { - id: "marketplace-scout", - description: "From Claude install", - source: "claude", - }, - ]); + const text = formatAgentSearchResults( + [ + { + id: "marketplace-scout", + description: "From Claude install", + source: "claude", + }, + ], + false, + ); expect(text).toContain("[source: claude]"); expect(text).toContain("marketplace-scout"); }); - test("injects full system prompt body so parent need not read_file plugin roots", () => { + test("default omits system prompt body", () => { + const uniqueBody = + "UNIQUE_BODY_MARKER_draper_review_pull_requests_for_design_clarity_xyz"; + const text = formatAgentSearchResults( + [ + { + id: "draper", + description: "PR design reviewer from marketplace", + source: "claude", + orchestrator: true, + systemPromptRole: uniqueBody, + }, + ], + false, + ); + expect(text).toContain("### draper"); + expect(text).toContain("[source: claude]"); + expect(text).toContain("[orchestrator]"); + expect(text).toContain("PR design reviewer from marketplace"); + expect(text).not.toContain(uniqueBody); + expect(text).not.toContain("System prompt / body:"); + }); + + test("include_body injects truncated system prompt body", () => { const body = "You are draper. Review pull requests for design clarity and maintainability.\n" + "Prefer concrete file/line citations."; - const text = formatAgentSearchResults([ - { - id: "draper", - description: "PR design reviewer from marketplace", - source: "claude", - systemPromptRole: body, - }, - ]); + const text = formatAgentSearchResults( + [ + { + id: "draper", + description: "PR design reviewer from marketplace", + source: "claude", + systemPromptRole: body, + }, + ], + true, + ); expect(text).toContain("### draper"); expect(text).toContain("[source: claude]"); expect(text).toContain("System prompt / body:"); expect(text).toContain(body); - expect(text).toContain("do not need read_file on plugin roots"); }); test("omits body section when systemPromptRole is absent", () => { - const text = formatAgentSearchResults([ - { id: "no-body", description: "Metadata only" }, - ]); + const text = formatAgentSearchResults( + [{ id: "no-body", description: "Metadata only" }], + true, + ); expect(text).toContain("### no-body"); expect(text).toContain("Metadata only"); expect(text).not.toContain("System prompt / body:"); @@ -92,13 +121,16 @@ describe("formatAgentSearchResults", () => { test("truncates oversized systemPromptRole bodies with ellipsis marker", () => { const body = "x".repeat(MAX_AGENT_SEARCH_BODY_CHARS + 500); - const text = formatAgentSearchResults([ - { - id: "huge", - description: "Oversized marketplace body", - systemPromptRole: body, - }, - ]); + const text = formatAgentSearchResults( + [ + { + id: "huge", + description: "Oversized marketplace body", + systemPromptRole: body, + }, + ], + true, + ); expect(text).toContain("System prompt / body:"); expect(text).toContain("…[truncated]"); expect(text).not.toContain(body); @@ -113,14 +145,17 @@ describe("formatAgentSearchResults", () => { test("redacts secret-shaped content in profile body at format layer", () => { // search_agents is not on the posix middleware path; scrub must happen here. const secret = "sk-live-abc123xyz789012345678"; - const text = formatAgentSearchResults([ - { - id: "leaky", - description: "Profile with credential-shaped body text", - source: "claude", - systemPromptRole: `Use API_KEY=${secret} when calling the provider.`, - }, - ]); + const text = formatAgentSearchResults( + [ + { + id: "leaky", + description: "Profile with credential-shaped body text", + source: "claude", + systemPromptRole: `Use API_KEY=${secret} when calling the provider.`, + }, + ], + true, + ); expect(text).toContain("### leaky"); expect(text).toContain("System prompt / body:"); expect(text).toContain(CREDENTIAL_REDACTION); @@ -130,7 +165,35 @@ describe("formatAgentSearchResults", () => { }); describe("createSearchAgentsTool", () => { - test("handler surfaces loaded systemPromptRole for plugin-style profiles", async () => { + test("handler default omits systemPromptRole body", async () => { + const uniqueBody = + "UNIQUE_BODY_MARKER_emil_product_sense_and_user_impact_xyz"; + const tool = createSearchAgentsTool(() => [ + { + id: "emil", + description: "Product-minded reviewer", + source: "claude", + systemPromptRole: uniqueBody, + }, + { + id: "greybeard", + description: "Architect", + systemPromptRole: "You are greybeard.", + }, + ]); + if (tool.kind !== "string") throw new Error("expected string tool"); + const text = await tool.handler( + { query: "emil product" }, + new AbortController().signal, + ); + expect(text).toContain("emil"); + expect(text).toContain("Product-minded reviewer"); + expect(text).toContain("[source: claude]"); + expect(text).not.toContain(uniqueBody); + expect(text).not.toContain("System prompt / body:"); + }); + + test("handler include_body true surfaces loaded systemPromptRole", async () => { const body = "You are emil. Focus on product sense and user impact."; const tool = createSearchAgentsTool(() => [ { @@ -147,7 +210,7 @@ describe("createSearchAgentsTool", () => { ]); if (tool.kind !== "string") throw new Error("expected string tool"); const text = await tool.handler( - { query: "emil product" }, + { query: "emil product", include_body: true }, new AbortController().signal, ); expect(text).toContain("emil"); @@ -199,7 +262,7 @@ describe("createSearchAgentsTool", () => { ]); if (tool.kind !== "string") throw new Error("expected string tool"); const text = await tool.handler( - { query: "leaky" }, + { query: "leaky", include_body: true }, new AbortController().signal, ); expect(text).toContain("### leaky"); diff --git a/src/agent/agent-search.ts b/src/agent/agent-search.ts index ba9168c02..f24b79c84 100644 --- a/src/agent/agent-search.ts +++ b/src/agent/agent-search.ts @@ -72,12 +72,14 @@ function truncateAgentBody(body: string): string { return `${body.slice(0, MAX_AGENT_SEARCH_BODY_CHARS)}\n…[truncated]`; } -// Format one profile for search_agents output. Injects the full loaded -// systemPromptRole (markdown body / role text) so the parent can inspect plugin -// and marketplace agents without read_file on paths outside the session cwd -// (path-escape blocks those roots by design). Bodies longer than +// Format one profile for search_agents output. Default is id, description, and +// spawn metadata (orchestrator flag, source). The loaded systemPromptRole is +// omitted unless includeBody is true. Bodies longer than // MAX_AGENT_SEARCH_BODY_CHARS are truncated with an ellipsis marker. -function formatAgentProfileEntry(p: AgentProfile): string { +function formatAgentProfileEntry( + p: AgentProfile, + includeBody: boolean, +): string { const desc = (p.description ?? "").trim(); const orch = p.orchestrator === true ? " [orchestrator]" : ""; const source = p.source !== undefined ? ` [source: ${p.source}]` : ""; @@ -85,6 +87,7 @@ function formatAgentProfileEntry(p: AgentProfile): string { desc.length > 0 ? `### ${p.id}${orch}${source}\n${desc}` : `### ${p.id}${orch}${source}`; + if (!includeBody) return header; const body = (p.systemPromptRole ?? "").trim(); if (body.length === 0) return header; return `${header}\n\nSystem prompt / body:\n${truncateAgentBody(body)}`; @@ -92,18 +95,19 @@ function formatAgentProfileEntry(p: AgentProfile): string { export function formatAgentSearchResults( profiles: readonly AgentProfile[], + includeBody: boolean, ): string { if (profiles.length === 0) { return "No agent profiles matched. Try broader terms (e.g. review, explore, implement) or list_dir on .agents/agents/."; } - const entries = profiles.map(formatAgentProfileEntry); + const entries = profiles.map((p) => formatAgentProfileEntry(p, includeBody)); // Live scrub for search_agents: this tool is not on the posix middleware path, so // SCRUBBABLE_TOOLS in tool-result-secret-scrub-plugin cannot reach it. Scrub here // before the formatted string becomes a tool result (marketplace/plugin bodies may // contain secret-shaped substrings). return scrubSecretShapedContent( [ - "Matching agent profiles (pass id to spawn_agent(agent=...)). Full system prompt / body is included so you do not need read_file on plugin roots outside the workspace:", + "Matching agent profiles (pass id to spawn_agent(agent=...)):", "", ...entries.flatMap((entry, i) => (i === 0 ? [entry] : ["", entry])), "", @@ -115,7 +119,7 @@ export function formatAgentSearchResults( export const searchAgentsDefinition: ToolDefinition = { name: "search_agents", description: - "Find spawnable agent profiles by capability, role, or team name (e.g. 'review', 'review team', 'architect', 'security'). Returns profile ids, descriptions, and the full loaded system prompt / body for each match so you can inspect plugin or Claude marketplace agents without reading files outside the workspace. Use the id in spawn_agent(agent=...). Call this when the user asks to spin up specialists or a team without naming exact ids.", + "Find spawnable agent profiles by capability, role, or team name (e.g. 'review', 'review team', 'architect', 'security'). Returns profile ids, descriptions, and spawn metadata (orchestrator flag, source). Pass include_body=true to include the loaded system prompt / body for each match (truncated). Use the id in spawn_agent(agent=...). Call this when the user asks to spin up specialists or a team without naming exact ids.", inputSchema: { type: "object", properties: { @@ -124,12 +128,17 @@ export const searchAgentsDefinition: ToolDefinition = { description: "What kind of agent or team you need — keywords from the user's request (e.g. 'review team', 'code quality', 'explore codebase').", }, + include_body: { + type: "boolean", + description: + "When true, include each match's loaded system prompt / body (truncated). Default false: id, description, and spawn metadata only.", + }, }, required: ["query"], }, }; -const SearchAgentsArgs = type({ query: "string" }); +const SearchAgentsArgs = type({ query: "string", "include_body?": "boolean" }); export function createSearchAgentsTool( getProfiles: () => readonly AgentProfile[], @@ -140,15 +149,16 @@ export function createSearchAgentsTool( handler: async (rawArgs: Record): Promise => { const parsed = SearchAgentsArgs(rawArgs); if (parsed instanceof type.errors) { - return "Error: search_agents requires query (string)."; + return "Error: search_agents requires query (string); include_body is optional boolean."; } const query = parsed.query.trim(); + const includeBody = parsed.include_body === true; // Empty and non-empty queries share createAgentIndex.search's default limit // (12) so a large marketplace catalog cannot dump every body into one result. if (query.length === 0 && getProfiles().length === 0) { return "No agent profiles are loaded."; } - return formatAgentSearchResults(index.search(query)); + return formatAgentSearchResults(index.search(query), includeBody); }, }); } diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index 42cf388ff..d4af211cd 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -93,7 +93,7 @@ export function buildHarnessFacts( ...(dynamicTools ? [ "- Core tools plus the advertised catalog (including skill_search) are resident. Use tool_search to load extra capabilities from plugins or integrations when needed.", - "- Use search_agents before dispatching named specialists or teams (results include full profile bodies; do not read_file plugin paths outside the workspace).", + "- Use search_agents before dispatching named specialists or teams (ids and descriptions by default; include_body=true for the loaded system prompt). Do not read_file plugin paths outside the workspace.", "- The user may send follow-up messages while workers run; they are queued. Enter delivers at the next parent tool.boundary; Alt+Enter on session-idle. A long parent tool holds that boundary. Update your plan, spawn or adjust workers, and keep the operator informed.", ] : ["- The tools below are your full toolset."]), @@ -245,7 +245,7 @@ const TOOL_SUMMARIES: Record = { wait_agents: "wait for spawned workers by agent_id; returns awaiting_director when a worker asks, without collecting that session", search_agents: - "find agent profiles by role or team before spawning with spawn_agent(agent=...); results include full system prompt / body so you need not read_file plugin roots outside the workspace", + "find agent profiles by role or team before spawning with spawn_agent(agent=...); default results are id, description, and spawn metadata — pass include_body=true for the loaded system prompt / body", manage_tasks: "maintain your work checklist — create/replace, update status, append, cancel", ask_director: diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index 5ca457fe2..39dab0f6b 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -768,8 +768,8 @@ function resolveAgentDispatch(input: { const known = profiles.map((p) => p.id).sort(); const hint = known.length > 0 - ? ` Known profiles: ${known.join(", ")}. Call search_agents to discover more (results include full system prompt / body; do not read_file plugin paths outside the workspace).` - : " No profiles are currently loaded. Call search_agents to discover available agents (results include full system prompt / body)."; + ? ` Known profiles: ${known.join(", ")}. Call search_agents to discover more.` + : " No profiles are currently loaded. Call search_agents to discover available agents."; return { error: `Error: unknown agent profile "${agentId}".${hint}` }; } let effortPin: ReasoningEffort | undefined;