From 7c91b0c28ce8921217581695c6552916266ea980 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 29 Sep 2026 18:31:14 -0700 Subject: [PATCH] fix(agent): canonicalize wire tool names in director and tui tracking Raw wire tool names from incoming tool calls were compared directly against internal engine names across director triggers, watchdogs, and TUI event handlers. Resolve them through canonicalToolName so aliased wire names (e.g. read/edit, manage_tasks) match correctly. --- src/agent/director.test.ts | 3 +++ src/agent/director.ts | 10 ++++++---- src/subagent/poll-exempt.ts | 3 ++- src/tui/mcp-view.ts | 3 ++- src/tui/runtime-bridge.ts | 7 +++++-- src/tui/stall-watchdog.ts | 5 ++++- src/tui/tool-execution-watchdog.ts | 9 ++++++--- src/tui/turns-to-blocks.ts | 7 ++++++- 8 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/agent/director.test.ts b/src/agent/director.test.ts index 042a9efc3..6501f98f7 100644 --- a/src/agent/director.test.ts +++ b/src/agent/director.test.ts @@ -1901,6 +1901,9 @@ describe("chatDirector LSP auto-activation", () => { test.each([ { tool: "read_file", path: "src/foo.ts", expected: [lspEmit] }, { tool: "edit_file", path: "lib/bar.rs", expected: [lspEmit] }, + // Wire names the model actually emits; the director must resolve them. + { tool: "read", path: "src/foo.ts", expected: [lspEmit] }, + { tool: "edit", path: "lib/bar.rs", expected: [lspEmit] }, // Non-code file. { tool: "read_file", path: "README.md", expected: [] }, // Failed result never activates. diff --git a/src/agent/director.ts b/src/agent/director.ts index 429f05c52..9be7c0604 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -1,3 +1,4 @@ +import { canonicalToolName } from "./canonical-tool-name.js"; import { DefaultDirector, type ExtendedInferenceOptions, @@ -446,7 +447,7 @@ function applyManageTasksToolCall( tasks: Task[], block: { name: string; arguments: unknown }, ): Task[] | null { - if (block.name !== "manage_tasks") return null; + if (canonicalToolName(block.name) !== "manage_tasks") return null; const taskArgs = parseManageTasksArgs(block.arguments); return taskArgs !== null ? applyManageTasks(tasks, taskArgs) : null; } @@ -1173,7 +1174,8 @@ class ChatDirectorImpl extends DefaultDirector { } for (const block of event.turn.content) { if (block.type !== "tool_call") continue; - if (block.name === "manage_tasks") { + const engineName = canonicalToolName(block.name); + if (engineName === "manage_tasks") { const next = applyManageTasksToolCall(this.tasks, block); if (next !== null) { this.tasks = next; @@ -1183,7 +1185,7 @@ class ChatDirectorImpl extends DefaultDirector { }), ); } - } else if (block.name === "read_file" || block.name === "edit_file") { + } else if (engineName === "read_file" || engineName === "edit_file") { const pathResult = PathArgSchema(block.arguments); const path = pathResult instanceof type.errors ? "" : pathResult.path; if (isCodeFile(path)) this.lspTriggerCalls.add(block.id); @@ -1194,7 +1196,7 @@ class ChatDirectorImpl extends DefaultDirector { args: block.arguments, }); } - if (block.name === "ask_operator") { + if (engineName === "ask_operator") { this.askOperatorCalls.add(block.id); } } diff --git a/src/subagent/poll-exempt.ts b/src/subagent/poll-exempt.ts index 63e7a1b1d..35a733422 100644 --- a/src/subagent/poll-exempt.ts +++ b/src/subagent/poll-exempt.ts @@ -1,3 +1,4 @@ +import { canonicalToolName } from "../agent/canonical-tool-name.js"; import { type } from "arktype"; import type { ToolCall, ToolResult } from "@intx/types/runtime"; import { isLiveWaitStatus, type WaitJSONStatus } from "./lifecycle.js"; @@ -40,7 +41,7 @@ export function isPollOnlyPendingBatch( return calls.every((call, index) => { const result = results[index]; if (result === undefined) return false; - if (call.name !== "wait_agents") return false; + if (canonicalToolName(call.name) !== "wait_agents") return false; const payload = resultPayload(result); if (payload === undefined) return false; return isWaitAgentsPending(payload); diff --git a/src/tui/mcp-view.ts b/src/tui/mcp-view.ts index 36a5069fe..fa24c3756 100644 --- a/src/tui/mcp-view.ts +++ b/src/tui/mcp-view.ts @@ -15,6 +15,7 @@ * for the pagination cursor. */ +import { canonicalToolName } from "../agent/canonical-tool-name.js"; import { fg as fgChunk, bold as boldChunk, @@ -501,7 +502,7 @@ const USE_SKILL_TOOL = "use_skill"; * future wording) simply does not collapse. */ function loadedSkillName(name: string, content: string): string | undefined { - if (name !== USE_SKILL_TOOL) return undefined; + if (canonicalToolName(name) !== USE_SKILL_TOOL) return undefined; return /^Skill "([^"]+)"/.exec(content)?.[1]; } diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 409e8f6ab..4119d2aa8 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -6,6 +6,7 @@ * later waves can bind real agent APIs). Not the production CLI entry. */ +import { canonicalToolName } from "../agent/canonical-tool-name.js"; import { cancelItem, createSessionQueue, @@ -1005,8 +1006,9 @@ export function flushStreamRowUpdates(shell: AppShell): void { function applyToolCall( shell: AppShell, bag: BridgeBag, - event: Extract, + raw: Extract, ): void { + const event = { ...raw, name: canonicalToolName(raw.name) }; if (event.name === MANAGE_TASKS_TOOL_NAME) { // Remembered so the matching result is dropped too — suppressing only the // call would leave its result to land as an unpaired row. Checklist lives @@ -1057,8 +1059,9 @@ function applyToolCall( function applyToolResult( shell: AppShell, bag: BridgeBag, - event: Extract, + raw: Extract, ): void { + const event = { ...raw, name: canonicalToolName(raw.name) }; if (event.callId !== undefined && bag.panelOnlyCallIds.delete(event.callId)) return; const result = toolResultRow({ diff --git a/src/tui/stall-watchdog.ts b/src/tui/stall-watchdog.ts index 1323f9e4a..bf1dae45a 100644 --- a/src/tui/stall-watchdog.ts +++ b/src/tui/stall-watchdog.ts @@ -1,3 +1,4 @@ +import { canonicalToolName } from "../agent/canonical-tool-name.js"; import type { TurnStatus } from "./chrome-state.js"; // How long the run can be continuously awaiting a response with no new content @@ -74,7 +75,9 @@ function silentPastThreshold( * mapping-owning sibling resolves first and clears the shared slot. */ function isStallBoundedToolName(name: string | null | undefined): boolean { - return name === "wait_agents" || name === "ask_director"; + if (name === null || name === undefined) return false; + const engine = canonicalToolName(name); + return engine === "wait_agents" || engine === "ask_director"; } function isStallBoundedInFlightTool(args: ShouldAbortForStallArgs): boolean { diff --git a/src/tui/tool-execution-watchdog.ts b/src/tui/tool-execution-watchdog.ts index 46e4bbe60..0bd16ecc4 100644 --- a/src/tui/tool-execution-watchdog.ts +++ b/src/tui/tool-execution-watchdog.ts @@ -1,3 +1,4 @@ +import { canonicalToolName } from "../agent/canonical-tool-name.js"; import { AsyncLocalStorage } from "node:async_hooks"; import { formatMcpToolTimeoutMessage, @@ -112,10 +113,12 @@ export function resolveToolExecutionTimeoutMs( config?: ToolWatchdogConfig, call?: ToolCall, ): number | undefined { + const engineName = + call !== undefined ? canonicalToolName(call.name) : undefined; if ( - call?.name === "spawn_agent" || - call?.name === "wait_agents" || - call?.name === "ask_director" + engineName === "spawn_agent" || + engineName === "wait_agents" || + engineName === "ask_director" ) { return undefined; } diff --git a/src/tui/turns-to-blocks.ts b/src/tui/turns-to-blocks.ts index 0583c1c0b..f2505e191 100644 --- a/src/tui/turns-to-blocks.ts +++ b/src/tui/turns-to-blocks.ts @@ -1,3 +1,4 @@ +import { canonicalToolName } from "../agent/canonical-tool-name.js"; import type { ContentBlock as RuntimeContentBlock, ConversationTurn, @@ -166,7 +167,11 @@ function finalizeResumeToolBlocks( // so an errored or missing result must not leave the raw rows behind. for (let i = 0; i < blocks.length; i += 1) { const call = blocks[i]; - if (call?.type !== "tool_call" || call.name !== "manage_tasks") continue; + if ( + call?.type !== "tool_call" || + canonicalToolName(call.name) !== "manage_tasks" + ) + continue; indicesToRemove.add(i); const resultIndex = call.callId !== undefined