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
This commit is contained in:
Peter Steinberger
2026-08-19 17:54:58 -07:00
committed by GitHub
parent c6ecfd26e5
commit 41009e765c
14 changed files with 411 additions and 46 deletions
+51
View File
@@ -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<typeof codeModeExecution.runCodeModeExec>[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");
+2 -2
View File
@@ -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];
+100
View File
@@ -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|>";
@@ -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",
@@ -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;
}
@@ -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) ||
@@ -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",
@@ -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;
@@ -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 =
@@ -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();
@@ -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;
+24
View File
@@ -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;
+50
View File
@@ -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" } }));
+33 -33
View File
@@ -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<string> {
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<string, boolean>();
private readonly networkInvocations = new Map<string, { active: number; observed: boolean }>();
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<T>(
payload: T,
runtime: ToolSearchRuntime | undefined,
parentToolCallId?: string,
terminalBatchStatus?: "waiting" | "completed" | "failed",
): AgentToolResult<T> {
const result = jsonResult(payload);
let result: AgentToolResult<T> = 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. */