mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(active-memory): preserve verbose recall summaries (#90739)
* fix(active-memory): preserve verbose recall summaries * fix(active-memory): require recall evidence for recovery * fix(active-memory): recognize capped recall results * fix(active-memory): preserve grounded recall state * refactor(active-memory): limit recovery to completed recalls * fix(active-memory): ground terminal recall recovery * fix(active-memory): limit unavailable recovery to completed replies * fix(active-memory): harden recall evidence recovery * fix(active-memory): preserve timeout recovery contract * fix(active-memory): preserve capped failure evidence * fix(active-memory): reject content-only recall failures * fix(active-memory): ground completed recall summaries * fix(active-memory): separate hook and recall timeouts * fix(active-memory): classify custom tool failures * fix(active-memory): preserve harness tool evidence * fix(active-memory): reject explicit empty results * fix(active-memory): wait for fallback recall evidence * fix(codex): report dynamic tool results * fix(active-memory): separate preflight recall deadline * fix(active-memory): normalize recall tool names * fix(agents): classify unavailable approvals * docs(active-memory): clarify hook timeout phases * test(active-memory): stabilize timeout abort proof * fix(agents): preserve successful cancellation outcomes --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
b5887d8c887a53a997dc4c5220f6b9d5adffbddb58e4b25ae0d20ca06850d0ca config-baseline.json
|
||||
0485ba902d2afd89d2c41cde7180d0cec2900b2db6804b9f97d42b7d85cd3af5 config-baseline.json
|
||||
72bb80be618406f3337eaa2560d2559a35e49bd29576de8dd4a3aec1a6a94d92 config-baseline.core.json
|
||||
1218f5555541b61bd5ddcac6441f15061b44789e2471d4ffecbe3059777c55c1 config-baseline.channel.json
|
||||
b0dec5acfe60557e728e5ad03cc36d19d2432d51f755656c97846afa7fbe374a config-baseline.plugin.json
|
||||
a14ac4261e98403d1a7e047070e6f151938444e27382b860315bd0c74fda4861 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
ab2a32b037be61953ad32d2498468bc812b0794ba6135530cefd1c8326d69de8 plugin-sdk-api-baseline.json
|
||||
2c7bca3b46e0edd08ed445a241bb0a80b77635bf82825d5201ce41a8759c0e56 plugin-sdk-api-baseline.jsonl
|
||||
85c3572e6ed2bfe3df92c7d53cef465b30d2e861ad9529009faa287cdc5aec71 plugin-sdk-api-baseline.json
|
||||
0d7c7e42d04b97d40519c5a23ba96599b05868c71a997eb913b9fccbc5fb2515 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -479,6 +479,9 @@ names that plugin registers. Active Memory lists those tools in the recall
|
||||
prompt and passes the same list to the embedded sub-agent. If none of the
|
||||
configured tools are available, or the memory sub-agent fails, Active Memory
|
||||
skips recall for that turn and the main reply continues without memory context.
|
||||
For custom recall tools, non-empty model-visible tool output counts as recall
|
||||
evidence unless structured result fields explicitly report an empty result or
|
||||
failure.
|
||||
`toolsAllow` only accepts concrete memory tool names. Wildcards, `group:*`
|
||||
entries, and core agent tools such as `read`, `exec`, `message`, and
|
||||
`web_search` are ignored before the hidden memory sub-agent starts.
|
||||
@@ -743,7 +746,11 @@ Before v2026.5.2 the plugin silently extended your configured `timeoutMs` by an
|
||||
extra 30000 ms during cold-start so model warm-up, embedding-index load, and
|
||||
the first recall could share one larger budget. v2026.5.2 moved that grace
|
||||
behind an explicit `setupGraceTimeoutMs` config — your configured `timeoutMs`
|
||||
is now the budget by default, unless you opt in.
|
||||
is now the recall-work budget by default, unless you opt in. The blocking hook
|
||||
uses two bounded phases around that budget: up to 1500 ms for session/config
|
||||
preflight before recall starts, then a separate fixed 1500 ms for abort
|
||||
settlement and transcript recovery after recall work stops. Neither allowance
|
||||
extends model or tool execution.
|
||||
|
||||
If you upgraded from v2026.4.x and you set `timeoutMs` to a value tuned for the
|
||||
old implicit-grace world (the recommended starter `timeoutMs: 15000` is one
|
||||
@@ -765,14 +772,16 @@ outer watchdog budgets back to the pre-v5.2 effective values:
|
||||
}
|
||||
```
|
||||
|
||||
Per the v2026.5.2 changelog: _"use the configured recall timeout as the
|
||||
blocking prompt-build hook budget by default and move cold-start setup grace
|
||||
behind explicit `setupGraceTimeoutMs` config, so the plugin no longer silently
|
||||
extends 15000 ms configs to 45000 ms on the main lane."_
|
||||
The v2026.5.2 change removed the old implicit 30000 ms cold-start extension.
|
||||
Beyond the configured recall-work budget, the hook can use up to 1500 ms for
|
||||
preflight and another 1500 ms for post-recall completion. Its worst-case
|
||||
blocking time is therefore `timeoutMs + setupGraceTimeoutMs + 3000` ms.
|
||||
|
||||
The embedded recall runner uses the same effective timeout budget, so
|
||||
`setupGraceTimeoutMs` covers both the outer prompt-build watchdog and the inner
|
||||
blocking recall run.
|
||||
blocking recall run. The preflight cap covers session/config checks before that
|
||||
budget begins. The post-recall allowance lets the outer hook settle abort
|
||||
cleanup and read any final transcript state.
|
||||
|
||||
For resource-tight gateways where cold-start latency is a known trade-off,
|
||||
lower values (5000–15000 ms) work too — the trade-off is a higher chance of
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+816
-217
File diff suppressed because it is too large
Load Diff
@@ -123,11 +123,12 @@
|
||||
"help": "Optional explicit denylist of chat/user IDs. Sessions whose resolved conversation id matches the list are skipped even when the chat type is allowed. Applied after allowedChatIds."
|
||||
},
|
||||
"timeoutMs": {
|
||||
"label": "Timeout (ms)"
|
||||
"label": "Timeout (ms)",
|
||||
"help": "Recall work budget on the main lane. Before recall, the hook allows up to 1500 ms for session/config preflight. After recall starts, it reserves another fixed 1500 ms only for abort settlement and transcript recovery."
|
||||
},
|
||||
"setupGraceTimeoutMs": {
|
||||
"label": "Setup Grace Timeout (ms)",
|
||||
"help": "Advanced: extra blocking budget for cold embedded-run setup before the recall timeout is considered exhausted. Defaults to 0 so timeoutMs remains the main-lane hook budget unless you opt in."
|
||||
"help": "Advanced: extra recall-work budget for cold embedded-run setup. Defaults to 0. The separate 1500 ms preflight cap and 1500 ms post-recall completion allowance still apply."
|
||||
},
|
||||
"queryMode": {
|
||||
"label": "Query Mode",
|
||||
|
||||
@@ -183,6 +183,7 @@ describe("dynamic tool execution helpers", () => {
|
||||
vi.useFakeTimers();
|
||||
let capturedSignal: AbortSignal | undefined;
|
||||
const onTimeout = vi.fn();
|
||||
const onAgentToolResult = vi.fn();
|
||||
const response = handleDynamicToolCallWithTimeout({
|
||||
call: {
|
||||
threadId: "thread-1",
|
||||
@@ -200,6 +201,7 @@ describe("dynamic tool execution helpers", () => {
|
||||
},
|
||||
signal: new AbortController().signal,
|
||||
timeoutMs: 1,
|
||||
onAgentToolResult,
|
||||
onTimeout,
|
||||
});
|
||||
|
||||
@@ -216,6 +218,64 @@ describe("dynamic tool execution helpers", () => {
|
||||
});
|
||||
expect(capturedSignal?.aborted).toBe(true);
|
||||
expect(onTimeout).toHaveBeenCalledTimes(1);
|
||||
expect(onAgentToolResult).toHaveBeenCalledWith({
|
||||
toolName: "message",
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "OpenClaw dynamic tool call timed out after 1ms while running tool message.",
|
||||
},
|
||||
],
|
||||
details: {
|
||||
status: "failed",
|
||||
error: "OpenClaw dynamic tool call timed out after 1ms while running tool message.",
|
||||
},
|
||||
},
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports pre-execution aborts to the private result observer", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error("run cancelled"));
|
||||
const onAgentToolResult = vi.fn();
|
||||
const handleToolCall = vi.fn();
|
||||
|
||||
const result = await handleDynamicToolCallWithTimeout({
|
||||
call: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-aborted",
|
||||
namespace: null,
|
||||
tool: "memory_search",
|
||||
arguments: {},
|
||||
},
|
||||
toolBridge: { handleToolCall },
|
||||
signal: controller.signal,
|
||||
timeoutMs: 1_000,
|
||||
onAgentToolResult,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
contentItems: [
|
||||
{ type: "inputText", text: "OpenClaw dynamic tool call aborted before execution." },
|
||||
],
|
||||
});
|
||||
expect(handleToolCall).not.toHaveBeenCalled();
|
||||
expect(onAgentToolResult).toHaveBeenCalledOnce();
|
||||
expect(onAgentToolResult).toHaveBeenCalledWith({
|
||||
toolName: "memory_search",
|
||||
result: {
|
||||
content: [{ type: "text", text: "OpenClaw dynamic tool call aborted before execution." }],
|
||||
details: {
|
||||
status: "failed",
|
||||
error: "OpenClaw dynamic tool call aborted before execution.",
|
||||
},
|
||||
},
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("logs process poll timeout context separately from session idle", async () => {
|
||||
|
||||
@@ -126,10 +126,41 @@ export async function handleDynamicToolCallWithTimeout(params: {
|
||||
toolBridge: Pick<CodexDynamicToolBridge, "handleToolCall">;
|
||||
signal: AbortSignal;
|
||||
timeoutMs: number;
|
||||
onAgentToolResult?: EmbeddedRunAttemptParams["onAgentToolResult"];
|
||||
onTimeout?: () => void;
|
||||
}): Promise<CodexDynamicToolCallResponse> {
|
||||
// Timeout or run abort can win while a tool ignores cancellation. Keep the
|
||||
// private observer terminal result exactly once across those competing paths.
|
||||
let didNotifyAgentToolResult = false;
|
||||
const notifyAgentToolResult = (
|
||||
event: Parameters<NonNullable<EmbeddedRunAttemptParams["onAgentToolResult"]>>[0],
|
||||
) => {
|
||||
if (didNotifyAgentToolResult) {
|
||||
return;
|
||||
}
|
||||
didNotifyAgentToolResult = true;
|
||||
try {
|
||||
params.onAgentToolResult?.(event);
|
||||
} catch (error) {
|
||||
embeddedAgentLog.warn(
|
||||
`onAgentToolResult handler failed: tool=${params.call.tool} error=${String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
const notifyFailedToolResult = (message: string) => {
|
||||
notifyAgentToolResult({
|
||||
toolName: params.call.tool,
|
||||
result: {
|
||||
content: [{ type: "text", text: message }],
|
||||
details: { status: "failed", error: message },
|
||||
},
|
||||
isError: true,
|
||||
});
|
||||
};
|
||||
if (params.signal.aborted) {
|
||||
return failedDynamicToolResponse("OpenClaw dynamic tool call aborted before execution.");
|
||||
const message = "OpenClaw dynamic tool call aborted before execution.";
|
||||
notifyFailedToolResult(message);
|
||||
return failedDynamicToolResponse(message);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
@@ -139,6 +170,7 @@ export async function handleDynamicToolCallWithTimeout(params: {
|
||||
const abortFromRun = () => {
|
||||
const message = "OpenClaw dynamic tool call aborted.";
|
||||
controller.abort(params.signal.reason ?? new Error(message));
|
||||
notifyFailedToolResult(message);
|
||||
resolveAbort?.(failedDynamicToolResponse(message, { sideEffectEvidence: true }));
|
||||
};
|
||||
const abortPromise = new Promise<CodexDynamicToolCallResponse>((resolve) => {
|
||||
@@ -155,6 +187,7 @@ export async function handleDynamicToolCallWithTimeout(params: {
|
||||
...timeoutDetails.meta,
|
||||
consoleMessage: timeoutDetails.consoleMessage,
|
||||
});
|
||||
notifyFailedToolResult(timeoutDetails.responseMessage);
|
||||
resolve(
|
||||
failedDynamicToolResponse(timeoutDetails.responseMessage, { sideEffectEvidence: true }),
|
||||
);
|
||||
@@ -167,13 +200,22 @@ export async function handleDynamicToolCallWithTimeout(params: {
|
||||
if (params.signal.aborted) {
|
||||
abortFromRun();
|
||||
}
|
||||
return await Promise.race([
|
||||
params.toolBridge.handleToolCall(params.call, { signal: controller.signal }),
|
||||
const response = await Promise.race([
|
||||
params.toolBridge.handleToolCall(params.call, {
|
||||
signal: controller.signal,
|
||||
onAgentToolResult: notifyAgentToolResult,
|
||||
}),
|
||||
abortPromise,
|
||||
timeoutPromise,
|
||||
]);
|
||||
if (!response.success && !didNotifyAgentToolResult) {
|
||||
notifyFailedToolResult(readDynamicToolResponseText(response));
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
return failedDynamicToolResponse(error instanceof Error ? error.message : String(error), {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
notifyFailedToolResult(message);
|
||||
return failedDynamicToolResponse(message, {
|
||||
sideEffectEvidence: true,
|
||||
});
|
||||
} finally {
|
||||
@@ -188,6 +230,16 @@ export async function handleDynamicToolCallWithTimeout(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function readDynamicToolResponseText(response: CodexDynamicToolCallResponse): string {
|
||||
const text = response.contentItems
|
||||
.flatMap((item) =>
|
||||
item.type === "inputText" && typeof item.text === "string" ? [item.text] : [],
|
||||
)
|
||||
.join("\n")
|
||||
.trim();
|
||||
return text || "OpenClaw dynamic tool call failed.";
|
||||
}
|
||||
|
||||
function failedDynamicToolResponse(
|
||||
message: string,
|
||||
options?: { sideEffectEvidence?: boolean },
|
||||
|
||||
@@ -222,6 +222,7 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
|
||||
it("can register a durable tool schema while denying execution for the current turn", async () => {
|
||||
const heartbeatExecute = vi.fn(async () => textToolResult("heartbeat recorded"));
|
||||
const onAgentToolResult = vi.fn();
|
||||
const bridge = createCodexDynamicToolBridge({
|
||||
tools: [createTool({ name: "message" })],
|
||||
registeredTools: [
|
||||
@@ -237,14 +238,17 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
HEARTBEAT_RESPONSE_TOOL_NAME,
|
||||
]);
|
||||
|
||||
const result = await bridge.handleToolCall({
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-1",
|
||||
namespace: null,
|
||||
tool: HEARTBEAT_RESPONSE_TOOL_NAME,
|
||||
arguments: {},
|
||||
});
|
||||
const result = await bridge.handleToolCall(
|
||||
{
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-1",
|
||||
namespace: null,
|
||||
tool: HEARTBEAT_RESPONSE_TOOL_NAME,
|
||||
arguments: {},
|
||||
},
|
||||
{ onAgentToolResult },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
@@ -256,6 +260,22 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
],
|
||||
});
|
||||
expect(heartbeatExecute).not.toHaveBeenCalled();
|
||||
expect(onAgentToolResult).toHaveBeenCalledWith({
|
||||
toolName: HEARTBEAT_RESPONSE_TOOL_NAME,
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `OpenClaw tool is not available for this turn: ${HEARTBEAT_RESPONSE_TOOL_NAME}`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
status: "failed",
|
||||
error: `OpenClaw tool is not available for this turn: ${HEARTBEAT_RESPONSE_TOOL_NAME}`,
|
||||
},
|
||||
},
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps available and registered schemas paired with their tools", () => {
|
||||
@@ -1027,6 +1047,152 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
expectContextFields(callArg(handler, 0, 1, "middleware context"), { runtime: "codex" });
|
||||
});
|
||||
|
||||
it("keeps unrecognized non-success statuses fail-closed", async () => {
|
||||
const onAgentToolResult = vi.fn();
|
||||
const bridge = createCodexDynamicToolBridge({
|
||||
tools: [
|
||||
createTool({
|
||||
name: "exec",
|
||||
execute: vi.fn(async () =>
|
||||
textToolResult("Approval is unavailable.", { status: "approval-unavailable" }),
|
||||
),
|
||||
}),
|
||||
],
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
const result = await bridge.handleToolCall(
|
||||
{
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-1",
|
||||
namespace: null,
|
||||
tool: "exec",
|
||||
arguments: { command: "pwd" },
|
||||
},
|
||||
{ onAgentToolResult },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ success: false });
|
||||
expect(onAgentToolResult).toHaveBeenCalledWith({
|
||||
toolName: "exec",
|
||||
result: textToolResult("Approval is unavailable.", { status: "approval-unavailable" }),
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicitly successful cancellation outcomes", async () => {
|
||||
const onAgentToolResult = vi.fn();
|
||||
const cancelledResult = textToolResult("Approval rejected.", {
|
||||
ok: true,
|
||||
status: "cancelled",
|
||||
});
|
||||
const bridge = createCodexDynamicToolBridge({
|
||||
tools: [
|
||||
createTool({
|
||||
name: "lobster",
|
||||
execute: vi.fn(async () => cancelledResult),
|
||||
}),
|
||||
],
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
const result = await bridge.handleToolCall(
|
||||
{
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-1",
|
||||
namespace: null,
|
||||
tool: "lobster",
|
||||
arguments: {},
|
||||
},
|
||||
{ onAgentToolResult },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ success: true });
|
||||
expect(onAgentToolResult).toHaveBeenCalledWith({
|
||||
toolName: "lobster",
|
||||
result: cancelledResult,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports sanitized dynamic tool results to the private result observer", async () => {
|
||||
const onAgentToolResult = vi.fn();
|
||||
const bridge = createCodexDynamicToolBridge({
|
||||
tools: [
|
||||
createTool({
|
||||
name: "memory_lookup_custom",
|
||||
execute: vi.fn(async () =>
|
||||
textToolResult("OPENROUTER_API_KEY=sk-or-v1-abcdef0123456789", {
|
||||
status: "failed",
|
||||
error: "backend unavailable",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
],
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
await bridge.handleToolCall(
|
||||
{
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-1",
|
||||
namespace: null,
|
||||
tool: "memory_lookup_custom",
|
||||
arguments: {},
|
||||
},
|
||||
{ onAgentToolResult },
|
||||
);
|
||||
|
||||
expect(onAgentToolResult).toHaveBeenCalledOnce();
|
||||
expect(onAgentToolResult).toHaveBeenCalledWith({
|
||||
toolName: "memory_lookup_custom",
|
||||
result: {
|
||||
content: [{ type: "text", text: "OPENROUTER_API_KEY=sk-or-…6789" }],
|
||||
details: { status: "failed", error: "backend unavailable" },
|
||||
},
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports thrown dynamic tool failures to the private result observer", async () => {
|
||||
const onAgentToolResult = vi.fn();
|
||||
const bridge = createCodexDynamicToolBridge({
|
||||
tools: [
|
||||
createTool({
|
||||
name: "memory_lookup_custom",
|
||||
execute: vi.fn(async () => {
|
||||
throw new Error("backend unavailable");
|
||||
}),
|
||||
}),
|
||||
],
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
await bridge.handleToolCall(
|
||||
{
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-1",
|
||||
namespace: null,
|
||||
tool: "memory_lookup_custom",
|
||||
arguments: {},
|
||||
},
|
||||
{ onAgentToolResult },
|
||||
);
|
||||
|
||||
expect(onAgentToolResult).toHaveBeenCalledWith({
|
||||
toolName: "memory_lookup_custom",
|
||||
result: {
|
||||
content: [{ type: "text", text: "backend unavailable" }],
|
||||
details: { status: "failed", error: "backend unavailable" },
|
||||
},
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves terminal async tool results without marking them as errors", async () => {
|
||||
const bridge = createBridgeWithToolResult("image_generate", {
|
||||
content: [{ type: "text", text: "Background task started." }],
|
||||
|
||||
@@ -12,11 +12,13 @@ import {
|
||||
embeddedAgentLog,
|
||||
type EmbeddedRunAttemptParams,
|
||||
isToolWrappedWithBeforeToolCallHook,
|
||||
isToolResultError,
|
||||
isMessagingTool,
|
||||
isMessagingToolSendAction,
|
||||
normalizeHeartbeatToolResponse,
|
||||
projectRuntimeToolInputSchema,
|
||||
runAgentHarnessAfterToolCallHook,
|
||||
sanitizeToolResult,
|
||||
setBeforeToolCallDiagnosticsEnabled,
|
||||
type AnyAgentTool,
|
||||
type HeartbeatToolResponse,
|
||||
@@ -71,7 +73,10 @@ export type CodexDynamicToolBridge = {
|
||||
specs: CodexDynamicToolSpec[];
|
||||
handleToolCall: (
|
||||
params: CodexDynamicToolCallParams,
|
||||
options?: { signal?: AbortSignal },
|
||||
options?: {
|
||||
signal?: AbortSignal;
|
||||
onAgentToolResult?: EmbeddedRunAttemptParams["onAgentToolResult"];
|
||||
},
|
||||
) => Promise<CodexDynamicToolCallResponse>;
|
||||
telemetry: {
|
||||
didSendViaMessagingTool: boolean;
|
||||
@@ -155,7 +160,6 @@ export function createCodexDynamicToolBridge(params: {
|
||||
...ALWAYS_DIRECT_DYNAMIC_TOOL_NAMES,
|
||||
...(params.directToolNames ?? []),
|
||||
]);
|
||||
|
||||
return {
|
||||
availableSpecs: availableTools.map((entry) =>
|
||||
createCodexDynamicToolSpec({
|
||||
@@ -175,19 +179,28 @@ export function createCodexDynamicToolBridge(params: {
|
||||
handleToolCall: async (call, options) => {
|
||||
const toolEntry = toolMap.get(call.tool);
|
||||
if (!toolEntry) {
|
||||
const message = registeredToolNames.has(call.tool)
|
||||
? `OpenClaw tool is not available for this turn: ${call.tool}`
|
||||
: `Unknown OpenClaw tool: ${call.tool}`;
|
||||
notifyAgentToolResult(
|
||||
options?.onAgentToolResult,
|
||||
call.tool,
|
||||
failedToolResult(message),
|
||||
true,
|
||||
);
|
||||
if (registeredToolNames.has(call.tool)) {
|
||||
return {
|
||||
contentItems: [
|
||||
{
|
||||
type: "inputText",
|
||||
text: `OpenClaw tool is not available for this turn: ${call.tool}`,
|
||||
text: message,
|
||||
},
|
||||
],
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
contentItems: [{ type: "inputText", text: `Unknown OpenClaw tool: ${call.tool}` }],
|
||||
contentItems: [{ type: "inputText", text: message }],
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
@@ -202,7 +215,7 @@ export function createCodexDynamicToolBridge(params: {
|
||||
const preparedArgs = tool.prepareArguments ? tool.prepareArguments(args) : args;
|
||||
didStartExecution = true;
|
||||
const rawResult = await tool.execute(call.callId, preparedArgs, signal);
|
||||
const rawIsError = isToolResultError(rawResult);
|
||||
const rawIsError = isCodexToolResultError(rawResult);
|
||||
const middlewareResult = await middlewareRunner.applyToolResultMiddleware({
|
||||
threadId: call.threadId,
|
||||
turnId: call.turnId,
|
||||
@@ -220,7 +233,8 @@ export function createCodexDynamicToolBridge(params: {
|
||||
args,
|
||||
result: middlewareResult,
|
||||
});
|
||||
const resultIsError = rawIsError || isToolResultError(result);
|
||||
const resultIsError = rawIsError || isCodexToolResultError(result);
|
||||
notifyAgentToolResult(options?.onAgentToolResult, toolName, result, resultIsError);
|
||||
collectToolTelemetry({
|
||||
toolName,
|
||||
args,
|
||||
@@ -262,6 +276,13 @@ export function createCodexDynamicToolBridge(params: {
|
||||
);
|
||||
return withSideEffectEvidence(response, terminalType !== "blocked");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
notifyAgentToolResult(
|
||||
options?.onAgentToolResult,
|
||||
toolName,
|
||||
failedToolResult(errorMessage),
|
||||
true,
|
||||
);
|
||||
collectToolTelemetry({
|
||||
toolName,
|
||||
args,
|
||||
@@ -278,7 +299,7 @@ export function createCodexDynamicToolBridge(params: {
|
||||
sessionKey: toolResultHookContext.sessionKey,
|
||||
channelId: toolResultHookContext.channelId,
|
||||
startArgs: args,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: errorMessage,
|
||||
startedAt,
|
||||
});
|
||||
return withSideEffectEvidence(
|
||||
@@ -287,7 +308,7 @@ export function createCodexDynamicToolBridge(params: {
|
||||
contentItems: [
|
||||
{
|
||||
type: "inputText",
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
text: errorMessage,
|
||||
},
|
||||
],
|
||||
success: false,
|
||||
@@ -301,6 +322,32 @@ export function createCodexDynamicToolBridge(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function notifyAgentToolResult(
|
||||
observer: EmbeddedRunAttemptParams["onAgentToolResult"] | undefined,
|
||||
toolName: string,
|
||||
result: unknown,
|
||||
isError: boolean,
|
||||
) {
|
||||
try {
|
||||
observer?.({
|
||||
toolName,
|
||||
result: sanitizeToolResult(result),
|
||||
isError,
|
||||
});
|
||||
} catch (error) {
|
||||
embeddedAgentLog.warn(
|
||||
`onAgentToolResult handler failed: tool=${toolName} error=${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function failedToolResult(message: string): AgentToolResult<unknown> {
|
||||
return {
|
||||
content: [{ type: "text", text: message }],
|
||||
details: { status: "failed", error: message },
|
||||
};
|
||||
}
|
||||
|
||||
function wrapProjectedCodexDynamicTools(
|
||||
tools: readonly ProjectedCodexDynamicTool[],
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
@@ -688,11 +735,17 @@ function readPositiveInteger(value: unknown): number | undefined {
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function isToolResultError(result: AgentToolResult<unknown>): boolean {
|
||||
function isCodexToolResultError(result: AgentToolResult<unknown>): boolean {
|
||||
if (isToolResultError(result)) {
|
||||
return true;
|
||||
}
|
||||
const details = result.details;
|
||||
if (!isRecord(details)) {
|
||||
return false;
|
||||
}
|
||||
if (details.ok === true || details.success === true) {
|
||||
return false;
|
||||
}
|
||||
if (details.timedOut === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1846,6 +1846,7 @@ export async function runCodexAppServerAttempt(
|
||||
toolBridge,
|
||||
signal: runAbortController.signal,
|
||||
timeoutMs: dynamicToolTimeoutMs,
|
||||
onAgentToolResult: params.onAgentToolResult,
|
||||
onTimeout: () => {
|
||||
trajectoryRecorder?.recordEvent("tool.timeout", {
|
||||
threadId: call.threadId,
|
||||
|
||||
@@ -1213,14 +1213,70 @@ describe("convertOpenClawToolToSdkTool", () => {
|
||||
});
|
||||
|
||||
it("converts single text content to an exact textResultForLlm", async () => {
|
||||
const sdkTool = convertOpenClawToolToSdkTool(
|
||||
makeTool({}, { content: [{ text: "hello", type: "text" }], details: null }),
|
||||
{},
|
||||
);
|
||||
const onAgentToolResult = vi.fn();
|
||||
const sourceResult = {
|
||||
content: [{ text: "hello", type: "text" }],
|
||||
details: { results: [{ text: "hello" }] },
|
||||
};
|
||||
const sdkTool = convertOpenClawToolToSdkTool(makeTool({}, sourceResult), { onAgentToolResult });
|
||||
|
||||
const result = await runSdkTool(sdkTool, {});
|
||||
|
||||
expect(result).toEqual({ resultType: "success", textResultForLlm: "hello" });
|
||||
expect(onAgentToolResult).toHaveBeenCalledWith({
|
||||
toolName: "tool-a",
|
||||
result: sourceResult,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports thrown tool failures to the private result observer", async () => {
|
||||
const error = new Error("backend unavailable");
|
||||
const onAgentToolResult = vi.fn();
|
||||
const sdkTool = convertOpenClawToolToSdkTool(
|
||||
makeTool({
|
||||
execute: vi.fn(async () => {
|
||||
throw error;
|
||||
}),
|
||||
}),
|
||||
{ onAgentToolResult },
|
||||
);
|
||||
|
||||
await runSdkTool(sdkTool, {});
|
||||
|
||||
expect(onAgentToolResult).toHaveBeenCalledWith({
|
||||
toolName: "tool-a",
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[copilot-tool-bridge] tool 'tool-a' failed: backend unavailable",
|
||||
},
|
||||
],
|
||||
details: { status: "failed", error: "backend unavailable" },
|
||||
},
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports returned OpenClaw error results as observer failures", async () => {
|
||||
const onAgentToolResult = vi.fn();
|
||||
const sourceResult = {
|
||||
content: [{ text: '{"status":"error","error":"backend unavailable"}', type: "text" }],
|
||||
details: { status: "error", error: "backend unavailable" },
|
||||
};
|
||||
const sdkTool = convertOpenClawToolToSdkTool(makeTool({}, sourceResult), {
|
||||
onAgentToolResult,
|
||||
});
|
||||
|
||||
const result = await runSdkTool(sdkTool, {});
|
||||
|
||||
expect(result).toMatchObject({ resultType: "success" });
|
||||
expect(onAgentToolResult).toHaveBeenCalledWith({
|
||||
toolName: "tool-a",
|
||||
result: sourceResult,
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("joins multiple text blocks with newlines", async () => {
|
||||
@@ -1276,16 +1332,12 @@ describe("convertOpenClawToolToSdkTool", () => {
|
||||
});
|
||||
|
||||
it("returns a failure result for unsupported content shapes", async () => {
|
||||
const sdkTool = convertOpenClawToolToSdkTool(
|
||||
makeTool(
|
||||
{},
|
||||
{
|
||||
content: [{ type: "resource" }],
|
||||
details: null,
|
||||
},
|
||||
),
|
||||
{},
|
||||
);
|
||||
const onAgentToolResult = vi.fn();
|
||||
const sourceResult = {
|
||||
content: [{ type: "resource" }],
|
||||
details: null,
|
||||
};
|
||||
const sdkTool = convertOpenClawToolToSdkTool(makeTool({}, sourceResult), { onAgentToolResult });
|
||||
|
||||
const result = await runSdkTool(sdkTool, {});
|
||||
|
||||
@@ -1296,6 +1348,11 @@ describe("convertOpenClawToolToSdkTool", () => {
|
||||
expect(getError(result as ToolResultObject)).toBe(
|
||||
"[copilot-tool-bridge] unsupported AgentToolResult content shape: resource",
|
||||
);
|
||||
expect(onAgentToolResult).toHaveBeenCalledWith({
|
||||
toolName: "tool-a",
|
||||
result: sourceResult,
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a failure result when execute throws and preserves the error", async () => {
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
buildEmbeddedAttemptToolRunContext,
|
||||
getPluginToolMeta,
|
||||
isSubagentSessionKey,
|
||||
isToolResultError,
|
||||
resolveAttemptSpawnWorkspaceDir,
|
||||
resolveEmbeddedAttemptToolConstructionPlan,
|
||||
resolveModelAuthMode,
|
||||
sanitizeToolResult,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
|
||||
type CreateOpenClawCodingTools =
|
||||
@@ -205,6 +207,7 @@ export async function createCopilotToolBridge(
|
||||
convertOpenClawToolToSdkTool(sourceTool, {
|
||||
abortSignal: input.abortSignal,
|
||||
beforeExecute: input.beforeExecute,
|
||||
onAgentToolResult: input.attemptParams?.onAgentToolResult,
|
||||
}),
|
||||
),
|
||||
sourceTools: filteredTools,
|
||||
@@ -384,6 +387,7 @@ export function convertOpenClawToolToSdkTool(
|
||||
ctx: {
|
||||
abortSignal?: AbortSignal;
|
||||
beforeExecute?: CopilotToolBridgeInput["beforeExecute"];
|
||||
onAgentToolResult?: CopilotToolAttemptParams["onAgentToolResult"];
|
||||
},
|
||||
): SdkTool {
|
||||
if (typeof sourceTool.name !== "string" || sourceTool.name.trim().length === 0) {
|
||||
@@ -397,13 +401,30 @@ export function convertOpenClawToolToSdkTool(
|
||||
}
|
||||
|
||||
let sequentialLock = Promise.resolve();
|
||||
const notifyToolResult = (result: unknown, isError: boolean) => {
|
||||
try {
|
||||
ctx.onAgentToolResult?.({ toolName: sourceTool.name, result, isError });
|
||||
} catch (error) {
|
||||
console.warn("[copilot-tool-bridge] onAgentToolResult handler threw; continuing", error);
|
||||
}
|
||||
};
|
||||
const failureResult = (message: string, error: unknown): ToolResultObject => {
|
||||
notifyToolResult(
|
||||
sanitizeToolResult({
|
||||
content: [{ type: "text", text: message }],
|
||||
details: { status: "failed", error: toError(error).message },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
return createFailureResult(message, error);
|
||||
};
|
||||
const executeOnce = async (
|
||||
args: unknown,
|
||||
invocation: ToolInvocation,
|
||||
): Promise<ToolResultObject> => {
|
||||
if (ctx.abortSignal?.aborted) {
|
||||
const error = new Error("[copilot-tool-bridge] aborted before execution");
|
||||
return createFailureResult(error.message, error);
|
||||
return failureResult(error.message, error);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -415,7 +436,7 @@ export function convertOpenClawToolToSdkTool(
|
||||
toolName: sourceTool.name,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
return createFailureResult(
|
||||
return failureResult(
|
||||
`[copilot-tool-bridge] beforeExecute failed for tool '${sourceTool.name}': ${toError(error).message}`,
|
||||
error,
|
||||
);
|
||||
@@ -425,7 +446,7 @@ export function convertOpenClawToolToSdkTool(
|
||||
try {
|
||||
preparedArgs = sourceTool.prepareArguments ? sourceTool.prepareArguments(args) : args;
|
||||
} catch (error: unknown) {
|
||||
return createFailureResult(
|
||||
return failureResult(
|
||||
`[copilot-tool-bridge] prepareArguments failed for tool '${sourceTool.name}': ${toError(error).message}`,
|
||||
error,
|
||||
);
|
||||
@@ -440,13 +461,19 @@ export function convertOpenClawToolToSdkTool(
|
||||
undefined,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
return createFailureResult(
|
||||
return failureResult(
|
||||
`[copilot-tool-bridge] tool '${sourceTool.name}' failed: ${toError(error).message}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
return agentToolResultToSdk(result);
|
||||
const sdkResult = agentToolResultToSdk(result);
|
||||
const sanitizedResult = sanitizeToolResult(result);
|
||||
notifyToolResult(
|
||||
sanitizedResult,
|
||||
sdkResult.resultType === "failure" || isToolResultError(sanitizedResult),
|
||||
);
|
||||
return sdkResult;
|
||||
};
|
||||
|
||||
const handler =
|
||||
|
||||
@@ -61,6 +61,7 @@ type RuntimePlanOverrides = Partial<Omit<AgentRuntimePlan, "auth" | "resolvedRef
|
||||
function makeForwardingCase(internalEvents: AgentInternalEvent[]) {
|
||||
// Forwarding cases prove request-scoped flags survive the overflow-compaction
|
||||
// route into the eventual embedded attempt.
|
||||
const onAgentToolResult = vi.fn();
|
||||
return {
|
||||
runId: "forward-attempt-params",
|
||||
params: {
|
||||
@@ -72,6 +73,7 @@ function makeForwardingCase(internalEvents: AgentInternalEvent[]) {
|
||||
requireExplicitMessageTarget: true,
|
||||
chatType: "channel",
|
||||
internalEvents,
|
||||
onAgentToolResult,
|
||||
},
|
||||
expected: {
|
||||
toolsAllow: ["exec", "read"],
|
||||
@@ -81,6 +83,7 @@ function makeForwardingCase(internalEvents: AgentInternalEvent[]) {
|
||||
forceMessageTool: true,
|
||||
requireExplicitMessageTarget: true,
|
||||
chatType: "channel",
|
||||
onAgentToolResult,
|
||||
},
|
||||
} satisfies {
|
||||
runId: string;
|
||||
|
||||
@@ -137,7 +137,10 @@ import {
|
||||
type PostCompactionGuardObservation,
|
||||
} from "./post-compaction-loop-guard.js";
|
||||
import { createEmbeddedRunReplayState, observeReplayMetadata } from "./replay-state.js";
|
||||
import { handleAssistantFailover, isShortWindowRateLimitMessage } from "./run/assistant-failover.js";
|
||||
import {
|
||||
handleAssistantFailover,
|
||||
isShortWindowRateLimitMessage,
|
||||
} from "./run/assistant-failover.js";
|
||||
import {
|
||||
createEmbeddedRunStageTracker,
|
||||
EMBEDDED_RUN_ATTEMPT_DISPATCH_STAGE,
|
||||
@@ -1792,6 +1795,7 @@ async function runEmbeddedAgentInternal(
|
||||
onReasoningStream: params.onReasoningStream,
|
||||
onReasoningEnd: params.onReasoningEnd,
|
||||
onToolResult: params.onToolResult,
|
||||
onAgentToolResult: params.onAgentToolResult,
|
||||
onAgentEvent: params.onAgentEvent,
|
||||
onExecutionPhase: params.onExecutionPhase,
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
@@ -2697,8 +2701,7 @@ async function runEmbeddedAgentInternal(
|
||||
{
|
||||
providerStarted: promptErrorSource === "prompt",
|
||||
transientRateLimit:
|
||||
promptFailoverReason === "rate_limit" &&
|
||||
isShortWindowRateLimitMessage(errorText),
|
||||
promptFailoverReason === "rate_limit" && isShortWindowRateLimitMessage(errorText),
|
||||
},
|
||||
);
|
||||
const promptFailoverFailure =
|
||||
|
||||
@@ -3417,6 +3417,7 @@ export async function runEmbeddedAttempt(
|
||||
shouldEmitToolOutput: params.shouldEmitToolOutput,
|
||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||
hasDeliveredMessageToolOnlySourceReply: () => didDeliverSourceReplyViaMessageTool,
|
||||
onAgentToolResult: params.onAgentToolResult,
|
||||
onToolResult: params.onToolResult,
|
||||
onReasoningStream: params.onReasoningStream,
|
||||
onReasoningEnd: params.onReasoningEnd,
|
||||
|
||||
@@ -210,6 +210,8 @@ export type RunEmbeddedAgentParams = {
|
||||
}) => void | Promise<void>;
|
||||
onReasoningEnd?: () => void | Promise<void>;
|
||||
onToolResult?: (payload: ReplyPayload) => void | Promise<void>;
|
||||
/** Synchronous private observer for the sanitized per-tool result. */
|
||||
onAgentToolResult?: (event: { toolName: string; result: unknown; isError: boolean }) => void;
|
||||
onAgentEvent?: (evt: {
|
||||
stream: string;
|
||||
data: Record<string, unknown>;
|
||||
|
||||
@@ -384,6 +384,35 @@ describe("handleToolExecutionEnd cron.add commitment tracking", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleToolExecutionEnd private result observer", () => {
|
||||
it("reports the sanitized original tool result", async () => {
|
||||
const { ctx } = createTestContext();
|
||||
const onAgentToolResult = vi.fn();
|
||||
ctx.params.onAgentToolResult = onAgentToolResult;
|
||||
const result = {
|
||||
content: [{ type: "text", text: '{"results":[{"text":"ramen"}]}' }],
|
||||
details: { results: [{ text: "ramen" }] },
|
||||
};
|
||||
|
||||
await handleToolExecutionEnd(
|
||||
ctx as never,
|
||||
{
|
||||
type: "tool_execution_end",
|
||||
toolName: "memory_search",
|
||||
toolCallId: "tool-memory-search",
|
||||
isError: false,
|
||||
result,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(onAgentToolResult).toHaveBeenCalledWith({
|
||||
toolName: "memory_search",
|
||||
result,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleToolExecutionEnd sessions_spawn terminal success tracking", () => {
|
||||
it("records accepted sessions_spawn identifiers", async () => {
|
||||
const { ctx } = createTestContext();
|
||||
@@ -931,7 +960,9 @@ describe("handleToolExecutionEnd exec approval prompts", () => {
|
||||
it("emits a deterministic unavailable payload when the initiating surface cannot approve", async () => {
|
||||
const { ctx } = createTestContext();
|
||||
const onToolResult = vi.fn();
|
||||
const onAgentToolResult = vi.fn();
|
||||
ctx.params.onToolResult = onToolResult;
|
||||
ctx.params.onAgentToolResult = onAgentToolResult;
|
||||
|
||||
await handleToolExecutionEnd(
|
||||
ctx as never,
|
||||
@@ -961,6 +992,13 @@ describe("handleToolExecutionEnd exec approval prompts", () => {
|
||||
expect(text).not.toContain("Pending command:");
|
||||
expect(text).not.toContain("Host:");
|
||||
expect(text).not.toContain("CWD:");
|
||||
expect(onAgentToolResult).toHaveBeenCalledWith({
|
||||
toolName: "exec",
|
||||
result: expect.objectContaining({
|
||||
details: expect.objectContaining({ status: "approval-unavailable" }),
|
||||
}),
|
||||
isError: true,
|
||||
});
|
||||
expect(ctx.state.deterministicApprovalPromptSent).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -1166,8 +1166,21 @@ export async function handleToolExecutionEnd(
|
||||
const runId = ctx.params.runId;
|
||||
const isError = evt.isError;
|
||||
const result = evt.result;
|
||||
const isToolError = isError || isToolResultError(result);
|
||||
const observerIsError = isError || isToolResultError(result);
|
||||
const sanitizedResult = sanitizeToolResult(result);
|
||||
const approvalUnavailable =
|
||||
isExecToolName(toolName) &&
|
||||
readExecToolDetails(sanitizedResult)?.status === "approval-unavailable";
|
||||
const isToolError = observerIsError && !approvalUnavailable;
|
||||
try {
|
||||
ctx.params.onAgentToolResult?.({
|
||||
toolName,
|
||||
result: sanitizedResult,
|
||||
isError: observerIsError,
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.warn(`onAgentToolResult handler failed: tool=${toolName} error=${String(error)}`);
|
||||
}
|
||||
const eventResult = isExecToolName(toolName)
|
||||
? capLiveExecResult(sanitizedResult)
|
||||
: sanitizedResult;
|
||||
|
||||
@@ -271,6 +271,7 @@ type ToolHandlerParams = Pick<
|
||||
| "onAgentEvent"
|
||||
| "onExecutionPhase"
|
||||
| "onHeartbeatToolResponse"
|
||||
| "onAgentToolResult"
|
||||
| "onToolResult"
|
||||
| "sessionKey"
|
||||
| "sessionId"
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
buildToolLifecycleErrorResult,
|
||||
extractToolErrorCode,
|
||||
extractToolErrorMessage,
|
||||
isToolResultError,
|
||||
sanitizeToolArgs,
|
||||
sanitizeToolResult,
|
||||
} from "./embedded-agent-subscribe.tools.js";
|
||||
@@ -125,6 +126,23 @@ describe("extractToolErrorMessage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isToolResultError", () => {
|
||||
it("recognizes returned failures and nonzero exits", () => {
|
||||
expect(isToolResultError({ details: { status: "failed" } })).toBe(true);
|
||||
expect(isToolResultError({ details: { status: "blocked" } })).toBe(true);
|
||||
expect(isToolResultError({ details: { status: "approval-unavailable" } })).toBe(true);
|
||||
expect(isToolResultError({ details: { status: "completed", timedOut: true } })).toBe(true);
|
||||
expect(isToolResultError({ details: { status: "completed", exitCode: 1 } })).toBe(true);
|
||||
expect(isToolResultError({ details: { status: "completed", exitCode: 0 } })).toBe(false);
|
||||
expect(isToolResultError({ details: { ok: true, status: "cancelled" } })).toBe(false);
|
||||
expect(isToolResultError({ details: { success: true, status: "canceled" } })).toBe(false);
|
||||
expect(isToolResultError({ details: { ok: false, status: "completed" } })).toBe(true);
|
||||
expect(isToolResultError({ details: { ok: true, status: "cancelled", timedOut: true } })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function getTextContent(result: unknown, index = 0): string {
|
||||
// Sanitizer tests assert text redaction while keeping the result shape opaque.
|
||||
const record = result as { content: Array<{ text: string }> };
|
||||
|
||||
@@ -541,11 +541,37 @@ export function extractToolResultMediaPaths(result: unknown): string[] {
|
||||
}
|
||||
|
||||
export function isToolResultError(result: unknown): boolean {
|
||||
const details = readToolResultDetails(result);
|
||||
const normalized = readToolResultStatus(result);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
const explicitlySuccessful = details?.ok === true || details?.success === true;
|
||||
if (details?.ok === false || details?.success === false) {
|
||||
return true;
|
||||
}
|
||||
return normalized === "error" || normalized === "timeout";
|
||||
const hasFailureStatus =
|
||||
normalized === "error" ||
|
||||
normalized === "failed" ||
|
||||
normalized === "failure" ||
|
||||
normalized === "timeout" ||
|
||||
normalized === "timed_out" ||
|
||||
normalized === "blocked" ||
|
||||
normalized === "denied" ||
|
||||
normalized === "forbidden" ||
|
||||
normalized === "unavailable" ||
|
||||
normalized === "approval-unavailable" ||
|
||||
normalized === "disabled" ||
|
||||
normalized === "aborted" ||
|
||||
normalized === "cancelled" ||
|
||||
normalized === "canceled" ||
|
||||
normalized === "killed" ||
|
||||
normalized === "invalid";
|
||||
if (hasFailureStatus && !explicitlySuccessful) {
|
||||
return true;
|
||||
}
|
||||
if (details?.timedOut === true || Boolean(details?.error)) {
|
||||
return true;
|
||||
}
|
||||
const exitCode = details?.exitCode;
|
||||
return typeof exitCode === "number" && Number.isFinite(exitCode) && exitCode !== 0;
|
||||
}
|
||||
|
||||
export function extractToolErrorCode(result: unknown): string | undefined {
|
||||
|
||||
@@ -46,6 +46,7 @@ export type SubscribeEmbeddedAgentSessionParams = {
|
||||
/** Attempt-owned delivery proof for message-tool-only source replies. */
|
||||
hasDeliveredMessageToolOnlySourceReply?: () => boolean;
|
||||
onToolResult?: (payload: ReplyPayload) => void | Promise<void>;
|
||||
onAgentToolResult?: (event: { toolName: string; result: unknown; isError: boolean }) => void;
|
||||
onReasoningStream?: (payload: {
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
|
||||
@@ -142,6 +142,38 @@ describe("tool_result_persist hook", () => {
|
||||
expect(toolResult.details.originalDetailsBytesAtLeast).toBeGreaterThan(8_192);
|
||||
});
|
||||
|
||||
it("preserves result state values when capping oversized details", () => {
|
||||
const sm = guardSessionManager(SessionManager.inMemory(), {
|
||||
agentId: "main",
|
||||
sessionKey: "main",
|
||||
});
|
||||
const appendMessage = sm.appendMessage.bind(sm) as unknown as (message: AgentMessage) => void;
|
||||
appendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_1", name: "lookup", arguments: {} }],
|
||||
} as AgentMessage);
|
||||
appendMessage({
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
isError: false,
|
||||
content: [{ type: "text", text: "visible output stays small" }],
|
||||
details: {
|
||||
success: true,
|
||||
disabled: false,
|
||||
unavailable: false,
|
||||
error: null,
|
||||
payload: "x".repeat(10_000),
|
||||
},
|
||||
} as any);
|
||||
|
||||
const details = requirePersistedToolResult(sm).details;
|
||||
expect(details.persistedDetailsTruncated).toBe(true);
|
||||
expect(details.success).toBe(true);
|
||||
expect(details.disabled).toBe(false);
|
||||
expect(details.unavailable).toBe(false);
|
||||
expect(details.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("redacts small toolResult details before persistence", () => {
|
||||
const tokenValue = "abcdefghijklmnopqrstuvwx1234567890";
|
||||
const bearerValue = "bearerdiagnosticvalue1234567890";
|
||||
@@ -607,6 +639,8 @@ describe("tool_result_persist hook", () => {
|
||||
details: {
|
||||
status: "completed".repeat(250),
|
||||
sessionId: "exec-oversized",
|
||||
success: false,
|
||||
error: "upstream unavailable",
|
||||
cwd: "/tmp/very-long-working-directory".repeat(250),
|
||||
name: "noisy process".repeat(250),
|
||||
fullOutputPath: "/tmp/output.log".repeat(250),
|
||||
@@ -631,6 +665,8 @@ describe("tool_result_persist hook", () => {
|
||||
expect(details.finalDetailsTruncated).toBe(true);
|
||||
expect(details.aggregated).toBeUndefined();
|
||||
expect(details.tail).toBeUndefined();
|
||||
expect(details.success).toBe(false);
|
||||
expect(details.error).toBe("upstream unavailable");
|
||||
expect(Buffer.byteLength(JSON.stringify(details), "utf-8")).toBeLessThan(8_192);
|
||||
});
|
||||
|
||||
|
||||
@@ -306,6 +306,24 @@ function sanitizePersistedSessionDetail(
|
||||
return out;
|
||||
}
|
||||
|
||||
function copyPersistedResultStateFields(
|
||||
out: Record<string, unknown>,
|
||||
src: Record<string, unknown>,
|
||||
maxStringChars: number,
|
||||
redactionConfig?: ToolResultDetailRedactionConfig,
|
||||
): void {
|
||||
for (const key of ["disabled", "unavailable", "success"] as const) {
|
||||
if (typeof src[key] === "boolean") {
|
||||
out[key] = src[key];
|
||||
}
|
||||
}
|
||||
if (typeof src.error === "string" && src.error) {
|
||||
out.error = redactPersistedDetailString(src.error, maxStringChars, redactionConfig);
|
||||
} else if (src.error) {
|
||||
out.error = true;
|
||||
}
|
||||
}
|
||||
|
||||
function buildPersistedDetailsFallback(
|
||||
src: Record<string, unknown> | undefined,
|
||||
originalSize: BoundedJsonUtf8Bytes,
|
||||
@@ -336,6 +354,12 @@ function buildPersistedDetailsFallback(
|
||||
);
|
||||
}
|
||||
}
|
||||
copyPersistedResultStateFields(
|
||||
fallback,
|
||||
src,
|
||||
MAX_PERSISTED_DETAIL_FALLBACK_STRING_CHARS,
|
||||
redactionConfig,
|
||||
);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -457,6 +481,7 @@ function sanitizeToolResultDetailsForPersistence(
|
||||
);
|
||||
}
|
||||
}
|
||||
copyPersistedResultStateFields(out, src, MAX_PERSISTED_DETAIL_STRING_CHARS, redactionConfig);
|
||||
if (typeof src.tail === "string") {
|
||||
out.tail = redactPersistedDetailString(
|
||||
src.tail,
|
||||
|
||||
@@ -131,6 +131,8 @@ export { isMessagingTool, isMessagingToolSendAction } from "../agents/embedded-a
|
||||
export {
|
||||
extractToolResultMediaArtifact,
|
||||
filterToolResultMediaUrls,
|
||||
isToolResultError,
|
||||
sanitizeToolResult,
|
||||
} from "../agents/embedded-agent-subscribe.tools.js";
|
||||
export { normalizeUsage } from "../agents/usage.js";
|
||||
export { resolveOpenClawAgentDir } from "./agent-dir-compat.js";
|
||||
|
||||
Reference in New Issue
Block a user