mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
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:
committed by
GitHub
parent
c6ecfd26e5
commit
41009e765c
@@ -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");
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" } }));
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user