From 41009e765c2202b67241826519d20abbf36cc3cd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 19 Aug 2026 17:54:58 -0700 Subject: [PATCH] fix(agents): stop after terminal dynamic tool results (#126208) * fix(agents): preserve terminal tool results across bridges * fix(agents): scope terminal results to completed tool batches --- src/agents/code-mode.test.ts | 51 +++++++++ src/agents/code-mode.ts | 4 +- src/agents/code-mode.wait.test.ts | 100 ++++++++++++++++++ .../run/attempt-result.test.ts | 6 ++ .../run/attempt-result.ts | 19 ++-- .../run/incomplete-turn-recovery.ts | 9 ++ .../run/settled-tool-evidence.test.ts | 94 ++++++++++++++++ src/agents/embedded-agent-runner/run/types.ts | 2 + ...ent-subscribe.handlers.tools.completion.ts | 7 ++ ...ded-agent-subscribe.handlers.tools.test.ts | 23 ++++ ...embedded-agent-subscribe.handlers.types.ts | 2 + src/agents/tool-search-ranking.ts | 24 +++++ src/agents/tool-search-runtime.test.ts | 50 +++++++++ src/agents/tool-search-runtime.ts | 66 ++++++------ 14 files changed, 411 insertions(+), 46 deletions(-) diff --git a/src/agents/code-mode.test.ts b/src/agents/code-mode.test.ts index f7210a8394fd..890b3de61d16 100644 --- a/src/agents/code-mode.test.ts +++ b/src/agents/code-mode.test.ts @@ -3,6 +3,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { Type } from "typebox"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as codeModeExecution from "./code-mode-execution.js"; import { applyCodeModeCatalog, CODE_MODE_EXEC_TOOL_NAME, @@ -13,6 +14,7 @@ import { resetCodeModeTestState, fakeTool, pluginTool, + pluginToolWithExecute, mcpTool, createCodeModeHarness, } from "./code-mode.test-support.js"; @@ -22,7 +24,10 @@ import { TOOL_DESCRIBE_RAW_TOOL_NAME, TOOL_SEARCH_CODE_MODE_TOOL_NAME, TOOL_SEARCH_RAW_TOOL_NAME, + resolveToolSearchConfig, + ToolSearchRuntime, } from "./tool-search.js"; +import { jsonResult } from "./tools/common.js"; describe("Code Mode catalog and model-visible surface", () => { beforeEach(() => { @@ -31,9 +36,55 @@ describe("Code Mode catalog and model-visible surface", () => { afterEach(() => { vi.useRealTimers(); + vi.restoreAllMocks(); resetCodeModeTestState(); }); + const runTerminalNestedCall = async ( + params: Pick< + Parameters[0], + "toolCallId" | "ctx" | "onRuntime" + >, + ) => { + const runtime = new ToolSearchRuntime(params.ctx, resolveToolSearchConfig({} as never)); + params.onRuntime?.(runtime); + await runtime.call("terminal_action", {}, { parentToolCallId: params.toolCallId }); + return { + status: "completed" as const, + value: null, + output: [], + replaySafe: false, + telemetry: { + ...runtime.telemetry(), + visibleTools: [CODE_MODE_EXEC_TOOL_NAME, CODE_MODE_WAIT_TOOL_NAME], + }, + }; + }; + + it("projects a nested terminal result from exec", async () => { + const { config, catalogRef, tools } = createCodeModeHarness(); + vi.spyOn(codeModeExecution, "runCodeModeExec").mockImplementation(runTerminalNestedCall); + const terminal = pluginToolWithExecute("terminal_action", "Terminal action", async () => ({ + ...jsonResult({ terminal: true }), + terminate: true, + })); + applyCodeModeCatalog({ + tools: [...tools, terminal], + config, + sessionId: "session-code-mode", + sessionKey: "agent:main:main", + runId: "run-code-mode", + catalogRef, + }); + + const result = await expectDefined(tools[0], "exec tool").execute("exec-terminal", { + code: 'return await tools.call("terminal_action", {});', + }); + + expect(result.details).toMatchObject({ status: "completed" }); + expect(result.terminate).toBe(true); + }); + it("hides all normal tools behind exec and wait", () => { const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness(); const shellExec = fakeTool("exec", "Run shell command"); diff --git a/src/agents/code-mode.ts b/src/agents/code-mode.ts index 84b13f5c80e5..acc997a74b7f 100644 --- a/src/agents/code-mode.ts +++ b/src/agents/code-mode.ts @@ -225,7 +225,7 @@ export function createCodeModeTools(ctx: CodeModeToolContext): AnyAgentTool[] { }, }), ); - return formatToolSearchControlResult(result, runtime); + return formatToolSearchControlResult(result, runtime, undefined, result.status); }, } as AnyAgentTool); const waitTool = markCodeModeControlTool({ @@ -255,7 +255,7 @@ export function createCodeModeTools(ctx: CodeModeToolContext): AnyAgentTool[] { }, }), ); - return formatToolSearchControlResult(result, runtime); + return formatToolSearchControlResult(result, runtime, undefined, result.status); }, } as AnyAgentTool); return [execTool, waitTool]; diff --git a/src/agents/code-mode.wait.test.ts b/src/agents/code-mode.wait.test.ts index 474f32f06ef8..57189fddf8af 100644 --- a/src/agents/code-mode.wait.test.ts +++ b/src/agents/code-mode.wait.test.ts @@ -13,6 +13,14 @@ import { testing, } from "./code-mode.test-support.js"; import { createToolSearchCatalogRef } from "./tool-search.js"; +import { jsonResult } from "./tools/common.js"; + +function createTerminalBridgeHarness() { + const harness = createCodeModeHarness(); + const config = { tools: { codeMode: { enabled: true, timeoutMs: 60_000 } } } as never; + const ctx = { ...harness.ctx, config, runtimeConfig: config }; + return { ...harness, config, tools: createCodeModeTools(ctx) }; +} describe("Code Mode wait, scope, and suspended runs", () => { beforeEach(() => { @@ -69,6 +77,98 @@ describe("Code Mode wait, scope, and suspended runs", () => { expect(resumed.output).toEqual([{ type: "text", text: "after" }]); }); + it("retains terminal bridge evidence until a yielded run completes through wait", async () => { + const { config, catalogRef, tools } = createTerminalBridgeHarness(); + const terminal = pluginToolWithExecute("terminal_action", "Terminal action", async () => ({ + ...jsonResult({ terminal: true }), + terminate: true, + })); + applyCodeModeCatalog({ + tools: [...tools, terminal], + config, + sessionId: "session-code-mode", + sessionKey: "agent:main:main", + runId: "run-code-mode", + catalogRef, + }); + + const suspended = await expectDefined(tools[0], "exec tool").execute( + "code-call-terminal-yield", + { + code: ` + await tools.callValue("terminal_action", {}); + await yield_control("pause"); + return "done"; + `, + }, + ); + + expect(resultDetails(suspended).status).toBe("waiting"); + expect(suspended.terminate).toBeUndefined(); + + let resumed = await expectDefined(tools[1], "wait tool").execute("code-wait-terminal-yield", { + runId: resultDetails(suspended).runId, + }); + for (let index = 1; index < 8 && resultDetails(resumed).status === "waiting"; index += 1) { + expect(resumed.terminate).toBeUndefined(); + resumed = await expectDefined(tools[1], "wait tool").execute( + `code-wait-terminal-yield-${index}`, + { runId: resultDetails(resumed).runId }, + ); + } + + expect(resultDetails(resumed)).toMatchObject({ status: "completed", value: "done" }); + expect(resumed.terminate).toBe(true); + }); + + it("discards retained terminal bridge evidence when a yielded run fails", async () => { + const { config, catalogRef, tools } = createTerminalBridgeHarness(); + const terminal = pluginToolWithExecute("terminal_action", "Terminal action", async () => ({ + ...jsonResult({ terminal: true }), + terminate: true, + })); + applyCodeModeCatalog({ + tools: [...tools, terminal], + config, + sessionId: "session-code-mode", + sessionKey: "agent:main:main", + runId: "run-code-mode", + catalogRef, + }); + + const suspended = await expectDefined(tools[0], "exec tool").execute( + "code-call-terminal-yield-failure", + { + code: ` + await tools.callValue("terminal_action", {}); + await yield_control("pause"); + throw new Error("resumed failure"); + `, + }, + ); + + expect(resultDetails(suspended).status).toBe("waiting"); + expect(suspended.terminate).toBeUndefined(); + + let resumed = await expectDefined(tools[1], "wait tool").execute( + "code-wait-terminal-yield-failure", + { runId: resultDetails(suspended).runId }, + ); + for (let index = 1; index < 8 && resultDetails(resumed).status === "waiting"; index += 1) { + expect(resumed.terminate).toBeUndefined(); + resumed = await expectDefined(tools[1], "wait tool").execute( + `code-wait-terminal-yield-failure-${index}`, + { runId: resultDetails(resumed).runId }, + ); + } + + expect(resultDetails(resumed)).toMatchObject({ + status: "failed", + error: expect.stringContaining("resumed failure"), + }); + expect(resumed.terminate).toBeUndefined(); + }); + it("keeps a safe suspension clean and wraps network content after wait resumes it", async () => { const { config, catalogRef, tools } = createCodeModeHarness(); const hostile = "Page instruction <|endoftext|>"; diff --git a/src/agents/embedded-agent-runner/run/attempt-result.test.ts b/src/agents/embedded-agent-runner/run/attempt-result.test.ts index 78e38c6d38a3..4a081ca47b92 100644 --- a/src/agents/embedded-agent-runner/run/attempt-result.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-result.test.ts @@ -17,9 +17,11 @@ function completeResult(params?: { yieldAcknowledgment?: string; toolMetas?: Array<{ toolName: string; + toolCallId?: string; meta?: string; replaySafe?: boolean; isError?: boolean; + terminate?: boolean; asyncStarted?: boolean; asyncTaskRunId?: string; asyncTaskId?: string; @@ -177,9 +179,11 @@ describe("attempt result projection", () => { { toolName: "read", isError: false }, { toolName: "exec", + toolCallId: "tool-current", meta: "done", replaySafe: true, isError: true, + terminate: true, asyncStarted: true, asyncTaskRunId: "run-1", asyncTaskId: "task-1", @@ -195,9 +199,11 @@ describe("attempt result projection", () => { }, { toolName: "exec", + toolCallId: "tool-current", meta: "done", replaySafe: true, isError: true, + terminate: true, asyncStarted: true, asyncTaskRunId: "run-1", asyncTaskId: "task-1", diff --git a/src/agents/embedded-agent-runner/run/attempt-result.ts b/src/agents/embedded-agent-runner/run/attempt-result.ts index e7f714f9520c..57b4e3d4854e 100644 --- a/src/agents/embedded-agent-runner/run/attempt-result.ts +++ b/src/agents/embedded-agent-runner/run/attempt-result.ts @@ -110,17 +110,8 @@ function normalizeEmbeddedAttemptToolMetas( ): EmbeddedRunAttemptResult["toolMetas"] { return entries .filter( - ( - entry, - ): entry is { - toolName: string; - meta?: string; - replaySafe?: boolean; - isError?: boolean; - asyncStarted?: boolean; - asyncTaskRunId?: string; - asyncTaskId?: string; - } => typeof entry.toolName === "string" && entry.toolName.trim().length > 0, + (entry): entry is EmbeddedAttemptSubscription["toolMetas"][number] & { toolName: string } => + typeof entry.toolName === "string" && entry.toolName.trim().length > 0, ) .map((entry) => { const normalized: EmbeddedRunAttemptResult["toolMetas"][number] = { @@ -128,9 +119,15 @@ function normalizeEmbeddedAttemptToolMetas( meta: entry.meta, replaySafe: entry.replaySafe === true, }; + if (entry.toolCallId) { + normalized.toolCallId = entry.toolCallId; + } if (typeof entry.isError === "boolean") { normalized.isError = entry.isError; } + if (entry.terminate === true) { + normalized.terminate = true; + } if (entry.asyncStarted === true) { normalized.asyncStarted = true; } diff --git a/src/agents/embedded-agent-runner/run/incomplete-turn-recovery.ts b/src/agents/embedded-agent-runner/run/incomplete-turn-recovery.ts index 4b896c576e16..501db028ca82 100644 --- a/src/agents/embedded-agent-runner/run/incomplete-turn-recovery.ts +++ b/src/agents/embedded-agent-runner/run/incomplete-turn-recovery.ts @@ -302,6 +302,14 @@ export function resolveSettledToolTerminalContinuationInstruction(params: { ), ); const hasSettledTerminalToolFailure = allToolsProvenSettled && failedTerminalToolNames.size > 0; + const hasIntentionalTerminalToolBatch = + allToolsProvenSettled && + requestedToolCalls.every( + ({ id, name }) => + params.attempt.toolMetas.findLast( + (meta) => meta.toolCallId === id && meta.toolName === name, + )?.terminate === true, + ); // ToolErrorSummary has no call id: its owner must match a failed result in the // proven terminal batch, or a stale/unrelated error could authorize finalization. const hasUnsettledToolError = Boolean( @@ -317,6 +325,7 @@ export function resolveSettledToolTerminalContinuationInstruction(params: { ((params.timedOut || params.attempt.terminal.kind === "timeout") && !idlePromptTimeout) || (terminal.kind === "failed" && !params.attempt.settledTurnFinalizationContext) || (assistant?.stopReason === "toolUse" ? !allToolsProvenSettled : !emptyStopAfterSettledTools) || + hasIntentionalTerminalToolBatch || hasUnsettledToolError || hasAsyncActivity(params.attempt.toolMetas) || hasAcceptedSessionSpawn(params.attempt.acceptedSessionSpawns) || diff --git a/src/agents/embedded-agent-runner/run/settled-tool-evidence.test.ts b/src/agents/embedded-agent-runner/run/settled-tool-evidence.test.ts index 6314b3906869..50cb38abd8c8 100644 --- a/src/agents/embedded-agent-runner/run/settled-tool-evidence.test.ts +++ b/src/agents/embedded-agent-runner/run/settled-tool-evidence.test.ts @@ -153,6 +153,100 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { expect(instruction).toBe(SETTLED_TOOL_TERMINAL_CONTINUATION_INSTRUCTION); }); + it("suppresses continuation for an exactly matched all-terminal current batch", () => { + const attempt = makeSettledIdleWriteAttempt(); + const instruction = resolveSettledToolTerminalContinuationInstruction( + makeSettledContinuationParams({ + ...attempt, + toolMetas: [ + { + toolName: "write", + toolCallId: "tool_1", + replaySafe: false, + terminate: true, + }, + ], + }), + ); + + expect(instruction).toBeNull(); + }); + + it("continues when terminal metadata belongs to a stale prior call", () => { + const attempt = makeSettledIdleWriteAttempt(); + const instruction = resolveSettledToolTerminalContinuationInstruction( + makeSettledContinuationParams({ + ...attempt, + toolMetas: [ + { + toolName: "write", + toolCallId: "tool_stale", + replaySafe: false, + terminate: true, + }, + ], + }), + ); + + expect(instruction).toBe(SETTLED_TOOL_TERMINAL_CONTINUATION_INSTRUCTION); + }); + + it.each([ + { + label: "nonterminal", + currentMeta: { toolName: "write", toolCallId: "tool_1" }, + expected: SETTLED_TOOL_TERMINAL_CONTINUATION_INSTRUCTION, + }, + { + label: "terminal", + currentMeta: { toolName: "write", toolCallId: "tool_1", terminate: true }, + expected: null, + }, + ])( + "uses the $label current occurrence when a provider reuses a tool-call id", + ({ currentMeta, expected }) => { + const attempt = makeSettledIdleWriteAttempt(); + const instruction = resolveSettledToolTerminalContinuationInstruction( + makeSettledContinuationParams({ + ...attempt, + toolMetas: [{ toolName: "write", toolCallId: "tool_1", terminate: true }, currentMeta], + }), + ); + + expect(instruction).toBe(expected); + }, + ); + + it("continues when the current requested batch mixes terminal and nonterminal results", () => { + const attempt = makeSettledIdleWriteAttempt(); + const toolUseAssistant = makeLastAssistant({ + stopReason: "toolUse", + content: [ + { type: "toolCall", id: "tool_1", name: "write", arguments: {} }, + { type: "toolCall", id: "tool_2", name: "read", arguments: {} }, + ], + }); + const instruction = resolveSettledToolTerminalContinuationInstruction( + makeSettledContinuationParams({ + ...attempt, + toolMetas: [ + { toolName: "write", toolCallId: "tool_1", terminate: true }, + { toolName: "read", toolCallId: "tool_2" }, + ], + itemLifecycle: { startedCount: 2, completedCount: 2, activeCount: 0 }, + messagesSnapshot: [ + { role: "user", content: [{ type: "text", text: "current turn" }] }, + toolUseAssistant, + { role: "toolResult", toolCallId: "tool_1", toolName: "write", isError: false }, + { role: "toolResult", toolCallId: "tool_2", toolName: "read", isError: false }, + attempt.currentAttemptAssistant!, + ] as unknown as EmbeddedRunAttemptResult["messagesSnapshot"], + }), + ); + + expect(instruction).toBe(SETTLED_TOOL_TERMINAL_CONTINUATION_INSTRUCTION); + }); + it.each([ { label: "provider failure with finalization context", diff --git a/src/agents/embedded-agent-runner/run/types.ts b/src/agents/embedded-agent-runner/run/types.ts index c262b479bc0d..ab676cdc9179 100644 --- a/src/agents/embedded-agent-runner/run/types.ts +++ b/src/agents/embedded-agent-runner/run/types.ts @@ -285,9 +285,11 @@ export type EmbeddedRunAttemptResult = { lastAssistantTextMessageIndex?: number; toolMetas: Array<{ toolName: string; + toolCallId?: string; meta?: string; replaySafe?: boolean; isError?: boolean; + terminate?: boolean; asyncStarted?: boolean; asyncTaskRunId?: string; asyncTaskId?: string; diff --git a/src/agents/embedded-agent-subscribe.handlers.tools.completion.ts b/src/agents/embedded-agent-subscribe.handlers.tools.completion.ts index ebb886228669..f8bf523a1b96 100644 --- a/src/agents/embedded-agent-subscribe.handlers.tools.completion.ts +++ b/src/agents/embedded-agent-subscribe.handlers.tools.completion.ts @@ -184,11 +184,18 @@ export async function handleToolExecutionEnd( const meta = callSummary.meta; const asyncStarted = !isToolError && isAsyncStartedToolResult(sanitizedResult); const asyncTaskIds = asyncStarted ? readAsyncStartedTaskIds(sanitizedResult) : {}; + const terminate = + result !== null && + typeof result === "object" && + "terminate" in result && + result.terminate === true; ctx.state.toolMetas.push({ toolName, + toolCallId, meta, replaySafe: callSummary.replaySafe, isError: observerIsError, + ...(terminate ? { terminate: true } : {}), ...(asyncStarted ? { asyncStarted: true, ...asyncTaskIds } : {}), }); const acceptedSessionSpawn = diff --git a/src/agents/embedded-agent-subscribe.handlers.tools.test.ts b/src/agents/embedded-agent-subscribe.handlers.tools.test.ts index 717f39f11530..e65f7bfaeecb 100644 --- a/src/agents/embedded-agent-subscribe.handlers.tools.test.ts +++ b/src/agents/embedded-agent-subscribe.handlers.tools.test.ts @@ -2142,6 +2142,29 @@ describe("handleToolExecutionEnd timeout metadata", () => { expect(ctx.state.toolMetas[2]?.asyncStarted).toBe(true); }); + it("records intentional termination with its exact tool call id", async () => { + const { ctx } = createTestContext(); + + await endTool(ctx, { + toolName: "terminal_action", + toolCallId: "tool-terminal-current", + isError: false, + result: { + content: [{ type: "text", text: "Done." }], + details: { status: "done" }, + terminate: true, + }, + }); + + expect(ctx.state.toolMetas).toEqual([ + expect.objectContaining({ + toolName: "terminal_action", + toolCallId: "tool-terminal-current", + terminate: true, + }), + ]); + }); + it("retains every failed call after later successes change the last-error slot", async () => { const { ctx } = createTestContext(); diff --git a/src/agents/embedded-agent-subscribe.handlers.types.ts b/src/agents/embedded-agent-subscribe.handlers.types.ts index dc5bc07021f8..00480c3c65cd 100644 --- a/src/agents/embedded-agent-subscribe.handlers.types.ts +++ b/src/agents/embedded-agent-subscribe.handlers.types.ts @@ -75,9 +75,11 @@ export type EmbeddedAgentSubscribeState = { assistantTexts: string[]; toolMetas: Array<{ toolName?: string; + toolCallId?: string; meta?: string; replaySafe?: boolean; isError?: boolean; + terminate?: boolean; asyncStarted?: boolean; asyncTaskRunId?: string; asyncTaskId?: string; diff --git a/src/agents/tool-search-ranking.ts b/src/agents/tool-search-ranking.ts index 861f2f137eab..948a0a08d6ca 100644 --- a/src/agents/tool-search-ranking.ts +++ b/src/agents/tool-search-ranking.ts @@ -3,6 +3,30 @@ // Both surfaces answer the same question — "which tools does this query mean?" — // so they index and score through here rather than keeping separate heuristics // that can disagree about the same catalog. +import { isRecord } from "@openclaw/normalization-core/record-coerce"; + +/** Collects property names and descriptions from a JSON-Schema-shaped value. */ +export function readParameterText(parameters: unknown, depth = 0): string { + if (depth > 4 || !isRecord(parameters)) { + return ""; + } + const parts: string[] = []; + const description = parameters.description; + if (typeof description === "string") { + parts.push(description); + } + const properties = parameters.properties; + if (isRecord(properties)) { + for (const [name, child] of Object.entries(properties)) { + parts.push(name, readParameterText(child, depth + 1)); + } + } + const items = parameters.items; + if (items !== undefined) { + parts.push(readParameterText(items, depth + 1)); + } + return parts.filter(Boolean).join(" "); +} /** BM25 term-frequency saturation. Standard Okapi default. */ const BM25_K1 = 1.2; diff --git a/src/agents/tool-search-runtime.test.ts b/src/agents/tool-search-runtime.test.ts index a6f286f57b41..5bdf2d14addc 100644 --- a/src/agents/tool-search-runtime.test.ts +++ b/src/agents/tool-search-runtime.test.ts @@ -459,6 +459,56 @@ describe("Tool Search dispatcher argument preparation", () => { }); }); +describe("Tool Search terminal results", () => { + it("preserves a terminal target result on the direct control", async () => { + const target = fakeTool("terminal_action"); + target.execute = vi.fn(async () => ({ + ...jsonResult({ outcome: "terminal" }), + terminate: true, + })); + const { catalogRef, config } = createRuntime([target]); + const callTool = createToolSearchTools({ catalogRef, config }).find( + (tool) => tool.name === TOOL_CALL_RAW_TOOL_NAME, + ); + + const result = await callTool!.execute("terminal-parent", { id: target.name }); + + expect(result.terminate).toBe(true); + expect(result.details).toMatchObject({ result: { terminate: true } }); + }); + + it.each([ + { secondTerminal: true, expectedTerminal: true }, + { secondTerminal: false, expectedTerminal: undefined }, + ])( + "uses all-terminal semantics for code controls: $secondTerminal", + async ({ secondTerminal, expectedTerminal }) => { + const first = fakeTool("first_action"); + first.execute = vi.fn(async () => ({ ...jsonResult({ first: true }), terminate: true })); + const second = fakeTool("second_action"); + second.execute = vi.fn(async () => ({ + ...jsonResult({ second: true }), + ...(secondTerminal ? { terminate: true } : {}), + })); + const { catalogRef, config } = createRuntime([first, second]); + const codeTool = createToolSearchTools({ catalogRef, config }).find( + (tool) => tool.name === TOOL_SEARCH_CODE_MODE_TOOL_NAME, + ); + + const result = await codeTool!.execute("code-parent", { + code: ` + await openclaw.tools.call("first_action", {}); + return await openclaw.tools.call("second_action", {}); + `, + }); + + expect(result.terminate).toBe(expectedTerminal); + expect(first.execute).toHaveBeenCalledOnce(); + expect(second.execute).toHaveBeenCalledOnce(); + }, + ); +}); + describe("Tool Search input schemas", () => { it("validates arguments after a policy hook repairs them", async () => { const hook = vi.fn(async () => ({ params: { instruction: "repaired" } })); diff --git a/src/agents/tool-search-runtime.ts b/src/agents/tool-search-runtime.ts index 733f22344364..12eeeb61f362 100644 --- a/src/agents/tool-search-runtime.ts +++ b/src/agents/tool-search-runtime.ts @@ -30,6 +30,7 @@ import { } from "./tool-search-catalog.js"; import { buildLexicalIndex, + readParameterText, scoreLexical, tokenizeDocument, tokenizeQuery, @@ -77,30 +78,6 @@ function toolSearchEntryText(entry: ToolSearchCatalogEntry, parameterText?: stri .join(" "); } -/** Collects property names and descriptions from a JSON-Schema-shaped value. */ -function readParameterText(parameters: unknown, depth = 0): string { - if (depth > 4 || !isRecord(parameters)) { - return ""; - } - const parts: string[] = []; - const description = parameters.description; - if (typeof description === "string") { - parts.push(description); - } - const properties = parameters.properties; - if (isRecord(properties)) { - for (const [name, child] of Object.entries(properties)) { - parts.push(name); - parts.push(readParameterText(child, depth + 1)); - } - } - const items = parameters.items; - if (items !== undefined) { - parts.push(readParameterText(items, depth + 1)); - } - return parts.filter(Boolean).join(" "); -} - function tokenizeLookupValue(input: string): Set { return new Set(normalizeStringEntries(input.toLowerCase().split(/[^a-z0-9]+/u))); } @@ -474,6 +451,7 @@ function sanitizeToolCallIdPart(value: string): string { export class ToolSearchRuntime { private callSequence = 0; + private readonly terminalTargetBatchByParent = new Map(); private readonly networkInvocations = new Map(); private readonly searchIndexes = new WeakMap< ToolSearchCatalogSession, @@ -596,6 +574,16 @@ export class ToolSearchRuntime { : this.networkInvocations.size > 0; } + takeTerminalTargetBatch(parentToolCallId?: string): boolean { + const parent = + parentToolCallId ?? + (this.terminalTargetBatchByParent.size === 1 + ? (this.terminalTargetBatchByParent.keys().next().value ?? "") + : ""); + const terminal = this.terminalTargetBatchByParent.get(parent) === true; + return this.terminalTargetBatchByParent.delete(parent) && terminal; + } + isReplaySafeExactId = (id: string): boolean => { let entry: ToolSearchCatalogEntry; try { @@ -713,6 +701,14 @@ export class ToolSearchRuntime { ) : await runExecution(); const acceptedResult = await acceptResultBeforeProjection(result); + const parentToolCallId = options?.parentToolCallId; + if (parentToolCallId) { + this.terminalTargetBatchByParent.set( + parentToolCallId, + this.terminalTargetBatchByParent.get(parentToolCallId) !== false && + acceptedResult.terminate === true, + ); + } return { tool: compactToolSearchCatalogEntry(entry), result: acceptedResult }; }; @@ -726,18 +722,22 @@ export function formatToolSearchControlResult( payload: T, runtime: ToolSearchRuntime | undefined, parentToolCallId?: string, + terminalBatchStatus?: "waiting" | "completed" | "failed", ): AgentToolResult { - const result = jsonResult(payload); + let result: AgentToolResult = jsonResult(payload); const content = result.content[0]; - if (!runtime?.hasNetworkContent(parentToolCallId) || content?.type !== "text") { - return result; + if (runtime?.hasNetworkContent(parentToolCallId) && content?.type === "text") { + const bounded = truncateSanitizedExternalContent(content.text, 20_000); + const modelText = bounded.truncated + ? `${truncateSanitizedExternalContent(content.text, 19_988).text}\n[truncated]` + : bounded.text; + const text = wrapExternalContent(modelText, { source: "api" }); + result = { ...result, content: [{ ...content, text }] }; } - const bounded = truncateSanitizedExternalContent(content.text, 20_000); - const modelText = bounded.truncated - ? `${truncateSanitizedExternalContent(content.text, 19_988).text}\n[truncated]` - : bounded.text; - const text = wrapExternalContent(modelText, { source: "api" }); - return { ...result, content: [{ ...content, text }] }; + const terminal = + terminalBatchStatus !== "waiting" && + runtime?.takeTerminalTargetBatch(parentToolCallId) === true; + return terminalBatchStatus !== "failed" && terminal ? { ...result, terminate: true } : result; } /** Keep dynamic failures rejected without exposing network-controlled error text. */