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:
brokemac79
2026-06-14 07:38:58 +01:00
committed by GitHub
parent a02813164d
commit d1299658ac
26 changed files with 2829 additions and 302 deletions
+2 -2
View File
@@ -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
+15 -6
View File
@@ -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 (500015000 ms) work too — the trade-off is a higher chance of
File diff suppressed because it is too large Load Diff
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,
+71 -14
View File
@@ -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 () => {
+32 -5
View File
@@ -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;
+6 -3
View File
@@ -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 }> };
+29 -3
View File
@@ -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);
});
+25
View File
@@ -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,
+2
View File
@@ -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";