diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fd8c3968a2..676d1078972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - CLI/help: treat positional `help` invocations like `openclaw channels help` as help paths for startup gating, avoiding model/auth warmup while preserving positional arguments such as `openclaw docs help`. Thanks @gumadeiras. - Matrix/E2EE: stabilize recovery and broken-device QA flows while avoiding Matrix device-cleanup sync races that could leave shutdown-time crypto work running. Thanks @gumadeiras. - Cron: treat isolated run-level agent failures as job errors even when no reply payload is produced, synthesizing a safe error payload so model/provider failures increment error counters and trigger failure notifications instead of clearing as successful. Fixes #43604; carries forward #43631. Thanks @SPFAdvisors. +- Cron: preserve exact `NO_REPLY` tool results from isolated jobs with empty final assistant turns as quiet successes instead of surfacing incomplete-turn errors. Fixes #68452; carries forward #68453. Thanks @anyech. - Cron: classify isolated runs as errors from structured embedded-run execution-denial metadata, with final-output marker fallback for `SYSTEM_RUN_DENIED`, `INVALID_REQUEST`, and approval-binding refusals, so blocked commands no longer appear green in cron history. Fixes #67172; carries forward #67186. Thanks @oc-gh-dr, @hclsys, and @1yihui. - Onboarding/GitHub Copilot: add manifest-owned `--github-copilot-token` support for non-interactive setup, including env fallback, tokenRef storage in ref mode, saved-profile reuse, and current Copilot default-model wiring. Refs #50002 and supersedes #50003. Thanks @scottgl9. - Gateway/install: add a validated `--wrapper`/`OPENCLAW_WRAPPER` service install path that persists executable LaunchAgent/systemd wrappers across forced reinstalls, updates, and doctor repairs instead of falling back to raw node/bun `ProgramArguments`. Fixes #69400. (#72445) Thanks @willtmc. diff --git a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts index b5637314703..bd0b27ab1fe 100644 --- a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts +++ b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts @@ -30,6 +30,7 @@ import { STRICT_AGENTIC_BLOCKED_TEXT, resolveReplayInvalidFlag, resolveRunLivenessState, + resolveSilentToolResultReplyPayload, shouldTreatEmptyAssistantReplyAsSilent, } from "./run/incomplete-turn.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; @@ -76,6 +77,113 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { expect(result.payloads?.[0]?.text).toContain("verify before retrying"); }); + it("synthesizes a silent cron payload from a trailing current-attempt NO_REPLY tool result", () => { + const payload = resolveSilentToolResultReplyPayload({ + isCronTrigger: true, + payloadCount: 0, + aborted: false, + timedOut: false, + attempt: makeAttemptResult({ + assistantTexts: [], + toolMetas: [{ toolName: "exec" }], + messagesSnapshot: [ + { + role: "toolResult", + content: [{ type: "text", text: "NO_REPLY" }], + details: { aggregated: "NO_REPLY" }, + } as unknown as EmbeddedRunAttemptResult["messagesSnapshot"][number], + { + role: "assistant", + stopReason: "stop", + provider: "openai", + model: "gpt-5.4", + content: [], + } as unknown as EmbeddedRunAttemptResult["messagesSnapshot"][number], + ], + }), + }); + + expect(payload).toEqual({ text: "NO_REPLY" }); + }); + + it("does not reuse an older NO_REPLY tool result without current-attempt tool activity", () => { + const payload = resolveSilentToolResultReplyPayload({ + isCronTrigger: true, + payloadCount: 0, + aborted: false, + timedOut: false, + attempt: makeAttemptResult({ + assistantTexts: [], + toolMetas: [], + messagesSnapshot: [ + { + role: "toolResult", + content: [{ type: "text", text: "NO_REPLY" }], + } as unknown as EmbeddedRunAttemptResult["messagesSnapshot"][number], + { + role: "user", + content: [{ type: "text", text: "Current cron prompt" }], + } as unknown as EmbeddedRunAttemptResult["messagesSnapshot"][number], + { + role: "assistant", + stopReason: "stop", + provider: "openai", + model: "gpt-5.4", + content: [], + } as unknown as EmbeddedRunAttemptResult["messagesSnapshot"][number], + ], + }), + }); + + expect(payload).toBeNull(); + }); + + it("treats exact NO_REPLY tool output as a quiet cron success when the final assistant is empty", async () => { + mockedClassifyFailoverReason.mockReturnValue(null); + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + assistantTexts: [], + toolMetas: [{ toolName: "exec" }], + messagesSnapshot: [ + { + role: "toolResult", + content: [{ type: "text", text: "NO_REPLY" }], + details: { aggregated: "NO_REPLY" }, + } as unknown as EmbeddedRunAttemptResult["messagesSnapshot"][number], + { + role: "assistant", + stopReason: "stop", + provider: "openai", + model: "gpt-5.4", + content: [], + } as unknown as EmbeddedRunAttemptResult["messagesSnapshot"][number], + ], + lastAssistant: { + role: "assistant", + stopReason: "stop", + provider: "openai", + model: "gpt-5.4", + content: [], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + ); + + const result = await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + trigger: "cron", + provider: "openai", + model: "gpt-5.4", + runId: "run-cron-no-reply-empty-final", + }); + + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); + expect(result.payloads).toEqual([{ text: "NO_REPLY" }]); + expect(result.meta.livenessState).toBe("working"); + expect(mockedLog.warn).not.toHaveBeenCalledWith( + expect.stringContaining("incomplete turn detected"), + ); + }); + it("uses explicit agentId without a session key before surfacing the strict-agentic blocked state", async () => { mockedClassifyFailoverReason.mockReturnValue(null); mockedRunEmbeddedAttempt.mockResolvedValue( diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 3adbcc24131..43070b8ab14 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -119,6 +119,7 @@ import { resolvePlanningOnlyRetryLimit, resolvePlanningOnlyRetryInstruction, resolveReasoningOnlyRetryInstruction, + resolveSilentToolResultReplyPayload, STRICT_AGENTIC_BLOCKED_TEXT, resolveReplayInvalidFlag, resolveRunLivenessState, @@ -1910,7 +1911,19 @@ export async function runEmbeddedPiAgent( }; } - const payloadCount = payloadsWithToolMedia?.length ?? 0; + const silentToolResultReplyPayload = resolveSilentToolResultReplyPayload({ + isCronTrigger: params.trigger === "cron", + payloadCount: payloadsWithToolMedia?.length ?? 0, + aborted, + timedOut, + attempt, + }); + const payloadsForTerminalPath = payloadsWithToolMedia?.length + ? payloadsWithToolMedia + : silentToolResultReplyPayload + ? [silentToolResultReplyPayload] + : payloadsWithToolMedia; + const payloadCount = payloadsForTerminalPath?.length ?? 0; const emptyAssistantReplyIsSilent = shouldTreatEmptyAssistantReplyAsSilent({ allowEmptyAssistantReplyAsSilent: params.allowEmptyAssistantReplyAsSilent, payloadCount, @@ -2192,7 +2205,7 @@ export async function runEmbeddedPiAgent( if (incompleteTurnText) { const replayInvalid = resolveReplayInvalidForAttempt(incompleteTurnText); const livenessState = resolveRunLivenessState({ - payloadCount: payloads.length, + payloadCount, aborted, timedOut, attempt, @@ -2205,7 +2218,7 @@ export async function runEmbeddedPiAgent( const incompleteStopReason = attempt.lastAssistant?.stopReason; log.warn( `incomplete turn detected: runId=${params.runId} sessionId=${params.sessionId} ` + - `stopReason=${incompleteStopReason} payloads=0 — surfacing error to user`, + `stopReason=${incompleteStopReason} payloads=${payloadCount} — surfacing error to user`, ); // Mark the failing profile for cooldown so multi-profile setups @@ -2265,7 +2278,7 @@ export async function runEmbeddedPiAgent( } const replayInvalid = resolveReplayInvalidForAttempt(null); const livenessState = resolveRunLivenessState({ - payloadCount: payloads.length, + payloadCount, aborted, timedOut, attempt, @@ -2278,7 +2291,7 @@ export async function runEmbeddedPiAgent( : (sessionLastAssistant?.stopReason as string | undefined); const terminalPayloads = emptyAssistantReplyIsSilent ? [{ text: SILENT_REPLY_TOKEN }] - : payloadsWithToolMedia; + : payloadsForTerminalPath; attempt.setTerminalLifecycleMeta?.({ replayInvalid, livenessState, diff --git a/src/agents/pi-embedded-runner/run/incomplete-turn.ts b/src/agents/pi-embedded-runner/run/incomplete-turn.ts index e8837d5c3d8..332a25dd7ee 100644 --- a/src/agents/pi-embedded-runner/run/incomplete-turn.ts +++ b/src/agents/pi-embedded-runner/run/incomplete-turn.ts @@ -1,7 +1,12 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "../../../auto-reply/tokens.js"; +import { + isSilentReplyPayloadText, + isSilentReplyText, + SILENT_REPLY_TOKEN, +} from "../../../auto-reply/tokens.js"; import type { EmbeddedPiExecutionContract } from "../../../config/types.agent-defaults.js"; import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js"; +import { collectTextContentBlocks } from "../../content-blocks.js"; import { isStrictAgenticSupportedProviderModel, stripProviderPrefix, @@ -52,6 +57,16 @@ type PlanningOnlyAttempt = Pick< | "toolMetas" >; +type SilentToolResultAttempt = Pick< + EmbeddedRunAttemptResult, + | "clientToolCall" + | "yieldDetected" + | "didSendDeterministicApprovalPrompt" + | "lastToolError" + | "messagesSnapshot" + | "toolMetas" +>; + type RunLivenessAttempt = Pick< EmbeddedRunAttemptResult, "lastAssistant" | "promptErrorSource" | "replayMetadata" | "timedOutDuringCompaction" @@ -253,6 +268,81 @@ function hasOnlySilentAssistantReply(assistantTexts: readonly string[]): boolean ); } +function isToolResultRole(role: string): boolean { + return role === "toolresult" || role === "tool_result" || role === "tool"; +} + +function readMessageTextContent(message: AgentMessage): string | undefined { + const content = (message as { content?: unknown }).content; + if (typeof content === "string") { + const trimmed = content.trim(); + return trimmed || undefined; + } + const text = collectTextContentBlocks(content) + .map((item) => item.trim()) + .filter((item) => item.length > 0) + .join("\n"); + return text || undefined; +} + +function readToolResultAggregatedText(message: AgentMessage): string | undefined { + const aggregated = (message as { details?: { aggregated?: unknown } }).details?.aggregated; + if (typeof aggregated !== "string") { + return undefined; + } + const trimmed = aggregated.trim(); + return trimmed || undefined; +} + +function hasTrailingSilentToolResult(messages: readonly AgentMessage[]): boolean { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i]; + if (!message) { + continue; + } + const role = normalizeLowercaseStringOrEmpty(message?.role); + if (isToolResultRole(role)) { + if ((message as { isError?: boolean }).isError === true) { + return false; + } + const text = readMessageTextContent(message) ?? readToolResultAggregatedText(message); + return isSilentReplyText(text, SILENT_REPLY_TOKEN); + } + if (role === "assistant" && !readMessageTextContent(message)) { + continue; + } + return false; + } + return false; +} + +export function resolveSilentToolResultReplyPayload(params: { + isCronTrigger: boolean; + payloadCount: number; + aborted: boolean; + timedOut: boolean; + attempt: SilentToolResultAttempt; +}): { text: typeof SILENT_REPLY_TOKEN } | null { + if ( + !params.isCronTrigger || + params.payloadCount !== 0 || + params.aborted || + params.timedOut || + params.attempt.toolMetas.length === 0 || + params.attempt.clientToolCall || + params.attempt.yieldDetected || + params.attempt.didSendDeterministicApprovalPrompt || + params.attempt.lastToolError || + params.attempt.messagesSnapshot.length === 0 + ) { + return null; + } + + return hasTrailingSilentToolResult(params.attempt.messagesSnapshot) + ? { text: SILENT_REPLY_TOKEN } + : null; +} + export function resolveReplayInvalidFlag(params: { attempt: RunLivenessAttempt; incompleteTurnText?: string | null;