fix(cron): preserve silent tool results

This commit is contained in:
Peter Steinberger
2026-04-27 06:04:21 +01:00
parent a167e687ce
commit 488a1ee146
4 changed files with 218 additions and 6 deletions

View File

@@ -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.

View File

@@ -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(

View File

@@ -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,

View File

@@ -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;