From 1506239dcb5719cace8d5359fcc9b7ba3daa4bed Mon Sep 17 00:00:00 2001 From: francisco-orkes Date: Fri, 28 Aug 2026 18:28:56 -0700 Subject: [PATCH 1/5] fix(agents): report the tool's own name in result.toolCalls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _extractToolCalls named every call after its task type, so HTTP, MCP and human tools came back as "http", "call_mcp_tool" and "human". The line that was meant to prevent that read inputData.method five lines after INTERNAL_KEYS had deleted it, so the ?? fallback to taskType was unconditional. It also case-folded, reporting a tool named getWeather as "getweather". Two narrower defects had the same blast radius: - SUB_WORKFLOW sat on the skip list, and an agent exposed as a tool compiles to one, so agent-as-tool calls were dropped entirely. - Selection required a reference name starting with "call_", which is OpenAI's tool-call id format. Anthropic emits "toolu_" and a blank id becomes a UUID, so an Anthropic-backed agent recorded no tool calls at all. Adding "toolu_" only defers the problem to the next provider. Select on inputData._agent_tool_name instead, which the server's dispatch script writes on every dispatched tool and which no orchestration task carries. It is also the only name that holds across tool kinds: the task type is the transport, and taskDefName is the transport's own name for MCP (call_mcp_tool), agent (the sub-workflow's name) and media tools. The marker is stripped from the reported args alongside the other internal keys. Tasks without the marker — servers older than it — keep the previous "call_" heuristic, but take their name from taskDefName rather than the task type. result.events is unaffected; it is populated from the stream's own accumulator and was already correct. --- .../__tests__/tool-call-extraction.test.ts | 237 ++++++++++++++++++ src/agents/runtime.ts | 54 ++-- 2 files changed, 271 insertions(+), 20 deletions(-) create mode 100644 src/agents/__tests__/tool-call-extraction.test.ts diff --git a/src/agents/__tests__/tool-call-extraction.test.ts b/src/agents/__tests__/tool-call-extraction.test.ts new file mode 100644 index 00000000..b7dbeff7 --- /dev/null +++ b/src/agents/__tests__/tool-call-extraction.test.ts @@ -0,0 +1,237 @@ +/** + * `result.toolCalls` extraction. + * + * The task shapes below mirror what the server's tool-dispatch script emits for + * each tool kind: a per-call reference name built from the provider's tool-call + * id, a task type that varies by kind (`HTTP`, `CALL_MCP_TOOL`, `SUB_WORKFLOW`, + * `HUMAN`, `SIMPLE`), and an `_agent_tool_name` marker carrying the tool's + * declared name on every dispatched tool. + */ + +import { _extractToolCalls } from "../runtime.js"; + +interface ToolCall { + name: string; + args: Record; + result: unknown; +} + +const extract = (tasks: Record[]): ToolCall[] => + _extractToolCalls({ tasks }) as ToolCall[]; + +/** A tool task as the dispatch script builds it. */ +const toolTask = ( + overrides: Record & { referenceTaskName: string; taskType: string }, +): Record => ({ + outputData: {}, + ...overrides, + inputData: { + ...((overrides.inputData ?? {}) as Record), + }, +}); + +describe("_extractToolCalls — tool naming", () => { + it("names an HTTP tool after the tool, not the task type", () => { + const calls = extract([ + toolTask({ + referenceTaskName: "call_PMnNIdOPvm9EQ8e6tn2kbxPY_0__1", + taskType: "HTTP", + taskDefName: "get_forecast", + inputData: { + http_request: { uri: "https://example.com/forecast", method: "GET" }, + _agent_tool_name: "get_forecast", + }, + outputData: { response: { body: { temp: 21 } } }, + }), + ]); + + expect(calls).toHaveLength(1); + expect(calls[0].name).toBe("get_forecast"); + expect(calls[0].result).toEqual({ response: { body: { temp: 21 } } }); + }); + + it("names an MCP tool after the tool, not `call_mcp_tool`", () => { + const calls = extract([ + toolTask({ + referenceTaskName: "call_abc_0__1", + taskType: "CALL_MCP_TOOL", + taskDefName: "call_mcp_tool", + inputData: { + mcpServer: "files", + method: "read_file", + arguments: { path: "/tmp/a" }, + _agent_tool_name: "read_file", + }, + }), + ]); + + expect(calls.map((c) => c.name)).toEqual(["read_file"]); + }); + + it("names a human tool after the tool, not `human`", () => { + const calls = extract([ + toolTask({ + referenceTaskName: "call_abc_0__1", + taskType: "HUMAN", + taskDefName: "ask_question", + inputData: { + __humanTaskDefinition: { displayName: "ask_question" }, + _agent_tool_name: "ask_question", + }, + }), + ]); + + expect(calls.map((c) => c.name)).toEqual(["ask_question"]); + }); + + it("preserves the tool name verbatim rather than case-folding it", () => { + const calls = extract([ + toolTask({ + referenceTaskName: "call_abc_0__1", + taskType: "getWeather", + taskDefName: "getWeather", + inputData: { city: "Lisbon", _agent_state: {}, _agent_tool_name: "getWeather" }, + }), + ]); + + expect(calls.map((c) => c.name)).toEqual(["getWeather"]); + }); +}); + +describe("_extractToolCalls — which tasks count", () => { + it("includes an agent invoked as a tool (SUB_WORKFLOW)", () => { + const calls = extract([ + toolTask({ + referenceTaskName: "call_abc_0__1", + taskType: "SUB_WORKFLOW", + taskDefName: "billing_agent_workflow", + inputData: { prompt: "refund status", _agent_tool_name: "billing_agent" }, + outputData: { result: "refunded" }, + }), + ]); + + expect(calls).toEqual([ + { name: "billing_agent", args: { prompt: "refund status" }, result: { result: "refunded" } }, + ]); + }); + + it("detects tools behind a non-OpenAI tool-call id format", () => { + const calls = extract([ + toolTask({ + referenceTaskName: "toolu_01A9EqMxQGxL_0__1", + taskType: "SIMPLE", + taskDefName: "lookup", + inputData: { q: "hello", _agent_tool_name: "lookup" }, + }), + toolTask({ + referenceTaskName: "5f2c0d9e-2a0e-4c1f-9a3f-1f6d2f0b0c11_0__1", + taskType: "HTTP", + taskDefName: "fetch_page", + inputData: { http_request: { uri: "https://example.com" }, _agent_tool_name: "fetch_page" }, + }), + ]); + + expect(calls.map((c) => c.name)).toEqual(["lookup", "fetch_page"]); + }); + + it("excludes orchestration tasks", () => { + const orchestration = [ + "LLM_CHAT_COMPLETE", + "SWITCH", + "DO_WHILE", + "INLINE", + "SET_VARIABLE", + "FORK", + "FORK_JOIN_DYNAMIC", + "JOIN", + ].map((taskType, i) => + toolTask({ + referenceTaskName: `call_orchestration_${i}`, + taskType, + inputData: { _agent_tool_name: "should_not_matter" }, + }), + ); + + expect(extract(orchestration)).toEqual([]); + }); + + it("returns an empty list when the execution has no tasks", () => { + expect(_extractToolCalls({})).toEqual([]); + expect(_extractToolCalls({ tasks: [] })).toEqual([]); + }); +}); + +describe("_extractToolCalls — arguments", () => { + it("strips internal keys from the reported arguments", () => { + const calls = extract([ + toolTask({ + referenceTaskName: "call_abc_0__1", + taskType: "SIMPLE", + taskDefName: "lookup", + inputData: { + q: "hello", + _agent_state: { messages: [] }, + _agent_tool_name: "lookup", + method: "lookup", + __humanTaskDefinition: {}, + }, + }), + ]); + + expect(calls[0].args).toEqual({ q: "hello" }); + }); + + it("leaves the execution's own task input untouched", () => { + const task = toolTask({ + referenceTaskName: "call_abc_0__1", + taskType: "SIMPLE", + taskDefName: "lookup", + inputData: { q: "hello", _agent_tool_name: "lookup" }, + }); + + extract([task]); + + expect(task.inputData).toEqual({ q: "hello", _agent_tool_name: "lookup" }); + }); + + it("reads snake_case task fields", () => { + const calls = extract([ + { + reference_task_name: "call_abc_0__1", + task_type: "SIMPLE", + input_data: { q: "hello", _agent_tool_name: "lookup" }, + output_data: { result: "hi" }, + }, + ]); + + expect(calls).toEqual([{ name: "lookup", args: { q: "hello" }, result: { result: "hi" } }]); + }); +}); + +describe("_extractToolCalls — servers older than the dispatch marker", () => { + it("falls back to the task definition name for an unmarked tool task", () => { + const calls = extract([ + toolTask({ + referenceTaskName: "call_PMnNIdOPvm9EQ8e6tn2kbxPY_0__1", + taskType: "SIMPLE", + taskDefName: "getWeather", + inputData: { city: "Lisbon" }, + }), + ]); + + expect(calls.map((c) => c.name)).toEqual(["getWeather"]); + }); + + it("ignores unmarked tasks that carry no tool-call reference name", () => { + const calls = extract([ + toolTask({ + referenceTaskName: "prefill_lookup", + taskType: "SIMPLE", + taskDefName: "lookup", + inputData: { q: "hello" }, + }), + ]); + + expect(calls).toEqual([]); + }); +}); diff --git a/src/agents/runtime.ts b/src/agents/runtime.ts index be14da0f..a8d14fa4 100644 --- a/src/agents/runtime.ts +++ b/src/agents/runtime.ts @@ -1953,8 +1953,13 @@ function _isOutputJunk(output: unknown): boolean { return false; } -/** System task types that are never user-defined tool calls. */ -const SYSTEM_TASK_TYPES = new Set([ +/** + * Task types that are orchestration scaffolding, never a tool invocation. + * + * `SUB_WORKFLOW` is deliberately absent: an agent exposed as a tool compiles to + * one, so skipping the type outright drops those calls. + */ +const ORCHESTRATION_TASK_TYPES = new Set([ "LLM_CHAT_COMPLETE", "SWITCH", "DO_WHILE", @@ -1963,11 +1968,13 @@ const SYSTEM_TASK_TYPES = new Set([ "FORK", "FORK_JOIN_DYNAMIC", "JOIN", - "SUB_WORKFLOW", ]); +/** Input key the server's tool-dispatch script writes on every dispatched tool. */ +const TOOL_NAME_KEY = "_agent_tool_name"; + /** Internal keys to strip from tool call input. */ -const INTERNAL_KEYS = ["_agent_state", "method", "__humanTaskDefinition"]; +const INTERNAL_KEYS = ["_agent_state", "method", "__humanTaskDefinition", TOOL_NAME_KEY]; /** * Extract output from a full execution response. @@ -2044,36 +2051,43 @@ function _extractMessages(execution: Record): unknown[] { /** * Extract tool calls from execution tasks. - * Mirrors Python's _extract_tool_calls: filters for call_* refs, skips system tasks. + * + * A dispatched tool carries its declared name in `inputData._agent_tool_name`, + * which is the only field that survives every tool kind: the task type is the + * transport (`HTTP`, `CALL_MCP_TOOL`, `SUB_WORKFLOW`, `HUMAN`, ...) and the task + * definition name is the transport's own name for MCP, agent and media tools. + * + * Servers predating that marker are handled by the older heuristic — a reference + * name prefixed with the provider's tool-call id — which only ever matched + * OpenAI's `call_` format. + * + * @internal Not part of the published agent surface; exported for tests. */ -function _extractToolCalls(execution: Record): unknown[] { +export function _extractToolCalls(execution: Record): unknown[] { const tasks = execution.tasks as Record[] | undefined; if (!Array.isArray(tasks)) return []; const toolCalls: unknown[] = []; for (const task of tasks) { const taskType = String(task.taskType ?? task.task_type ?? "").toUpperCase(); - const ref = String(task.referenceTaskName ?? task.reference_task_name ?? ""); + if (ORCHESTRATION_TASK_TYPES.has(taskType)) continue; - // The call_ prefix is the compiler's marker for tool invocations. - // Any task with a call_ ref is a user-initiated tool call, regardless - // of whether the underlying task type is HTTP, CALL_MCP_TOOL, SIMPLE, etc. - if (!ref.startsWith("call_")) continue; - // Skip only orchestration-level system tasks (these never have call_ refs, - // but guard against edge cases) - if (SYSTEM_TASK_TYPES.has(taskType)) continue; + const rawInput = (task.inputData ?? task.input_data ?? {}) as Record; + const marker = rawInput[TOOL_NAME_KEY]; + const toolName = typeof marker === "string" && marker !== "" ? marker : undefined; - const inputData = { ...((task.inputData ?? task.input_data ?? {}) as Record) }; + if (toolName === undefined) { + const ref = String(task.referenceTaskName ?? task.reference_task_name ?? ""); + if (!ref.startsWith("call_")) continue; + } + + const inputData = { ...rawInput }; for (const k of INTERNAL_KEYS) { Reflect.deleteProperty(inputData, k); } - // Use the tool name from inputData.method (set by compiler) if available - const toolName = String(inputData.method ?? taskType).toLowerCase(); - delete inputData.method; - toolCalls.push({ - name: toolName, + name: toolName ?? String(task.taskDefName ?? task.task_def_name ?? taskType), args: inputData, result: task.outputData ?? task.output_data ?? {}, }); From a15ccfdc1568af3392119235bbf6a1c39756bec5 Mon Sep 17 00:00:00 2001 From: francisco-orkes Date: Fri, 28 Aug 2026 18:34:19 -0700 Subject: [PATCH 2/5] fix(agents): detect tools the dispatch script leaves unmarked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _agent_tool_name is written by the dispatch script agents compile to when their tools are declared up front. Agents that discover tools at runtime compile to a second script (enrichToolsScriptDynamic), which builds the same tasks and never writes the marker — so on that path the previous commit fell straight through to the "call_" reference-name heuristic and an Anthropic-backed MCP agent still recorded nothing. Selecting purely by task type, as one reading of the bug report suggests, is not available: the agent compiler emits 26 SIMPLE, 8 SUB_WORKFLOW and a HUMAN task of its own for guardrail workers, handoffs and approvals, so those types cannot distinguish a tool call from scaffolding. HTTP and CALL_MCP_TOOL can. Neither appears anywhere in the compiled agent outside tool dispatch, so a task of either type is a tool call whether or not it carries the marker. CALL_MCP_TOOL names the tool in `method` — the field the original code meant to read before the internal- key strip removed it — and that read stays scoped to CALL_MCP_TOOL so a worker tool taking its own `method` argument is not renamed by it. Unmarked SIMPLE, SUB_WORKFLOW and HUMAN tools keep the "call_" heuristic and are still missed on a non-OpenAI provider. Closing that needs the marker on the runtime-discovery path, which is a server-side change. --- .../__tests__/tool-call-extraction.test.ts | 67 +++++++++++++++++-- src/agents/runtime.ts | 42 ++++++++++-- 2 files changed, 98 insertions(+), 11 deletions(-) diff --git a/src/agents/__tests__/tool-call-extraction.test.ts b/src/agents/__tests__/tool-call-extraction.test.ts index b7dbeff7..5d7481f5 100644 --- a/src/agents/__tests__/tool-call-extraction.test.ts +++ b/src/agents/__tests__/tool-call-extraction.test.ts @@ -8,6 +8,8 @@ * declared name on every dispatched tool. */ +import { describe, it, expect } from "@jest/globals"; + import { _extractToolCalls } from "../runtime.js"; interface ToolCall { @@ -208,7 +210,7 @@ describe("_extractToolCalls — arguments", () => { }); }); -describe("_extractToolCalls — servers older than the dispatch marker", () => { +describe("_extractToolCalls — tools the dispatch script left unmarked", () => { it("falls back to the task definition name for an unmarked tool task", () => { const calls = extract([ toolTask({ @@ -222,14 +224,71 @@ describe("_extractToolCalls — servers older than the dispatch marker", () => { expect(calls.map((c) => c.name)).toEqual(["getWeather"]); }); - it("ignores unmarked tasks that carry no tool-call reference name", () => { + it("names an unmarked MCP tool from `method`, whatever the tool-call id format", () => { + const calls = extract([ + toolTask({ + referenceTaskName: "toolu_01A9EqMxQGxL_0__1", + taskType: "CALL_MCP_TOOL", + taskDefName: "call_mcp_tool", + inputData: { mcpServer: "files", method: "read_file", arguments: { path: "/tmp/a" } }, + }), + ]); + + expect(calls).toEqual([ + { + name: "read_file", + args: { mcpServer: "files", arguments: { path: "/tmp/a" } }, + result: {}, + }, + ]); + }); + + it("detects an unmarked HTTP tool, whatever the tool-call id format", () => { + const calls = extract([ + toolTask({ + referenceTaskName: "toolu_01A9EqMxQGxL_0__1", + taskType: "HTTP", + taskDefName: "get_forecast", + inputData: { http_request: { uri: "https://example.com/forecast" } }, + }), + ]); + + expect(calls.map((c) => c.name)).toEqual(["get_forecast"]); + }); + + it("does not read `method` off a worker tool that happens to take one", () => { const calls = extract([ toolTask({ - referenceTaskName: "prefill_lookup", + referenceTaskName: "call_abc_0__1", taskType: "SIMPLE", - taskDefName: "lookup", + taskDefName: "sendRequest", + inputData: { method: "POST", url: "https://example.com" }, + }), + ]); + + expect(calls.map((c) => c.name)).toEqual(["sendRequest"]); + }); + + it("ignores unmarked tasks of a type the agent compiler also emits itself", () => { + const calls = extract([ + toolTask({ + referenceTaskName: "myagent_guardrail_check", + taskType: "SIMPLE", + taskDefName: "guardrail_worker", inputData: { q: "hello" }, }), + toolTask({ + referenceTaskName: "myagent_approval", + taskType: "HUMAN", + taskDefName: "approval", + inputData: {}, + }), + toolTask({ + referenceTaskName: "myagent_handoff", + taskType: "SUB_WORKFLOW", + taskDefName: "billing_agent_workflow", + inputData: { prompt: "hi" }, + }), ]); expect(calls).toEqual([]); diff --git a/src/agents/runtime.ts b/src/agents/runtime.ts index a8d14fa4..b11a2c2a 100644 --- a/src/agents/runtime.ts +++ b/src/agents/runtime.ts @@ -1970,9 +1970,23 @@ const ORCHESTRATION_TASK_TYPES = new Set([ "JOIN", ]); -/** Input key the server's tool-dispatch script writes on every dispatched tool. */ +/** + * Input key the server's tool-dispatch script writes on a dispatched tool. + * + * Written for every tool an agent declares up front. Agents that discover their + * tools at runtime compile to a second dispatch script that omits it, so its + * absence does not mean the task is not a tool call. + */ const TOOL_NAME_KEY = "_agent_tool_name"; +/** + * Task types only tool dispatch produces, so a task of one is a tool call even + * unmarked. The rest of a compiled agent's task types are ambiguous: it emits + * `SIMPLE`, `SUB_WORKFLOW` and `HUMAN` tasks of its own for workers, handoffs + * and approvals. + */ +const TOOL_ONLY_TASK_TYPES = new Set(["HTTP", "CALL_MCP_TOOL"]); + /** Internal keys to strip from tool call input. */ const INTERNAL_KEYS = ["_agent_state", "method", "__humanTaskDefinition", TOOL_NAME_KEY]; @@ -2049,6 +2063,11 @@ function _extractMessages(execution: Record): unknown[] { return lastLlmMsgs; } +/** A task input field is usable as a tool name only if it is a non-empty string. */ +function _asName(value: unknown): string | undefined { + return typeof value === "string" && value !== "" ? value : undefined; +} + /** * Extract tool calls from execution tasks. * @@ -2057,9 +2076,11 @@ function _extractMessages(execution: Record): unknown[] { * transport (`HTTP`, `CALL_MCP_TOOL`, `SUB_WORKFLOW`, `HUMAN`, ...) and the task * definition name is the transport's own name for MCP, agent and media tools. * - * Servers predating that marker are handled by the older heuristic — a reference - * name prefixed with the provider's tool-call id — which only ever matched - * OpenAI's `call_` format. + * Two signals back it up for tasks the dispatch script left unmarked — agents + * that discover their tools at runtime, and servers predating the marker. A + * task-type check covers the types only tool dispatch emits. Everything else + * falls back to the original heuristic, a reference name prefixed with the + * provider's tool-call id, which only ever matched OpenAI's `call_` format. * * @internal Not part of the published agent surface; exported for tests. */ @@ -2073,12 +2094,19 @@ export function _extractToolCalls(execution: Record): unknown[] if (ORCHESTRATION_TASK_TYPES.has(taskType)) continue; const rawInput = (task.inputData ?? task.input_data ?? {}) as Record; - const marker = rawInput[TOOL_NAME_KEY]; - const toolName = typeof marker === "string" && marker !== "" ? marker : undefined; + const defName = String(task.taskDefName ?? task.task_def_name ?? taskType); + let toolName = _asName(rawInput[TOOL_NAME_KEY]); + + if (toolName === undefined && TOOL_ONLY_TASK_TYPES.has(taskType)) { + // `CALL_MCP_TOOL` names the tool in `method` — its task definition name is + // the transport's. An HTTP tool's definition name is already the tool's. + toolName = (taskType === "CALL_MCP_TOOL" ? _asName(rawInput.method) : undefined) ?? defName; + } if (toolName === undefined) { const ref = String(task.referenceTaskName ?? task.reference_task_name ?? ""); if (!ref.startsWith("call_")) continue; + toolName = defName; } const inputData = { ...rawInput }; @@ -2087,7 +2115,7 @@ export function _extractToolCalls(execution: Record): unknown[] } toolCalls.push({ - name: toolName ?? String(task.taskDefName ?? task.task_def_name ?? taskType), + name: toolName, args: inputData, result: task.outputData ?? task.output_data ?? {}, }); From fc2c9f59e70473b46f25e4abe5b31caa349b7486 Mon Sep 17 00:00:00 2001 From: francisco-orkes Date: Tue, 8 Sep 2026 16:48:46 -0700 Subject: [PATCH 3/5] fix(agents): read an agent tool's marker where the mapper leaves it Verified against a live 5.5.0 server: an agent tool's _agent_tool_name does not stay at the top of inputData. The sub-workflow task mapper rebuilds inputData around workflowInput and the marker rides along inside it, so the previous commit fell through to the "call_" heuristic and an agent-as-tool was still missed on a non-OpenAI provider. A handoff compiles to SUB_WORKFLOW too and carries the marker at neither level -- confirmed on a real handoff run, whose router and handoff tasks both come back unmarked. That absence is what keeps the two apart, so reading the nested marker does not turn handoffs into tool calls. Also stop upper-casing the last-resort name. The task-type fallback fed on the already-upper-cased copy, so a worker tool reached through it was reported as GETWEATHER -- louder than main's getweather and no more correct. A SIMPLE task's type is the tool's own name, so the raw spelling is the one worth keeping. The trimmed task shape run() actually receives is now pinned by tests. GET /agent/execution/{id} returns taskType, referenceTaskName, status and outputData with no inputData and no taskDefName, so no marker reaches the extraction on that path at all. --- .../__tests__/tool-call-extraction.test.ts | 80 ++++++++++++++++++- src/agents/runtime.ts | 19 ++++- 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/src/agents/__tests__/tool-call-extraction.test.ts b/src/agents/__tests__/tool-call-extraction.test.ts index 5d7481f5..b23f666c 100644 --- a/src/agents/__tests__/tool-call-extraction.test.ts +++ b/src/agents/__tests__/tool-call-extraction.test.ts @@ -101,20 +101,64 @@ describe("_extractToolCalls — tool naming", () => { }); describe("_extractToolCalls — which tasks count", () => { + // The sub-workflow task mapper rebuilds `inputData` around `workflowInput`, + // so an agent tool's marker arrives nested rather than at the top level. it("includes an agent invoked as a tool (SUB_WORKFLOW)", () => { const calls = extract([ toolTask({ referenceTaskName: "call_abc_0__1", taskType: "SUB_WORKFLOW", - taskDefName: "billing_agent_workflow", - inputData: { prompt: "refund status", _agent_tool_name: "billing_agent" }, + taskDefName: "billing_agent", + inputData: { + subWorkflowName: "billing_agent", + workflowInput: { prompt: "refund status", _agent_tool_name: "billing_agent" }, + }, outputData: { result: "refunded" }, }), ]); - expect(calls).toEqual([ - { name: "billing_agent", args: { prompt: "refund status" }, result: { result: "refunded" } }, + expect(calls.map((c) => c.name)).toEqual(["billing_agent"]); + expect(calls[0].result).toEqual({ result: "refunded" }); + }); + + it("includes an agent tool whatever the tool-call id format", () => { + const calls = extract([ + toolTask({ + referenceTaskName: "toolu_01A9EqMxQGxL_2__1", + taskType: "SUB_WORKFLOW", + taskDefName: "billing_agent", + inputData: { + subWorkflowName: "billing_agent", + workflowInput: { prompt: "refund status", _agent_tool_name: "billing_agent" }, + }, + }), ]); + + expect(calls.map((c) => c.name)).toEqual(["billing_agent"]); + }); + + // A handoff compiles to `SUB_WORKFLOW` as well, and carries the marker at + // neither level. That absence is the only thing separating the two. + it("excludes a handoff, which is a SUB_WORKFLOW carrying no marker", () => { + const calls = extract([ + toolTask({ + referenceTaskName: "support_handoff_0_billing_agent__1", + taskType: "SUB_WORKFLOW", + taskDefName: "billing_agent", + inputData: { + subWorkflowName: "billing_agent", + workflowInput: { prompt: "refund status", session_id: "s1" }, + }, + }), + toolTask({ + referenceTaskName: "support_router__1", + taskType: "SUB_WORKFLOW", + taskDefName: "support_router", + inputData: { subWorkflowName: "support_router", workflowInput: { prompt: "hi" } }, + }), + ]); + + expect(calls).toEqual([]); }); it("detects tools behind a non-OpenAI tool-call id format", () => { @@ -294,3 +338,31 @@ describe("_extractToolCalls — tools the dispatch script left unmarked", () => expect(calls).toEqual([]); }); }); + +// The agent-execution endpoint `run()` reads returns a trimmed task: task type, +// reference name, status and output, with no `inputData` and no task definition +// name. Nothing in it carries a declared tool name, so this pins how far the +// extraction can get on that shape rather than asserting the tool's real name. +describe("_extractToolCalls — the trimmed shape run() receives", () => { + const trimmed = (referenceTaskName: string, taskType: string) => ({ + referenceTaskName, + taskType, + status: "COMPLETED", + outputData: { result: "ok" }, + }); + + it("names a worker tool from its task type, which is the tool's own name", () => { + const calls = extract([trimmed("call_9852jJV2Kzyae3MCDGHPeyXa__1", "getWeather")]); + + expect(calls.map((c) => c.name)).toEqual(["getWeather"]); + }); + + it("cannot recover a tool name for a transport-typed task, and does not fold its case", () => { + const calls = extract([ + trimmed("call_mMLCQyj7CID3cjRLhvZNYV5p__1", "CALL_MCP_TOOL"), + trimmed("call_vlC3GlsOMbCG9F8UD1iAyYov_1__1", "HTTP"), + ]); + + expect(calls.map((c) => c.name)).toEqual(["CALL_MCP_TOOL", "HTTP"]); + }); +}); diff --git a/src/agents/runtime.ts b/src/agents/runtime.ts index b11a2c2a..cba6d6c2 100644 --- a/src/agents/runtime.ts +++ b/src/agents/runtime.ts @@ -2075,6 +2075,8 @@ function _asName(value: unknown): string | undefined { * which is the only field that survives every tool kind: the task type is the * transport (`HTTP`, `CALL_MCP_TOOL`, `SUB_WORKFLOW`, `HUMAN`, ...) and the task * definition name is the transport's own name for MCP, agent and media tools. + * An agent tool is the one kind whose marker moves: the sub-workflow task mapper + * rebuilds `inputData`, so the marker arrives nested inside `workflowInput`. * * Two signals back it up for tasks the dispatch script left unmarked — agents * that discover their tools at runtime, and servers predating the marker. A @@ -2090,13 +2092,26 @@ export function _extractToolCalls(execution: Record): unknown[] const toolCalls: unknown[] = []; for (const task of tasks) { - const taskType = String(task.taskType ?? task.task_type ?? "").toUpperCase(); + // A SIMPLE task's type is the tool's own name, so the raw spelling is worth + // keeping: it is the last thing left to name a tool by when the execution + // carries neither the marker nor a task definition name. + const rawType = String(task.taskType ?? task.task_type ?? ""); + const taskType = rawType.toUpperCase(); if (ORCHESTRATION_TASK_TYPES.has(taskType)) continue; const rawInput = (task.inputData ?? task.input_data ?? {}) as Record; - const defName = String(task.taskDefName ?? task.task_def_name ?? taskType); + const defName = String(task.taskDefName ?? task.task_def_name ?? rawType); let toolName = _asName(rawInput[TOOL_NAME_KEY]); + if (toolName === undefined && taskType === "SUB_WORKFLOW") { + // An agent tool's marker does not stay at the top level: the sub-workflow + // task mapper rebuilds `inputData` around `workflowInput`, and the marker + // rides along inside it. A handoff compiles to `SUB_WORKFLOW` too and + // carries the marker at neither level, which is what keeps the two apart. + const nested = rawInput.workflowInput as Record | undefined; + toolName = _asName(nested?.[TOOL_NAME_KEY]); + } + if (toolName === undefined && TOOL_ONLY_TASK_TYPES.has(taskType)) { // `CALL_MCP_TOOL` names the tool in `method` — its task definition name is // the transport's. An HTTP tool's definition name is already the tool's. From 55b90fb96d97191f33ac2065076c97ac619cb262 Mon Sep 17 00:00:00 2001 From: francisco-orkes Date: Tue, 8 Sep 2026 21:37:20 -0700 Subject: [PATCH 4/5] docs(agents): trim tool-call extraction comments Say what the code does and why, not how; the fallback order is already legible in the code itself. --- .../__tests__/tool-call-extraction.test.ts | 19 ++----- src/agents/runtime.ts | 55 +++++++------------ 2 files changed, 25 insertions(+), 49 deletions(-) diff --git a/src/agents/__tests__/tool-call-extraction.test.ts b/src/agents/__tests__/tool-call-extraction.test.ts index b23f666c..8906c371 100644 --- a/src/agents/__tests__/tool-call-extraction.test.ts +++ b/src/agents/__tests__/tool-call-extraction.test.ts @@ -1,11 +1,6 @@ /** * `result.toolCalls` extraction. - * - * The task shapes below mirror what the server's tool-dispatch script emits for - * each tool kind: a per-call reference name built from the provider's tool-call - * id, a task type that varies by kind (`HTTP`, `CALL_MCP_TOOL`, `SUB_WORKFLOW`, - * `HUMAN`, `SIMPLE`), and an `_agent_tool_name` marker carrying the tool's - * declared name on every dispatched tool. + * Task shapes mirror what the server emits per tool kind, taken from real runs. */ import { describe, it, expect } from "@jest/globals"; @@ -101,8 +96,7 @@ describe("_extractToolCalls — tool naming", () => { }); describe("_extractToolCalls — which tasks count", () => { - // The sub-workflow task mapper rebuilds `inputData` around `workflowInput`, - // so an agent tool's marker arrives nested rather than at the top level. + // The sub-workflow mapper leaves an agent tool's marker inside workflowInput. it("includes an agent invoked as a tool (SUB_WORKFLOW)", () => { const calls = extract([ toolTask({ @@ -137,8 +131,7 @@ describe("_extractToolCalls — which tasks count", () => { expect(calls.map((c) => c.name)).toEqual(["billing_agent"]); }); - // A handoff compiles to `SUB_WORKFLOW` as well, and carries the marker at - // neither level. That absence is the only thing separating the two. + // A handoff is also SUB_WORKFLOW; the missing marker is all that separates them. it("excludes a handoff, which is a SUB_WORKFLOW carrying no marker", () => { const calls = extract([ toolTask({ @@ -339,10 +332,8 @@ describe("_extractToolCalls — tools the dispatch script left unmarked", () => }); }); -// The agent-execution endpoint `run()` reads returns a trimmed task: task type, -// reference name, status and output, with no `inputData` and no task definition -// name. Nothing in it carries a declared tool name, so this pins how far the -// extraction can get on that shape rather than asserting the tool's real name. +// GET /agent/execution/{id} returns no inputData and no taskDefName, so no +// declared name reaches the SDK. These pin how far extraction gets on that shape. describe("_extractToolCalls — the trimmed shape run() receives", () => { const trimmed = (referenceTaskName: string, taskType: string) => ({ referenceTaskName, diff --git a/src/agents/runtime.ts b/src/agents/runtime.ts index cba6d6c2..23481465 100644 --- a/src/agents/runtime.ts +++ b/src/agents/runtime.ts @@ -1954,10 +1954,8 @@ function _isOutputJunk(output: unknown): boolean { } /** - * Task types that are orchestration scaffolding, never a tool invocation. - * - * `SUB_WORKFLOW` is deliberately absent: an agent exposed as a tool compiles to - * one, so skipping the type outright drops those calls. + * Task types that are orchestration, never a tool invocation. + * SUB_WORKFLOW is absent deliberately: an agent used as a tool compiles to one. */ const ORCHESTRATION_TASK_TYPES = new Set([ "LLM_CHAT_COMPLETE", @@ -1971,19 +1969,16 @@ const ORCHESTRATION_TASK_TYPES = new Set([ ]); /** - * Input key the server's tool-dispatch script writes on a dispatched tool. - * - * Written for every tool an agent declares up front. Agents that discover their - * tools at runtime compile to a second dispatch script that omits it, so its - * absence does not mean the task is not a tool call. + * Input key carrying a dispatched tool's declared name. + * Agents that discover tools at runtime compile to a script that omits it, + * so its absence doesn't rule out a tool call. */ const TOOL_NAME_KEY = "_agent_tool_name"; /** - * Task types only tool dispatch produces, so a task of one is a tool call even - * unmarked. The rest of a compiled agent's task types are ambiguous: it emits - * `SIMPLE`, `SUB_WORKFLOW` and `HUMAN` tasks of its own for workers, handoffs - * and approvals. + * Task types only tool dispatch emits, so these are tool calls even unmarked. + * SIMPLE, SUB_WORKFLOW and HUMAN are ambiguous — the compiler emits its own for + * guardrail workers, handoffs and approvals. */ const TOOL_ONLY_TASK_TYPES = new Set(["HTTP", "CALL_MCP_TOOL"]); @@ -2063,7 +2058,7 @@ function _extractMessages(execution: Record): unknown[] { return lastLlmMsgs; } -/** A task input field is usable as a tool name only if it is a non-empty string. */ +/** A usable tool name: a non-empty string. */ function _asName(value: unknown): string | undefined { return typeof value === "string" && value !== "" ? value : undefined; } @@ -2071,20 +2066,14 @@ function _asName(value: unknown): string | undefined { /** * Extract tool calls from execution tasks. * - * A dispatched tool carries its declared name in `inputData._agent_tool_name`, - * which is the only field that survives every tool kind: the task type is the - * transport (`HTTP`, `CALL_MCP_TOOL`, `SUB_WORKFLOW`, `HUMAN`, ...) and the task - * definition name is the transport's own name for MCP, agent and media tools. - * An agent tool is the one kind whose marker moves: the sub-workflow task mapper - * rebuilds `inputData`, so the marker arrives nested inside `workflowInput`. + * Tools are identified by the name the server marks on dispatch. Neither + * taskType nor taskDefName works alone: both name the transport for MCP, agent + * and media tools. * - * Two signals back it up for tasks the dispatch script left unmarked — agents - * that discover their tools at runtime, and servers predating the marker. A - * task-type check covers the types only tool dispatch emits. Everything else - * falls back to the original heuristic, a reference name prefixed with the - * provider's tool-call id, which only ever matched OpenAI's `call_` format. + * Unmarked tasks fall back to the task type, then to a `call_` reference-name + * prefix, which only matches OpenAI's tool-call id format. * - * @internal Not part of the published agent surface; exported for tests. + * @internal Exported for tests. */ export function _extractToolCalls(execution: Record): unknown[] { const tasks = execution.tasks as Record[] | undefined; @@ -2092,9 +2081,7 @@ export function _extractToolCalls(execution: Record): unknown[] const toolCalls: unknown[] = []; for (const task of tasks) { - // A SIMPLE task's type is the tool's own name, so the raw spelling is worth - // keeping: it is the last thing left to name a tool by when the execution - // carries neither the marker nor a task definition name. + // A SIMPLE task's type is the tool's own name, so keep the raw spelling. const rawType = String(task.taskType ?? task.task_type ?? ""); const taskType = rawType.toUpperCase(); if (ORCHESTRATION_TASK_TYPES.has(taskType)) continue; @@ -2104,17 +2091,15 @@ export function _extractToolCalls(execution: Record): unknown[] let toolName = _asName(rawInput[TOOL_NAME_KEY]); if (toolName === undefined && taskType === "SUB_WORKFLOW") { - // An agent tool's marker does not stay at the top level: the sub-workflow - // task mapper rebuilds `inputData` around `workflowInput`, and the marker - // rides along inside it. A handoff compiles to `SUB_WORKFLOW` too and - // carries the marker at neither level, which is what keeps the two apart. + // The sub-workflow mapper rebuilds inputData, leaving the marker inside + // workflowInput. A handoff is also SUB_WORKFLOW but carries no marker. const nested = rawInput.workflowInput as Record | undefined; toolName = _asName(nested?.[TOOL_NAME_KEY]); } if (toolName === undefined && TOOL_ONLY_TASK_TYPES.has(taskType)) { - // `CALL_MCP_TOOL` names the tool in `method` — its task definition name is - // the transport's. An HTTP tool's definition name is already the tool's. + // CALL_MCP_TOOL's taskDefName is the transport's; the tool is in `method`. + // An HTTP tool's taskDefName is already the tool's own. toolName = (taskType === "CALL_MCP_TOOL" ? _asName(rawInput.method) : undefined) ?? defName; } From b607811ed9d1ca80e816964129b6327ef90e2c9b Mon Sep 17 00:00:00 2001 From: francisco-orkes Date: Tue, 8 Sep 2026 23:08:59 -0700 Subject: [PATCH 5/5] fix(agents): correct the extraction doc comment, rename _asName The comment said unmarked tasks fall back to the task type; they fall back to taskDefName. _nonEmptyString says what it checks and that it can return undefined. --- src/agents/runtime.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/agents/runtime.ts b/src/agents/runtime.ts index 23481465..a0c1aa49 100644 --- a/src/agents/runtime.ts +++ b/src/agents/runtime.ts @@ -2058,8 +2058,8 @@ function _extractMessages(execution: Record): unknown[] { return lastLlmMsgs; } -/** A usable tool name: a non-empty string. */ -function _asName(value: unknown): string | undefined { +/** Returns the value if it is a non-empty string, else undefined. */ +function _nonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value !== "" ? value : undefined; } @@ -2070,8 +2070,9 @@ function _asName(value: unknown): string | undefined { * taskType nor taskDefName works alone: both name the transport for MCP, agent * and media tools. * - * Unmarked tasks fall back to the task type, then to a `call_` reference-name - * prefix, which only matches OpenAI's tool-call id format. + * Unmarked tasks fall back to the task definition name. Ones whose type isn't + * tool-only need a `call_` reference prefix to count at all, which matches + * OpenAI's tool-call id format alone. * * @internal Exported for tests. */ @@ -2088,19 +2089,19 @@ export function _extractToolCalls(execution: Record): unknown[] const rawInput = (task.inputData ?? task.input_data ?? {}) as Record; const defName = String(task.taskDefName ?? task.task_def_name ?? rawType); - let toolName = _asName(rawInput[TOOL_NAME_KEY]); + let toolName = _nonEmptyString(rawInput[TOOL_NAME_KEY]); if (toolName === undefined && taskType === "SUB_WORKFLOW") { // The sub-workflow mapper rebuilds inputData, leaving the marker inside // workflowInput. A handoff is also SUB_WORKFLOW but carries no marker. const nested = rawInput.workflowInput as Record | undefined; - toolName = _asName(nested?.[TOOL_NAME_KEY]); + toolName = _nonEmptyString(nested?.[TOOL_NAME_KEY]); } if (toolName === undefined && TOOL_ONLY_TASK_TYPES.has(taskType)) { // CALL_MCP_TOOL's taskDefName is the transport's; the tool is in `method`. // An HTTP tool's taskDefName is already the tool's own. - toolName = (taskType === "CALL_MCP_TOOL" ? _asName(rawInput.method) : undefined) ?? defName; + toolName = (taskType === "CALL_MCP_TOOL" ? _nonEmptyString(rawInput.method) : undefined) ?? defName; } if (toolName === undefined) {