Skip to content

Commit a2c65f0

Browse files
committed
fix(cron): keep recovered tool warnings diagnostic
1 parent 1d77170 commit a2c65f0

11 files changed

Lines changed: 225 additions & 13 deletions

src/agents/pi-embedded-runner/run/payloads.errors.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { AssistantMessage } from "@earendil-works/pi-ai";
22
import { describe, expect, it } from "vitest";
3+
import { getReplyPayloadMetadata } from "../../../auto-reply/reply-payload.js";
34
import { formatBillingErrorMessage } from "../../pi-embedded-helpers.js";
45
import { makeAssistantMessageFixture } from "../../test-helpers/assistant-message-fixtures.js";
56
import {
@@ -457,6 +458,9 @@ describe("buildEmbeddedRunPayloads", () => {
457458
expect(payloads[1]?.isError).toBe(true);
458459
expect(payloads[1]?.text).toContain("Write");
459460
expect(payloads[1]?.text).not.toContain("missing");
461+
expect(getReplyPayloadMetadata(payloads[1] as object)?.nonTerminalToolErrorWarning).toBe(
462+
undefined,
463+
);
460464
});
461465

462466
it("shows exec tool errors when assistant output claims success", () => {
@@ -474,6 +478,9 @@ describe("buildEmbeddedRunPayloads", () => {
474478
expect(payloads[1]?.isError).toBe(true);
475479
expect(payloads[1]?.text).toContain("Exec");
476480
expect(payloads[1]?.text).not.toContain("python: command not found");
481+
expect(getReplyPayloadMetadata(payloads[1] as object)?.nonTerminalToolErrorWarning).toBe(
482+
undefined,
483+
);
477484
});
478485

479486
it("shows mutating tool errors when assistant output does not acknowledge the failure", () => {

src/agents/pi-embedded-runner/run/payloads.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,28 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => {
351351
});
352352
});
353353

354+
it("marks middleware tool-error warnings after assistant output as non-terminal", () => {
355+
const payloads = buildPayloads({
356+
assistantTexts: ["Queued 3 topics."],
357+
lastToolError: {
358+
toolName: "exec",
359+
error: "Tool output unavailable due to post-processing error",
360+
middlewareError: true,
361+
},
362+
verboseLevel: "off",
363+
});
364+
365+
expect(payloads).toHaveLength(2);
366+
expect(payloads[0]?.text).toBe("Queued 3 topics.");
367+
expect(payloads[1]).toMatchObject({
368+
isError: true,
369+
});
370+
expect(payloads[1]?.text).toContain("Exec failed");
371+
expect(getReplyPayloadMetadata(payloads[1] as object)).toMatchObject({
372+
nonTerminalToolErrorWarning: true,
373+
});
374+
});
375+
354376
it("surfaces concise bash tool errors when verbose mode is off", () => {
355377
const payloads = buildPayloads({
356378
lastToolError: { toolName: "bash", error: "command failed" },

src/agents/pi-embedded-runner/run/payloads.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ function shouldIncludeToolErrorDetails(params: {
136136
);
137137
}
138138

139+
function shouldMarkNonTerminalToolErrorWarning(lastToolError: ToolErrorSummary): boolean {
140+
return lastToolError.middlewareError === true;
141+
}
142+
139143
function resolveToolErrorWarningPolicy(params: {
140144
lastToolError: ToolErrorSummary;
141145
hasUserFacingReply: boolean;
@@ -221,6 +225,7 @@ export function buildEmbeddedRunPayloads(params: {
221225
presentation?: ReplyPayload["presentation"];
222226
interactive?: ReplyPayload["interactive"];
223227
channelData?: Record<string, unknown>;
228+
nonTerminalToolErrorWarning?: boolean;
224229
sourceReplyMirror?: {
225230
idempotencyKey?: string;
226231
};
@@ -509,6 +514,9 @@ export function buildEmbeddedRunPayloads(params: {
509514
replyItems.push({
510515
text: warningText,
511516
isError: true,
517+
nonTerminalToolErrorWarning:
518+
hasUserFacingAssistantReply &&
519+
shouldMarkNonTerminalToolErrorWarning(params.lastToolError),
512520
});
513521
}
514522
}
@@ -530,6 +538,11 @@ export function buildEmbeddedRunPayloads(params: {
530538
if (item.isError !== undefined) {
531539
payload.isError = item.isError;
532540
}
541+
if (item.nonTerminalToolErrorWarning) {
542+
setReplyPayloadMetadata(payload, {
543+
nonTerminalToolErrorWarning: true,
544+
});
545+
}
533546
if (item.replyToId) {
534547
payload.replyToId = item.replyToId;
535548
}

src/agents/pi-embedded-subscribe.handlers.tools.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,47 @@ describe("handleToolExecutionEnd cron.add commitment tracking", () => {
281281
});
282282

283283
describe("handleToolExecutionEnd mutating failure recovery", () => {
284+
it("marks middleware failures on the last tool error", async () => {
285+
const { ctx } = createTestContext();
286+
287+
await handleToolExecutionStart(
288+
ctx as never,
289+
{
290+
type: "tool_execution_start",
291+
toolName: "exec",
292+
toolCallId: "tool-exec-middleware-error",
293+
args: { cmd: "echo ok" },
294+
} as never,
295+
);
296+
297+
await handleToolExecutionEnd(
298+
ctx as never,
299+
{
300+
type: "tool_execution_end",
301+
toolName: "exec",
302+
toolCallId: "tool-exec-middleware-error",
303+
isError: false,
304+
result: {
305+
content: [
306+
{
307+
type: "text",
308+
text: "Tool output unavailable due to post-processing error.",
309+
},
310+
],
311+
details: {
312+
status: "error",
313+
middlewareError: true,
314+
},
315+
},
316+
} as never,
317+
);
318+
319+
expect(ctx.state.lastToolError).toMatchObject({
320+
toolName: "exec",
321+
middlewareError: true,
322+
});
323+
});
324+
284325
it("clears edit failure when the retry succeeds through common file path aliases", async () => {
285326
const { ctx } = createTestContext();
286327

src/agents/pi-embedded-subscribe.handlers.tools.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,19 @@ const beforeToolCallModuleLoader = createLazyImportLoader<BeforeToolCallModule>(
6767
const LIVE_EXEC_OUTPUT_MAX_CHARS = 8000;
6868
const LIVE_EXEC_UPDATE_MIN_INTERVAL_MS = 250;
6969

70+
function isMiddlewareToolResultError(result: unknown): boolean {
71+
if (!result || typeof result !== "object") {
72+
return false;
73+
}
74+
const details = (result as { details?: unknown }).details;
75+
return Boolean(
76+
details &&
77+
typeof details === "object" &&
78+
!Array.isArray(details) &&
79+
(details as { middlewareError?: unknown }).middlewareError === true,
80+
);
81+
}
82+
7083
function loadExecApprovalReply(): Promise<ExecApprovalReplyModule> {
7184
return execApprovalReplyModuleLoader.load();
7285
}
@@ -939,6 +952,7 @@ export async function handleToolExecutionEnd(
939952
meta,
940953
error: errorMessage,
941954
timedOut: isToolResultTimedOut(sanitizedResult) || undefined,
955+
middlewareError: isMiddlewareToolResultError(sanitizedResult) || undefined,
942956
mutatingAction: callSummary?.mutatingAction,
943957
actionFingerprint: callSummary?.actionFingerprint,
944958
fileTarget: callSummary?.fileTarget,

src/agents/tool-error-summary.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export type ToolErrorSummary = {
66
meta?: string;
77
error?: string;
88
timedOut?: boolean;
9+
middlewareError?: boolean;
910
mutatingAction?: boolean;
1011
actionFingerprint?: string;
1112
fileTarget?: FileTarget;

src/auto-reply/reply-payload.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ export type ReplyPayloadMetadata = {
163163
idempotencyKey?: string;
164164
};
165165
beforeAgentRunBlocked?: boolean;
166+
/** Warning synthesized from an observed tool error after the run produced assistant output. */
167+
nonTerminalToolErrorWarning?: boolean;
166168
};
167169

168170
const replyPayloadMetadata = new WeakMap<object, ReplyPayloadMetadata>();

src/cron/isolated-agent.helpers.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it } from "vitest";
2+
import { setReplyPayloadMetadata } from "../auto-reply/reply-payload.js";
23
import { detectCronDenialToken, resolveCronPayloadOutcome } from "./isolated-agent/helpers.js";
34

45
describe("detectCronDenialToken", () => {
@@ -65,6 +66,51 @@ describe("resolveCronPayloadOutcome", () => {
6566
expect(result.summary).toBe("Write completed successfully.");
6667
});
6768

69+
it("keeps non-terminal tool warnings diagnostic when final assistant output succeeded", () => {
70+
const toolWarning = setReplyPayloadMetadata(
71+
{
72+
text: "⚠️ Exec failed",
73+
isError: true,
74+
},
75+
{ nonTerminalToolErrorWarning: true },
76+
);
77+
78+
const result = resolveCronPayloadOutcome({
79+
payloads: [{ text: "Queued 3 topics." }, toolWarning],
80+
finalAssistantVisibleText: "Queued 3 topics.",
81+
preferFinalAssistantVisibleText: true,
82+
});
83+
84+
expect(result.hasFatalErrorPayload).toBe(false);
85+
expect(result.embeddedRunError).toBeUndefined();
86+
expect(result.summary).toBe("Queued 3 topics.");
87+
expect(result.outputText).toBe("Queued 3 topics.");
88+
expect(result.deliveryPayloads).toEqual([{ text: "Queued 3 topics." }]);
89+
});
90+
91+
it("keeps marked middleware warnings diagnostic after structured cron output", () => {
92+
const mediaPayload = { mediaUrl: "file:///tmp/cron-report.png" };
93+
const toolWarning = setReplyPayloadMetadata(
94+
{
95+
text: "⚠️ Exec failed",
96+
isError: true,
97+
},
98+
{ nonTerminalToolErrorWarning: true },
99+
);
100+
101+
const result = resolveCronPayloadOutcome({
102+
payloads: [mediaPayload, toolWarning],
103+
});
104+
105+
expect(result.hasFatalErrorPayload).toBe(false);
106+
expect(result.embeddedRunError).toBeUndefined();
107+
expect(result.summary).toBeUndefined();
108+
expect(result.outputText).toBeUndefined();
109+
expect(result.synthesizedText).toBeUndefined();
110+
expect(result.deliveryPayloads).toEqual([mediaPayload]);
111+
expect(result.deliveryPayloadHasStructuredContent).toBe(true);
112+
});
113+
68114
it("treats trailing message delivery warnings as non-fatal when final assistant text exists", () => {
69115
const result = resolveCronPayloadOutcome({
70116
payloads: [{ text: "Draft output" }, { text: "⚠️ ✉️ Message failed", isError: true }],

src/cron/isolated-agent/helpers.ts

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload";
22
import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS } from "../../auto-reply/heartbeat.js";
3+
import { getReplyPayloadMetadata } from "../../auto-reply/reply-payload.js";
34
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
45
import { normalizeOptionalString } from "../../shared/string-coerce.js";
56
import { truncateUtf16Safe } from "../../utils.js";
@@ -151,6 +152,9 @@ export function pickSummaryFromPayloads(
151152
}
152153
}
153154
for (let i = payloads.length - 1; i >= 0; i--) {
155+
if (isNonTerminalToolErrorWarning(payloads[i])) {
156+
continue;
157+
}
154158
const summary = pickSummaryFromOutput(payloads[i]?.text);
155159
if (summary) {
156160
return summary;
@@ -172,6 +176,9 @@ export function pickLastNonEmptyTextFromPayloads(
172176
}
173177
}
174178
for (let i = payloads.length - 1; i >= 0; i--) {
179+
if (isNonTerminalToolErrorWarning(payloads[i])) {
180+
continue;
181+
}
175182
const clean = (payloads[i]?.text ?? "").trim();
176183
if (clean) {
177184
return clean;
@@ -249,14 +256,26 @@ function isCronMessagePresentationWarning(text: string | undefined): boolean {
249256
);
250257
}
251258

259+
function isNonTerminalToolErrorWarning(payload: object | undefined): boolean {
260+
return Boolean(payload && getReplyPayloadMetadata(payload)?.nonTerminalToolErrorWarning);
261+
}
262+
263+
function isSuccessfulCronPayload(payload: DeliveryPayload | undefined): boolean {
264+
return (
265+
payload?.isError !== true &&
266+
(isDeliverablePayload(payload) || payloadHasStructuredDeliveryContent(payload))
267+
);
268+
}
269+
252270
export function resolveCronPayloadOutcome(params: {
253271
payloads: DeliveryPayload[];
254272
runLevelError?: unknown;
255273
failureSignal?: CronFailureSignal | undefined;
256274
finalAssistantVisibleText?: string | undefined;
257275
preferFinalAssistantVisibleText?: boolean;
258276
}): CronPayloadOutcome {
259-
const firstText = params.payloads[0]?.text ?? "";
277+
const firstText =
278+
params.payloads.find((payload) => !isNonTerminalToolErrorWarning(payload))?.text ?? "";
260279
const fallbackSummary =
261280
pickSummaryFromPayloads(params.payloads) ?? pickSummaryFromOutput(firstText);
262281
const fallbackOutputText = pickLastNonEmptyTextFromPayloads(params.payloads);
@@ -277,23 +296,33 @@ export function resolveCronPayloadOutcome(params: {
277296
const hasSuccessfulPayloadAfterLastError =
278297
!params.runLevelError &&
279298
lastErrorPayloadIndex >= 0 &&
280-
params.payloads
281-
.slice(lastErrorPayloadIndex + 1)
282-
.some((payload) => payload?.isError !== true && Boolean(payload?.text?.trim()));
299+
params.payloads.slice(lastErrorPayloadIndex + 1).some(isSuccessfulCronPayload);
283300
const hasSuccessfulPayloadBeforeLastError =
284301
!params.runLevelError &&
285302
lastErrorPayloadIndex > 0 &&
286-
params.payloads
287-
.slice(0, lastErrorPayloadIndex)
288-
.some((payload) => payload?.isError !== true && Boolean(payload?.text?.trim()));
303+
params.payloads.slice(0, lastErrorPayloadIndex).some(isSuccessfulCronPayload);
304+
const lastErrorPayload =
305+
lastErrorPayloadIndex >= 0 ? params.payloads[lastErrorPayloadIndex] : undefined;
306+
const hasRecoveringTerminalOutput =
307+
normalizedFinalAssistantVisibleText !== undefined ||
308+
hasSuccessfulPayloadAfterLastError ||
309+
hasSuccessfulPayloadBeforeLastError;
310+
const hasNonTerminalToolErrorWarning =
311+
!params.runLevelError &&
312+
params.failureSignal?.fatalForCron !== true &&
313+
hasRecoveringTerminalOutput &&
314+
isNonTerminalToolErrorWarning(lastErrorPayload);
289315
const hasPendingPresentationWarning =
290316
!params.runLevelError &&
291317
params.failureSignal?.fatalForCron !== true &&
292318
lastErrorPayloadIndex >= 0 &&
293319
isCronMessagePresentationWarning(lastErrorPayloadText) &&
294320
(normalizedFinalAssistantVisibleText !== undefined || hasSuccessfulPayloadBeforeLastError);
295321
const hasFatalStructuredErrorPayload =
296-
hasErrorPayload && !hasSuccessfulPayloadAfterLastError && !hasPendingPresentationWarning;
322+
hasErrorPayload &&
323+
!hasSuccessfulPayloadAfterLastError &&
324+
!hasPendingPresentationWarning &&
325+
!hasNonTerminalToolErrorWarning;
297326
const hasStructuredDeliveryPayloads = selectedDeliveryPayloads.some((payload) =>
298327
payloadHasStructuredDeliveryContent(payload),
299328
);
@@ -324,10 +353,13 @@ export function resolveCronPayloadOutcome(params: {
324353
{ field: "synthesizedText", text: synthesizedText },
325354
{ field: "fallbackSummary", text: fallbackSummary },
326355
{ field: "fallbackOutputText", text: fallbackOutputText },
327-
...params.payloads.map((payload, index) => ({
328-
field: `payloads[${index}].text`,
329-
text: payload?.text,
330-
})),
356+
...params.payloads
357+
.map((payload, index) => ({ payload, index }))
358+
.filter(({ payload }) => !isNonTerminalToolErrorWarning(payload))
359+
.map(({ payload, index }) => ({
360+
field: `payloads[${index}].text`,
361+
text: payload?.text,
362+
})),
331363
]);
332364
const failureSignal = normalizeCronFailureSignal(params.failureSignal);
333365
const runLevelError = formatCronRunLevelError(params.runLevelError);

0 commit comments

Comments
 (0)