From 0bac02c5ab30968dd8f90fddb9a6112c1181d216 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 3 Oct 2026 08:56:41 -0700 Subject: [PATCH 1/3] fix(subagent): type submit_result and sanitize Grok tool schemas Grok workers inherit the parent xAI profile then 400 on first infer with an empty Bad Request. submit_result advertised an untyped result property, which the Responses proxy rejects, and inherited MCP schemas can carry $schema. Give result a JSON Schema type and sanitize Grok wire schemas so one bad tool cannot kill the infer. --- src/agent/tool-schema-normalize.test.ts | 62 ++++++++++++++++++- src/agent/tool-schema-normalize.ts | 80 ++++++++++++++++++++++--- src/subagent/run.ts | 6 +- 3 files changed, 139 insertions(+), 9 deletions(-) diff --git a/src/agent/tool-schema-normalize.test.ts b/src/agent/tool-schema-normalize.test.ts index 15bc5b55b..084e17ef5 100644 --- a/src/agent/tool-schema-normalize.test.ts +++ b/src/agent/tool-schema-normalize.test.ts @@ -1,5 +1,6 @@ import { defined } from "../../testkit/defined.js"; import { describe, expect, test } from "bun:test"; +import type { ToolDefinition } from "@intx/types/runtime"; import { presentDefinition } from "./director.js"; import { manageTasksDefinition } from "./tasks.js"; import { @@ -123,7 +124,6 @@ describe("normalizeToolDefinitionsForProvider", () => { for (const ctx of [ { providerName: "anthropic", model: "claude-sonnet-4" }, { providerName: "openai", model: "gpt-5.6" }, - { providerName: "xai/default", model: "grok-4.5" }, { providerName: "opencode-go", model: "gpt-5.1" }, ] as const) { const out = normalizeToolDefinitionsForProvider(defs, ctx); @@ -134,6 +134,66 @@ describe("normalizeToolDefinitionsForProvider", () => { } }); + test("grok sanitizes untyped properties and strips $schema", () => { + const untyped: ToolDefinition = { + name: "submit_result", + description: "test", + inputSchema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + turn_token: { type: "string" }, + result: { description: "The structured result payload." }, + }, + required: ["turn_token", "result"], + }, + }; + const out = normalizeToolDefinitionsForProvider([untyped], { + providerName: "xai/default-2", + model: "grok-4.6", + }); + const schema = defined(out[0]).inputSchema as { + $schema?: unknown; + properties?: { + result?: { type?: string; additionalProperties?: boolean }; + }; + }; + expect(schema.$schema).toBeUndefined(); + expect(schema.properties?.result?.type).toBe("object"); + expect(schema.properties?.result?.additionalProperties).toBe(true); + }); + + test("grok sanitizer leaves anthropic identity and typed properties alone", () => { + const typed: ToolDefinition = { + name: "ask_director", + description: "test", + inputSchema: { + type: "object", + properties: { + question: { type: "string", description: "q" }, + }, + required: ["question"], + }, + }; + const input = [typed]; + const grok = normalizeToolDefinitionsForProvider(input, { + providerName: "xai/default", + model: "grok-4.6", + }); + const anthropic = normalizeToolDefinitionsForProvider(input, { + providerName: "anthropic", + model: "claude-sonnet-4", + }); + expect(anthropic).toBe(input); + expect(grok).not.toBe(input); + const grokQuestion = ( + defined(grok[0]).inputSchema as { + properties?: { question?: { type?: string } }; + } + ).properties?.question; + expect(grokQuestion?.type).toBe("string"); + }); + test("kimi rewrite leaves non-present tools untouched", () => { const out = normalizeToolDefinitionsForProvider(defs, { providerName: "moonshot", diff --git a/src/agent/tool-schema-normalize.ts b/src/agent/tool-schema-normalize.ts index 1306efd99..ad1d5a447 100644 --- a/src/agent/tool-schema-normalize.ts +++ b/src/agent/tool-schema-normalize.ts @@ -1,5 +1,8 @@ import type { ToolDefinition } from "@intx/types/runtime"; -import { isKimiLeafProvider } from "../subagent/provider-family.js"; +import { + isKimiLeafProvider, + isXaiGrokLeafProvider, +} from "../subagent/provider-family.js"; /** Context used to decide whether a provider needs wire-schema rewrites. */ export interface NormalizeToolDefsContext { @@ -190,10 +193,68 @@ function rewritePresentForKimi(def: ToolDefinition): ToolDefinition { }; } +const OPEN_OBJECT = { + type: "object", + additionalProperties: true, +} as const; + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function schemaDeclaresShape(node: Record): boolean { + return ( + node["type"] !== undefined || + node["$ref"] !== undefined || + node["oneOf"] !== undefined || + node["anyOf"] !== undefined || + node["allOf"] !== undefined + ); +} + +/** + * Grok's Responses proxy 400s the whole infer on one invalid tool schema + * (empty body, statusText "Bad Request"). Untyped properties and `$schema` + * are the shapes MCP tools and submit_result historically advertised. + */ +function sanitizeSchemaForGrok(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sanitizeSchemaForGrok); + if (!isPlainObject(value)) return value; + const next: Record = {}; + for (const [key, child] of Object.entries(value)) { + if (key === "$schema" || key === "$id") continue; + if (key === "properties" && isPlainObject(child)) { + const props: Record = {}; + for (const [name, prop] of Object.entries(child)) { + const sanitized = sanitizeSchemaForGrok(prop); + if (isPlainObject(sanitized) && !schemaDeclaresShape(sanitized)) { + props[name] = { ...sanitized, ...OPEN_OBJECT }; + } else { + props[name] = sanitized; + } + } + next[key] = props; + continue; + } + next[key] = sanitizeSchemaForGrok(child); + } + return next; +} + +function sanitizeToolDefForGrok(def: ToolDefinition): ToolDefinition { + return { + ...def, + inputSchema: sanitizeSchemaForGrok( + structuredClone(def.inputSchema), + ) as ToolDefinition["inputSchema"], + }; +} + /** * Family-gated wire rewrite of tool definitions before they reach the director / - * provider. Today only Moonshot/kimi get a non-recursive `present` schema; - * other providers receive the definitions unchanged (identity). + * provider. Moonshot/kimi get a non-recursive `present` schema. Grok/xAI get + * schema sanitization so an untyped property or `$schema` cannot 400 the + * infer. Other providers receive the definitions unchanged (identity). * * Does not alter runtime validation or the canonical `presentDefinition` used * as the source of truth for non-kimi advertise paths. @@ -206,10 +267,15 @@ export function normalizeToolDefinitionsForProvider( defs: readonly ToolDefinition[], ctx: NormalizeToolDefsContext, ): ToolDefinition[] { - if (!isKimiLeafProvider(ctx)) { + const kimi = isKimiLeafProvider(ctx); + const grok = isXaiGrokLeafProvider(ctx); + if (!kimi && !grok) { return defs as ToolDefinition[]; } - return defs.map((def) => - def.name === "present" ? rewritePresentForKimi(def) : def, - ); + return defs.map((def) => { + let next = def; + if (kimi && def.name === "present") next = rewritePresentForKimi(next); + if (grok) next = sanitizeToolDefForGrok(next); + return next; + }); } diff --git a/src/subagent/run.ts b/src/subagent/run.ts index e028a91f3..a1dd2f43c 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -568,7 +568,11 @@ const submitResultDefinition: ToolDefinition = { type: "string", description: "Turn token from the dispatch brief.", }, - result: { description: "The structured result payload." }, + result: { + type: "object", + additionalProperties: true, + description: "The structured result payload.", + }, }, required: ["turn_token", "result"], }, From 1dcd438f0b69bbb8c945330f1feb1596903fd532 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 3 Oct 2026 09:33:51 -0700 Subject: [PATCH 2/3] fix(subagent): advertise a small worker preset plus tool_search Workers were putting every mounted tool on the wire, including all inherited MCP schemas. Dispatch already uses a CORE+CATALOG prefix and discovers the rest via tool_search. Give leaves the same shape: a smaller preset than dispatch, tool_search, and promote-on-execute. MCP stays mounted for execute but off the wire until search. --- src/agent/tool-search.test.ts | 32 +++++++++++++ src/agent/tool-search.ts | 46 +++++++++++++++++++ src/subagent/nudge-director.ts | 8 ++++ src/subagent/run.ts | 84 +++++++++++++++++++++++++++++----- 4 files changed, 159 insertions(+), 11 deletions(-) diff --git a/src/agent/tool-search.test.ts b/src/agent/tool-search.test.ts index 7b4d73316..85d3ad148 100644 --- a/src/agent/tool-search.test.ts +++ b/src/agent/tool-search.test.ts @@ -9,6 +9,7 @@ import { createActivatedToolTracker, advertisedTools, advertisedToolNamesForSessionMode, + advertisedToolNamesForWorker, coreToolNamesForSessionMode, CORE_TOOL_NAMES, CATALOG_TOOL_NAMES, @@ -953,6 +954,37 @@ describe("promote-on-execute", () => { }); }); +describe("advertisedToolNamesForWorker", () => { + test("leaf prefix is smaller than dispatch and includes tool_search", () => { + const leaf = advertisedToolNamesForWorker({ orchestrator: false }); + const dispatch = advertisedToolNamesForSessionMode( + "orchestrator", + FULL_AVAILABILITY, + ); + expect(leaf).toContain("tool_search"); + expect(leaf).toContain("ask_director"); + expect(leaf).toContain("submit_result"); + expect(leaf).toContain("skill_search"); + expect(leaf).toContain("read"); + expect(leaf).toContain("bash"); + expect(leaf).not.toContain("spawn_agent"); + expect(leaf).not.toContain("search_agents"); + expect(leaf).not.toContain("ask_operator"); + expect(leaf).not.toContain("wait_agents"); + expect(leaf.every((name) => !name.startsWith("mcp__"))).toBe(true); + expect(leaf.length).toBeLessThan(dispatch.length); + }); + + test("nested orchestrator adds fleet verbs but not search_agents", () => { + const orch = advertisedToolNamesForWorker({ orchestrator: true }); + expect(orch).toContain("spawn_agent"); + expect(orch).toContain("send_input"); + expect(orch).toContain("tool_search"); + expect(orch).not.toContain("search_agents"); + expect(orch).not.toContain("ask_operator"); + }); +}); + describe("advertisedTools", () => { const registry: ToolDefinition[] = [ { diff --git a/src/agent/tool-search.ts b/src/agent/tool-search.ts index 9ca035156..d0aeb285d 100644 --- a/src/agent/tool-search.ts +++ b/src/agent/tool-search.ts @@ -120,6 +120,52 @@ export function advertisedToolNamesForSessionMode( ]; } +const WORKER_LEAF_EXTRA: readonly string[] = [ + "ask_director", + "submit_result", + "skill_search", +]; + +const WORKER_ORCH_EXTRA: readonly string[] = [ + "spawn_agent", + "list_agents", + "close_agent", + "resume_agent", + "interrupt_agent", + "send_input", +]; + +/** + * Advertised wire prefix for a spawned worker. Smaller than dispatch: no + * `ask_operator`, no `search_agents`, no fleet verbs on leaves. Nested + * orchestrators add the fleet verbs they actually mount. MCP stays off this + * list until tool_search / promote-on-execute. + */ +export function advertisedToolNamesForWorker(opts: { + orchestrator: boolean; + languageServerAvailable?: boolean; +}): readonly string[] { + const core = CORE_TOOL_NAMES.filter((name) => { + if (name === "ask_operator") return false; + if (name === "search_agents") return false; + if (name === "wait_agents") return false; + if (name === "lsp") return opts.languageServerAvailable === true; + if (ORCHESTRATOR_ONLY_TOOL_NAMES.includes(name)) return opts.orchestrator; + return true; + }); + const extra = opts.orchestrator + ? [...WORKER_LEAF_EXTRA, ...WORKER_ORCH_EXTRA] + : WORKER_LEAF_EXTRA; + const seen = new Set(); + const out: string[] = []; + for (const name of [...core, ...CATALOG_TOOL_NAMES, ...extra]) { + if (seen.has(name)) continue; + seen.add(name); + out.push(name); + } + return out; +} + // Built-in file/search/web tools advertised alongside the core set. They carry full // schemas on the wire so the model can call them directly; MCP tools are not // listed at all — they are discovered blind via tool_search. diff --git a/src/subagent/nudge-director.ts b/src/subagent/nudge-director.ts index b8e34b87c..8fa21625a 100644 --- a/src/subagent/nudge-director.ts +++ b/src/subagent/nudge-director.ts @@ -130,6 +130,7 @@ export class SubAgentDirector extends DefaultDirector { private readonly compaction: CompactionGovernor; private readonly retryPolicy: RetryPolicy; private readonly _systemPrompt: string; + private advertisedTools: ToolDefinition[]; /** When true (CritiqueDirector), empty readCounts is not a successful complete. */ private readonly requireEvidence: boolean; /** When true (planner / intent=plan), stub plan Findings is not a complete. */ @@ -226,6 +227,11 @@ export class SubAgentDirector extends DefaultDirector { this.isAskPending = isPending; } + /** Replace the advertised wire set after tool_search / promote-on-execute. */ + updateToolDefinitions(toolDefinitions: ToolDefinition[]): void { + this.advertisedTools = toolDefinitions; + } + /** Run state every intervention record carries, for judging it afterwards. */ private interventionState(): { turnsCompleted: number; @@ -261,6 +267,7 @@ export class SubAgentDirector extends DefaultDirector { : systemPrompt; super(composedPrompt, toolDefinitions, {}); this._systemPrompt = composedPrompt; + this.advertisedTools = toolDefinitions; this.compaction = createCompactionGovernor( requestContinuation, composedPrompt, @@ -288,6 +295,7 @@ export class SubAgentDirector extends DefaultDirector { ...(options ?? {}), retryPolicy: options?.retryPolicy ?? this.retryPolicy, systemPrompt: options?.systemPrompt ?? this._systemPrompt, + tools: this.advertisedTools, }), }; // A real parent follow-up re-opens the brief; empty continuations do not. diff --git a/src/subagent/run.ts b/src/subagent/run.ts index a1dd2f43c..a2ba5dbfe 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -42,7 +42,10 @@ import { } from "../config/inference-sources.js"; import { readSourceCredentialMaterial } from "../config/source-credentials.js"; import { sanitizeDiagnosticValue } from "../diagnostic-sanitize.js"; -import { assembleInferenceBase } from "../session/assemble-runtime.js"; +import { + assembleInferenceBase, + createAdvertisedToolset, +} from "../session/assemble-runtime.js"; import { advertiseShellGuardTimeout } from "../plugins/shell-guard-plugin.js"; import { advertiseEditFileLineRange } from "../plugins/edit-file-line-range.js"; import { createWebFetchTool } from "../tools/web-fetch.js"; @@ -65,7 +68,6 @@ import { NOOP_INTERVENTION_SINK, type InterventionSink, } from "./intervention-log.js"; -import { normalizeToolDefinitionsForProvider } from "../agent/tool-schema-normalize.js"; import { canonicalToolName } from "../agent/canonical-tool-name.js"; import { isMcpToolName } from "../mcp/tool-name.js"; import { createApplyPatchTool } from "../agent/apply-patch-tool.js"; @@ -82,6 +84,11 @@ import { toolProfileForModel, withAuthzParityDefinitions, } from "../agent/tool-aliases.js"; +import { + advertisedToolNamesForWorker, + createToolIndex, + createToolSearchTool, +} from "../agent/tool-search.js"; import { buildCompactionContinuationMessage, @@ -1120,6 +1127,53 @@ async function runSubAgentInner( } } + const workerPrefix = advertisedToolNamesForWorker({ + orchestrator: params.orchestrator === true, + }); + const advertisedSet = createAdvertisedToolset({ + sessionMode: "orchestrator", + toolAvailability: { + languageServerAvailable: false, + operatorAvailable: false, + }, + getProvider: () => ({ + providerName: params.provider.providerName, + model: params.provider.model, + }), + builtInPrefix: workerPrefix, + ...(params.requiresTools !== undefined && params.requiresTools.length > 0 + ? { pinnedTools: params.requiresTools } + : {}), + }); + const runnerHolder: { + current?: ReturnType; + } = {}; + let workerDirector: SubAgentDirector | undefined; + const allMountedDefs = (): ToolDefinition[] => + runnerHolder.current?.currentDefinitions() ?? + tools.map((tool) => tool.definition); + const refreshAdvertised = (): void => { + advertisedSet.flushPromotions(); + workerDirector?.updateToolDefinitions( + advertisedSet.computeAdvertised(allMountedDefs()), + ); + }; + const toolIndex = createToolIndex(allMountedDefs, [ + ...workerPrefix, + ...(params.requiresTools ?? []), + ]); + tools = [ + ...tools, + createToolSearchTool({ + search: (query, limit) => toolIndex.search(query, limit), + lookup: (name) => allMountedDefs().find((def) => def.name === name), + promote: (names) => { + advertisedSet.activated.activate(names); + refreshAdvertised(); + }, + }), + ]; + tools = wrapAgentToolsWithResultTruncation(tools, { getBlobWriter: () => childBlobWriter, getContextDir: () => childContextDir, @@ -1143,12 +1197,15 @@ async function runSubAgentInner( ...(attachedSection !== undefined ? [attachedSection] : []), ]; const toolProfile = toolProfileForModel(params.provider); + const advertisedDefs = advertisedSet.computeAdvertised( + tools.map((t) => t.definition), + ); // The prompt lists what the wire carries: on gpt the file tools fold into // apply_patch, which is already mounted, so names can repeat. const toolNames = [ ...new Set( foldFileToolNames( - tools.map((t) => t.definition.name), + advertisedDefs.map((d) => d.name), toolProfile, ).map((name) => advertisedToolName(name, toolProfile)), ), @@ -1193,24 +1250,20 @@ async function runSubAgentInner( // modelFamilyPolicy is resolved above at the skill mount; reused here for // stall timing and wire-schema normalization. - // Family-gate wire schemas the same way main sessions do (kimi present rewrite). - // Sub-agent toolsets currently omit `present` (main-session only); normalize is - // still applied so a future present on leaf tools cannot reintroduce $ref cycles. + // Family-gate wire schemas the same way main sessions do. Worker advertise + // uses a smaller prefix plus tool_search; computeAdvertised already + // normalizes for kimi/grok. const directorDef = defineDirector({ id: `${ID_PREFIX}/subagent`, configSchema: type({}), factory: (_config, _env, agentCtx) => { const director = new SubAgentDirector( agentCtx.systemPrompt, - normalizeToolDefinitionsForProvider( + advertisedSet.computeAdvertised( projectToolDefinitions( foldFileToolDefinitions(agentCtx.toolDefinitions, toolProfile), toolProfile, ), - { - providerName: params.provider.providerName, - model: params.provider.model, - }, ), requestContinuation, modelFamilyPolicy.subAgentStallTimeoutMs, @@ -1227,6 +1280,7 @@ async function runSubAgentInner( director.observeForcedStop((reason) => { directorForcedStopReason = reason; }); + workerDirector = director; director.observeAskPending(() => askDirectorState.pending); director.observeInterventions((event) => { interventions(event); @@ -1275,6 +1329,14 @@ async function runSubAgentInner( tools, toolWatchdogFromSettings(params.settings), ); + runnerHolder.current = runner; + runner.setCallGate(advertisedSet.isAdvertised, { + isActivated: (name) => advertisedSet.activated.has(name), + }); + runner.setOnUndeclaredCall((name) => { + advertisedSet.activated.activate([name]); + refreshAdvertised(); + }); return withAuthzParityDefinitions({ ...runner, run: (call, signal) => From b5801c0e41b44f3a99a598253cabf28cb69b76d3 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 3 Oct 2026 10:32:10 -0700 Subject: [PATCH 3/3] fix(subagent): advertise per-director tool allowlists on workers The worker wire prefix is the director package allowlist plus tool_search and the leaf reporting channel, not one shared preset. Explorer is READ_TOOLS (no path writes). Coder/docs/orchestrator keep their own envelopes. apply_patch still folds via tool profile. --- src/agent/directors/explorer/package.ts | 4 +-- src/agent/directors/registry.test.ts | 13 +++++--- src/agent/directors/tool-sets.ts | 2 +- src/agent/tool-search.test.ts | 36 ++++++++++++++++----- src/agent/tool-search.ts | 43 +++++++++---------------- src/subagent/run.ts | 4 ++- 6 files changed, 58 insertions(+), 44 deletions(-) diff --git a/src/agent/directors/explorer/package.ts b/src/agent/directors/explorer/package.ts index 9eeffe5ac..10d524c4e 100644 --- a/src/agent/directors/explorer/package.ts +++ b/src/agent/directors/explorer/package.ts @@ -1,5 +1,5 @@ import type { DirectorPackage } from "../types.js"; -import { REVIEW_TOOLS } from "../tool-sets.js"; +import { READ_TOOLS } from "../tool-sets.js"; /** * Explorer worker (CL-7020 / CL-7015 rename from explore). @@ -33,7 +33,7 @@ FINDINGS SHAPE: Findings must be a scannable map — key paths, symbols, call fl FINISH BIAS: Prefer one thorough pass then report. Expand Findings, change approach, or write the final report — do not keep re-reading the same paths. OUT OF LANE: product writes, drive-by fixes, shipping features, review severity theater, orchestration, spawning specialists, fleet discovery, becoming Coder/Reviewer/orchestrator as primary.`, - tools: { allow: REVIEW_TOOLS }, + tools: { allow: READ_TOOLS }, spawn: { maySpawn: false }, tier: "leaf", modelRole: "explore", diff --git a/src/agent/directors/registry.test.ts b/src/agent/directors/registry.test.ts index 46fb4743e..69a810002 100644 --- a/src/agent/directors/registry.test.ts +++ b/src/agent/directors/registry.test.ts @@ -101,9 +101,9 @@ describe("director registry", () => { expect(explorer.description).toContain("agent id: explorer"); expect(explorer.capabilities?.mode).toBe("allow"); expect(explorer.capabilities?.tools).toContain("read_file"); - expect(explorer.capabilities?.tools).toContain("write_file"); - expect(explorer.capabilities?.tools).toContain("edit_file"); - expect(explorer.capabilities?.tools).toContain("delete_file"); + expect(explorer.capabilities?.tools).not.toContain("write_file"); + expect(explorer.capabilities?.tools).not.toContain("edit_file"); + expect(explorer.capabilities?.tools).not.toContain("delete_file"); expect(explorer.orchestrator).toBe(false); const reviewer = packageToProfile(DIRECTOR_REGISTRY.reviewer); @@ -135,8 +135,13 @@ describe("director registry", () => { expect(spawners).toEqual(["dispatch"]); }); - test("closed directors mount product write tools", () => { + test("explorer is read-only; other closed directors mount product writes", () => { + const explorerAllow = DIRECTOR_REGISTRY.explorer.tools?.allow ?? []; + expect(explorerAllow).not.toContain("write_file"); + expect(explorerAllow).not.toContain("edit_file"); + expect(explorerAllow).not.toContain("delete_file"); for (const id of DIRECTOR_IDS) { + if (id === "explorer") continue; const allow = DIRECTOR_REGISTRY[id].tools?.allow ?? []; expect(allow).toContain("write_file"); expect(allow).toContain("edit_file"); diff --git a/src/agent/directors/tool-sets.ts b/src/agent/directors/tool-sets.ts index 3e11efed8..614165232 100644 --- a/src/agent/directors/tool-sets.ts +++ b/src/agent/directors/tool-sets.ts @@ -53,7 +53,7 @@ export const DOCS_TOOLS = [ ...PRODUCT_WRITE_TOOLS, ] as const; -/** Review / planner / explorer: read surface + path writes. */ +/** Review / planner / warden / prober: read surface + path writes. */ export const REVIEW_TOOLS = [...READ_TOOLS, ...PRODUCT_WRITE_TOOLS] as const; /** diff --git a/src/agent/tool-search.test.ts b/src/agent/tool-search.test.ts index 85d3ad148..c5899208c 100644 --- a/src/agent/tool-search.test.ts +++ b/src/agent/tool-search.test.ts @@ -23,6 +23,12 @@ import { toolSearchDefinition, type ToolAvailability, } from "./tool-search.js"; +import { + BUILD_TOOLS, + DOCS_TOOLS, + ORCHESTRATOR_TOOLS, + READ_TOOLS, +} from "./directors/tool-sets.js"; const FULL_AVAILABILITY: ToolAvailability = { languageServerAvailable: true, @@ -955,8 +961,8 @@ describe("promote-on-execute", () => { }); describe("advertisedToolNamesForWorker", () => { - test("leaf prefix is smaller than dispatch and includes tool_search", () => { - const leaf = advertisedToolNamesForWorker({ orchestrator: false }); + test("explorer/read allowlist has no writes and includes tool_search", () => { + const leaf = advertisedToolNamesForWorker({ allow: READ_TOOLS }); const dispatch = advertisedToolNamesForSessionMode( "orchestrator", FULL_AVAILABILITY, @@ -964,19 +970,33 @@ describe("advertisedToolNamesForWorker", () => { expect(leaf).toContain("tool_search"); expect(leaf).toContain("ask_director"); expect(leaf).toContain("submit_result"); - expect(leaf).toContain("skill_search"); - expect(leaf).toContain("read"); - expect(leaf).toContain("bash"); + expect(leaf).toContain("read_file"); + expect(leaf).toContain("run_shell"); + expect(leaf).not.toContain("write_file"); expect(leaf).not.toContain("spawn_agent"); expect(leaf).not.toContain("search_agents"); expect(leaf).not.toContain("ask_operator"); - expect(leaf).not.toContain("wait_agents"); expect(leaf.every((name) => !name.startsWith("mcp__"))).toBe(true); expect(leaf.length).toBeLessThan(dispatch.length); }); - test("nested orchestrator adds fleet verbs but not search_agents", () => { - const orch = advertisedToolNamesForWorker({ orchestrator: true }); + test("coder/build allowlist includes path writes, not fleet verbs", () => { + const coder = advertisedToolNamesForWorker({ allow: BUILD_TOOLS }); + expect(coder).toContain("write_file"); + expect(coder).toContain("edit_file"); + expect(coder).toContain("tool_search"); + expect(coder).not.toContain("spawn_agent"); + }); + + test("docs allowlist omits run_shell", () => { + const docs = advertisedToolNamesForWorker({ allow: DOCS_TOOLS }); + expect(docs).toContain("write_file"); + expect(docs).not.toContain("run_shell"); + expect(docs).toContain("tool_search"); + }); + + test("nested orchestrator allowlist adds fleet verbs but not search_agents", () => { + const orch = advertisedToolNamesForWorker({ allow: ORCHESTRATOR_TOOLS }); expect(orch).toContain("spawn_agent"); expect(orch).toContain("send_input"); expect(orch).toContain("tool_search"); diff --git a/src/agent/tool-search.ts b/src/agent/tool-search.ts index d0aeb285d..3f0abd85f 100644 --- a/src/agent/tool-search.ts +++ b/src/agent/tool-search.ts @@ -17,6 +17,7 @@ import { type ToolProfile, } from "./tool-aliases.js"; import { canonicalToolName } from "./canonical-tool-name.js"; +import { READ_TOOLS } from "./directors/tool-sets.js"; // Tools whose full schema is always advertised to the model. Everything else is // registered but discovered on demand via tool_search, which returns ranked @@ -120,45 +121,31 @@ export function advertisedToolNamesForSessionMode( ]; } -const WORKER_LEAF_EXTRA: readonly string[] = [ +const WORKER_WIRE_ALWAYS: readonly string[] = [ + "tool_search", "ask_director", "submit_result", - "skill_search", -]; - -const WORKER_ORCH_EXTRA: readonly string[] = [ - "spawn_agent", - "list_agents", - "close_agent", - "resume_agent", - "interrupt_agent", - "send_input", ]; /** - * Advertised wire prefix for a spawned worker. Smaller than dispatch: no - * `ask_operator`, no `search_agents`, no fleet verbs on leaves. Nested - * orchestrators add the fleet verbs they actually mount. MCP stays off this - * list until tool_search / promote-on-execute. + * Advertised wire prefix for a spawned worker: the director's tool allowlist + * plus `tool_search` and the leaf reporting channel. No shared preset across + * roles. MCP stays off this list until tool_search / promote-on-execute. + * Model-specific names (apply_patch) fold later via tool profile. */ export function advertisedToolNamesForWorker(opts: { - orchestrator: boolean; + allow?: readonly string[]; languageServerAvailable?: boolean; }): readonly string[] { - const core = CORE_TOOL_NAMES.filter((name) => { - if (name === "ask_operator") return false; - if (name === "search_agents") return false; - if (name === "wait_agents") return false; - if (name === "lsp") return opts.languageServerAvailable === true; - if (ORCHESTRATOR_ONLY_TOOL_NAMES.includes(name)) return opts.orchestrator; - return true; - }); - const extra = opts.orchestrator - ? [...WORKER_LEAF_EXTRA, ...WORKER_ORCH_EXTRA] - : WORKER_LEAF_EXTRA; + const base = + opts.allow !== undefined && opts.allow.length > 0 ? opts.allow : READ_TOOLS; const seen = new Set(); const out: string[] = []; - for (const name of [...core, ...CATALOG_TOOL_NAMES, ...extra]) { + for (const name of [...base, ...WORKER_WIRE_ALWAYS]) { + if (name === "ask_operator" || name === "search_agents") continue; + if (name === "wait_agents") continue; + if (name === "lsp" && opts.languageServerAvailable !== true) continue; + if (name.startsWith("mcp__")) continue; if (seen.has(name)) continue; seen.add(name); out.push(name); diff --git a/src/subagent/run.ts b/src/subagent/run.ts index a2ba5dbfe..9a2436fad 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -1128,7 +1128,9 @@ async function runSubAgentInner( } const workerPrefix = advertisedToolNamesForWorker({ - orchestrator: params.orchestrator === true, + ...(params.capabilities?.mode === "allow" + ? { allow: params.capabilities.tools } + : {}), }); const advertisedSet = createAdvertisedToolset({ sessionMode: "orchestrator",