diff --git a/src/exec/runner.ts b/src/exec/runner.ts index 233bd8808..214b89cb2 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -846,6 +846,10 @@ export async function runExec(config: Config): Promise { createExecToolCallGate(isAdvertised, { isCodex: isCodexProviderName(config.providerName), }), + // Same promoted-but-unmounted contract as the TUI gate: an activated + // name missing from the registry errors toward retry (see run() in + // DynamicToolRunner). + { isActivated: (name) => activatedToolNames.has(name) }, ); const { directorHolder, buildAgent } = assembleChatAgent({ diff --git a/src/tui/dynamic-tool-runner.test.ts b/src/tui/dynamic-tool-runner.test.ts index 42ae36daf..f4612aa8a 100644 --- a/src/tui/dynamic-tool-runner.test.ts +++ b/src/tui/dynamic-tool-runner.test.ts @@ -180,6 +180,140 @@ describe("mangled dispatch names", () => { }); }); +describe("activated-but-unmounted registry miss", () => { + test("a gate-activated name missing from the registry reports reconnecting, not unknown tool", async () => { + const runner = createDynamicToolRunner([ + stringTool("read_file", "core"), + stringTool("mcp__acme__do", "blind-result"), + ]); + const activated = new Set(["mcp__acme__do"]); + runner.setCallGate((name) => name === "read_file" || activated.has(name), { + isActivated: (name) => activated.has(name), + }); + + // The tool was promoted (gate open) but its server disconnected before the + // call, so removeTools dropped it from the registry mid-window. + runner.removeTools(["mcp__acme__do"]); + const result = await runner.run( + { id: "1", name: "mcp__acme__do", arguments: {} }, + new AbortController().signal, + ); + + expect(result.isError).toBe(true); + expect(result.content).toContain("mcp__acme__do"); + expect(result.content).toContain("reconnecting"); + expect(result.content).toContain("Retry the call shortly"); + expect(result.content).not.toBe("unknown tool: mcp__acme__do"); + }); + + test("a never-activated miss keeps the exact unknown tool string", async () => { + const runner = createDynamicToolRunner([stringTool("read_file", "core")]); + runner.setCallGate((name) => name === "read_file", { + isActivated: () => false, + }); + + const result = await runner.run( + { id: "1", name: "mcp__gone__tool", arguments: {} }, + new AbortController().signal, + ); + + expect(result.isError).toBe(true); + expect(result.content).toBe("unknown tool: mcp__gone__tool"); + }); +}); + +describe("harness namespace prefix", () => { + test("a default.-prefixed call dispatches the registered bare tool", async () => { + const runner = createDynamicToolRunner([ + stringTool("read_file", "core"), + stringTool("mcp__acme__do", "blind-result"), + ]); + const advertised = new Set(["read_file", "mcp__acme__do"]); + runner.setCallGate((name) => advertised.has(name)); + + const result = await runner.run( + { id: "1", name: "default.mcp__acme__do", arguments: {} }, + new AbortController().signal, + ); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("blind-result"); + }); + + test("a prefixed miss keeps the exact unknown tool string with the original name", async () => { + const runner = createDynamicToolRunner([stringTool("read_file", "core")]); + runner.setCallGate((name) => name === "read_file", { + isActivated: () => false, + }); + + const result = await runner.run( + { id: "1", name: "default.mcp__gone__tool", arguments: {} }, + new AbortController().signal, + ); + + expect(result.isError).toBe(true); + expect(result.content).toBe("unknown tool: default.mcp__gone__tool"); + }); + + test("an exact dotted registration wins over the bare suffix (anti-misrouting)", async () => { + const runner = createDynamicToolRunner([ + stringTool("mcp__acme__do", "bare"), + stringTool("default.mcp__acme__do", "dotted"), + ]); + runner.setCallGate(() => true); + + const result = await runner.run( + { id: "1", name: "default.mcp__acme__do", arguments: {} }, + new AbortController().signal, + ); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("dotted"); + }); + + test("a prefixed miss for an activated-but-unmounted stripped tool reports reconnecting under the original name", async () => { + const runner = createDynamicToolRunner([ + stringTool("read_file", "core"), + stringTool("mcp__acme__do", "blind-result"), + ]); + const activated = new Set(["mcp__acme__do"]); + runner.setCallGate((name) => name === "read_file" || activated.has(name), { + isActivated: (name) => activated.has(name), + }); + + // The server dropped between search and call, so the bare tool left the + // registry; the model still emits the harness-namespaced form. + runner.removeTools(["mcp__acme__do"]); + const result = await runner.run( + { id: "1", name: "default.mcp__acme__do", arguments: {} }, + new AbortController().signal, + ); + + expect(result.isError).toBe(true); + expect(result.content).toContain("default.mcp__acme__do"); + expect(result.content).toContain("reconnecting"); + expect(result.content).toContain("Retry the call shortly"); + }); + + test("a normalized call rejected by the gate reports the stripped name", async () => { + const runner = createDynamicToolRunner([ + stringTool("read_file", "core"), + stringTool("mcp__acme__do", "blind-result"), + ]); + runner.setCallGate((name) => name === "read_file"); + + const result = await runner.run( + { id: "1", name: "default.mcp__acme__do", arguments: {} }, + new AbortController().signal, + ); + + expect(result.isError).toBe(true); + expect(result.content).toContain("mcp__acme__do"); + expect(result.content).toContain("tool_search"); + expect(result.content).not.toContain("default.mcp__acme__do"); + }); +}); + describe("terminal control stripping", () => { test("strips escape sequences from any tool's result, including MCP", async () => { const payload = "before\x1b]52;c;ZXZpbA==\x07\x1b[31mred\x1b[0m\x07after"; diff --git a/src/tui/dynamic-tool-runner.ts b/src/tui/dynamic-tool-runner.ts index 116a96f28..53160c92e 100644 --- a/src/tui/dynamic-tool-runner.ts +++ b/src/tui/dynamic-tool-runner.ts @@ -31,8 +31,18 @@ export type DynamicToolRunner = AgentToolRunner & { * the current wire (built-in prefix + pinned + activated) with an error * pointing at tool_search, instead of silently dispatching. Without a gate * every registered tool stays dispatchable — sub-agent runners never set one. + * + * `options.isActivated` is the promotion side of the gate: a name the model + * was already shown (tool_search activated it) that is absent from the + * registry — its server disconnected or the snapshot rebuilt under it — + * reports "not currently available, server may be reconnecting, retry + * shortly" instead of the bare unknown-tool string. Names never activated + * keep the exact unknown-tool string. */ - setCallGate(isCallable: (name: string) => boolean): void; + setCallGate( + isCallable: (name: string) => boolean, + options?: { isActivated?: (name: string) => boolean }, + ): void; }; export function createDynamicToolRunner( @@ -41,6 +51,7 @@ export function createDynamicToolRunner( ): DynamicToolRunner { const byName = new Map(); let callGate: ((name: string) => boolean) | undefined; + let isActivated: ((name: string) => boolean) | undefined; const addTools = (tools: AgentTool[]): void => { const incoming = new Set(); @@ -69,14 +80,49 @@ export function createDynamicToolRunner( addTools, removeTools, currentDefinitions, - setCallGate(isCallable: (name: string) => boolean): void { + setCallGate( + isCallable: (name: string) => boolean, + options?: { isActivated?: (name: string) => boolean }, + ): void { callGate = isCallable; + isActivated = options?.isActivated; }, async run(call: ToolCall, signal: AbortSignal): Promise { const resolved = resolveRegisteredToolName(call.name, (name) => byName.has(name), ); if (resolved === undefined) { + // The name was promoted (gate-activated) but the registry no longer + // holds it — the server dropped between search and call. Say so: the + // model already has the schema from the tool_search card and only + // needs to retry, not re-search. A name never activated keeps the + // exact unknown-tool string. + if (isActivated?.(call.name) === true) { + return { + callId: call.id, + content: + `Error: ${call.name} is not currently available - its server may ` + + `still be reconnecting. Retry the call shortly.`, + isError: true, + }; + } + // A harness-namespaced call (`default.`) misses the registry + // under its original name even though the model was shown ``: + // consult activation with the stripped form too, but keep the original + // name in the message so the transcript matches what the model emitted. + const dot = call.name.indexOf("."); + if (dot > 0) { + const stripped = call.name.slice(dot + 1); + if (stripped.length > 0 && isActivated?.(stripped) === true) { + return { + callId: call.id, + content: + `Error: ${call.name} is not currently available - its server may ` + + `still be reconnecting. Retry the call shortly.`, + isError: true, + }; + } + } return { callId: call.id, content: `unknown tool: ${call.name}`, diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index cf45941c3..fa6aa99c0 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -498,6 +498,9 @@ export async function assembleTUISession( ]); toolset.dynamicRunner.setCallGate( (name) => unadvertisedCallable.has(name) || isAdvertised(name), + // A promoted-but-unmounted name (server dropped between search and call) + // reports reconnecting instead of bare unknown-tool at the call gate. + { isActivated: (name) => activatedToolNames.has(name) }, ); // Reload, interrupt, compaction continuation, and proxy deliver share one queue