mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
fix(cron): preserve silent tool results
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user