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
4 changes: 4 additions & 0 deletions src/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,10 @@ export async function runExec(config: Config): Promise<ExecResult> {
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({
Expand Down
134 changes: 134 additions & 0 deletions src/tui/dynamic-tool-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
50 changes: 48 additions & 2 deletions src/tui/dynamic-tool-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -41,6 +51,7 @@ export function createDynamicToolRunner(
): DynamicToolRunner {
const byName = new Map<string, AgentTool>();
let callGate: ((name: string) => boolean) | undefined;
let isActivated: ((name: string) => boolean) | undefined;

const addTools = (tools: AgentTool[]): void => {
const incoming = new Set<string>();
Expand Down Expand Up @@ -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<ToolResult> {
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.<stripped>`) misses the registry
// under its original name even though the model was shown `<stripped>`:
// 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}`,
Expand Down
3 changes: 3 additions & 0 deletions src/tui/runner/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading