Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 3 additions & 2 deletions docs/PLUGINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
141 changes: 102 additions & 39 deletions src/agent/agent-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,60 +45,92 @@ 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:");
});

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);
Expand All @@ -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);
Expand All @@ -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(() => [
{
Expand All @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
32 changes: 21 additions & 11 deletions src/agent/agent-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,38 +72,42 @@ 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}]` : "";
const header =
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)}`;
}

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])),
"",
Expand All @@ -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: {
Expand All @@ -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[],
Expand All @@ -140,15 +149,16 @@ export function createSearchAgentsTool(
handler: async (rawArgs: Record<string, unknown>): Promise<string> => {
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);
},
});
}
4 changes: 2 additions & 2 deletions src/agent/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."]),
Expand Down Expand Up @@ -245,7 +245,7 @@ const TOOL_SUMMARIES: Record<string, string> = {
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:
Expand Down
4 changes: 2 additions & 2 deletions src/subagent/agent-fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading