Skip to content

Commit 3685da5

Browse files
committed
fix(cron): classify embedded fallback results
1 parent e396ff6 commit 3685da5

2 files changed

Lines changed: 179 additions & 8 deletions

File tree

src/cron/isolated-agent/run-executor.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
import { createHash } from "node:crypto";
33
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
44
import type { BootstrapContextMode } from "../../agents/bootstrap-files.js";
5+
import {
6+
classifyEmbeddedAgentRunResultForModelFallback,
7+
mergeEmbeddedAgentRunResultForModelFallbackExhaustion,
8+
} from "../../agents/embedded-agent-runner/result-fallback-classifier.js";
59
import type { FastModeAutoProgressState } from "../../agents/fast-mode.js";
610
import { runAgentHarnessBeforeMessageWriteHook } from "../../agents/harness/hook-helpers.js";
711
import { resolveCliRuntimeExecutionProvider } from "../../agents/model-runtime-aliases.js";
@@ -294,6 +298,22 @@ export function createCronPromptExecutor(params: {
294298
recorder: UserTurnTranscriptRecorder;
295299
}
296300
| undefined;
301+
// CLI providers surface their own terminal classification. Embedded cron
302+
// results can return fallback-safe incomplete/empty payloads, so the fallback
303+
// classifier must agree with the branch that produced the result.
304+
const resolveCronExecutionProvider = (provider: string, model: string) => {
305+
const executionProvider =
306+
resolveCliRuntimeExecutionProvider({
307+
provider,
308+
cfg: params.cfgWithAgentDefaults,
309+
agentId: params.agentId,
310+
modelId: model,
311+
}) ?? provider;
312+
return {
313+
executionProvider,
314+
isCli: isCliProvider(executionProvider, params.cfgWithAgentDefaults),
315+
};
316+
};
297317

298318
const runPrompt = async (promptText: string) => {
299319
const userTurnTranscriptRecorder =
@@ -339,22 +359,24 @@ export function createCronPromptExecutor(params: {
339359
});
340360
},
341361
fallbacksOverride: cronFallbacksOverride,
362+
classifyResult: ({ provider, model, result }) =>
363+
resolveCronExecutionProvider(provider, model).isCli
364+
? null
365+
: classifyEmbeddedAgentRunResultForModelFallback({ provider, model, result }),
366+
mergeExhaustedResult: mergeEmbeddedAgentRunResultForModelFallbackExhaustion,
342367
run: async (providerOverride, modelOverride, runOptions) => {
343368
if (params.abortSignal?.aborted) {
344369
throw new Error(params.abortReason());
345370
}
346-
const executionProvider =
347-
resolveCliRuntimeExecutionProvider({
348-
provider: providerOverride,
349-
cfg: params.cfgWithAgentDefaults,
350-
agentId: params.agentId,
351-
modelId: modelOverride,
352-
}) ?? providerOverride;
371+
const { executionProvider, isCli } = resolveCronExecutionProvider(
372+
providerOverride,
373+
modelOverride,
374+
);
353375
const bootstrapPromptWarningSignature =
354376
bootstrapPromptWarningSignaturesSeen[bootstrapPromptWarningSignaturesSeen.length - 1];
355377
// CLI providers can resume provider-native sessions; embedded providers
356378
// use OpenClaw's transcript/session file plus prompt-cache affinity.
357-
if (isCliProvider(executionProvider, params.cfgWithAgentDefaults)) {
379+
if (isCli) {
358380
const cliSessionBinding = params.cronSession.isNewSession
359381
? undefined
360382
: await getCliSessionBinding(params.cronSession.sessionEntry, executionProvider);
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { describe, expect, it } from "vitest";
2+
import { mergeEmbeddedAgentRunResultForModelFallbackExhaustion } from "../../agents/embedded-agent-runner/result-fallback-classifier.js";
3+
import { makeIsolatedAgentJobFixture, makeIsolatedAgentParamsFixture } from "./job-fixtures.js";
4+
import { setupRunCronIsolatedAgentTurnSuite } from "./run.suite-helpers.js";
5+
import {
6+
isCliProviderMock,
7+
loadRunCronIsolatedAgentTurn,
8+
mockRunCronFallbackPassthrough,
9+
runEmbeddedAgentMock,
10+
runWithModelFallbackMock,
11+
} from "./run.test-harness.js";
12+
13+
const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
14+
15+
function makeReasoningOnlyIncompleteResult() {
16+
return {
17+
payloads: [{ text: "Agent couldn't generate a response.", isError: true }],
18+
meta: {
19+
agentMeta: {},
20+
agentHarnessResultClassification: "reasoning-only",
21+
error: {
22+
kind: "incomplete_turn",
23+
message: "Agent couldn't generate a response.",
24+
fallbackSafe: true,
25+
terminalPresentation: false,
26+
},
27+
},
28+
};
29+
}
30+
31+
function makeHealthyFallbackResult() {
32+
return {
33+
payloads: [{ text: "Workspace cleaned up: removed 12 stale files." }],
34+
meta: {
35+
agentMeta: {},
36+
finalAssistantVisibleText: "Workspace cleaned up: removed 12 stale files.",
37+
},
38+
};
39+
}
40+
41+
function installFaithfulFallbackLoop(): void {
42+
runWithModelFallbackMock.mockImplementation(
43+
async ({ provider, model, run, fallbacksOverride, classifyResult, mergeExhaustedResult }) => {
44+
const candidates = [
45+
{ provider, model },
46+
...((fallbacksOverride ?? []) as string[]).map((ref) => {
47+
const slash = ref.indexOf("/");
48+
return { provider: ref.slice(0, slash), model: ref.slice(slash + 1) };
49+
}),
50+
];
51+
let latest = { result: undefined as unknown, provider, model };
52+
for (let index = 0; index < candidates.length; index += 1) {
53+
const candidate = candidates[index];
54+
const result = await run(candidate.provider, candidate.model, {
55+
isFinalFallbackAttempt: index === candidates.length - 1,
56+
});
57+
const classification = await classifyResult?.({
58+
result,
59+
provider: candidate.provider,
60+
model: candidate.model,
61+
attempt: index + 1,
62+
total: candidates.length,
63+
});
64+
if (!classification) {
65+
return { result, provider: candidate.provider, model: candidate.model, attempts: [] };
66+
}
67+
latest = { result, provider: candidate.provider, model: candidate.model };
68+
}
69+
const merged = mergeExhaustedResult
70+
? mergeExhaustedResult({ latestResult: latest.result, preferredResult: latest.result })
71+
: latest.result;
72+
return { result: merged, provider: latest.provider, model: latest.model, attempts: [] };
73+
},
74+
);
75+
}
76+
77+
describe("runCronIsolatedAgentTurn result-level fallback wiring", () => {
78+
setupRunCronIsolatedAgentTurnSuite({ fast: true });
79+
80+
it("engages the configured fallback when the primary returns a reasoning-only incomplete result", async () => {
81+
installFaithfulFallbackLoop();
82+
runEmbeddedAgentMock.mockReset();
83+
runEmbeddedAgentMock
84+
.mockResolvedValueOnce(makeReasoningOnlyIncompleteResult())
85+
.mockResolvedValueOnce(makeHealthyFallbackResult());
86+
87+
const result = await runCronIsolatedAgentTurn(
88+
makeIsolatedAgentParamsFixture({
89+
job: makeIsolatedAgentJobFixture({
90+
payload: {
91+
kind: "agentTurn",
92+
message: "test",
93+
fallbacks: ["anthropic/claude-sonnet-4-6"],
94+
},
95+
}),
96+
}),
97+
);
98+
99+
expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(2);
100+
expect(runEmbeddedAgentMock.mock.calls[1]?.[0]).toMatchObject({
101+
provider: "anthropic",
102+
model: "claude-sonnet-4-6",
103+
});
104+
expect(result.status).toBe("ok");
105+
});
106+
107+
it("passes the embedded classifier and merge helper only for embedded results", async () => {
108+
mockRunCronFallbackPassthrough();
109+
110+
await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture());
111+
112+
const request = runWithModelFallbackMock.mock.calls[0]?.[0] as {
113+
classifyResult?: (input: {
114+
result: unknown;
115+
provider: string;
116+
model: string;
117+
attempt: number;
118+
total: number;
119+
}) => unknown;
120+
mergeExhaustedResult?: unknown;
121+
};
122+
expect(typeof request?.classifyResult).toBe("function");
123+
expect(request?.mergeExhaustedResult).toBe(
124+
mergeEmbeddedAgentRunResultForModelFallbackExhaustion,
125+
);
126+
127+
const reasoningOnly = makeReasoningOnlyIncompleteResult();
128+
expect(
129+
await request.classifyResult?.({
130+
result: reasoningOnly,
131+
provider: "openai",
132+
model: "gpt-5.4",
133+
attempt: 1,
134+
total: 2,
135+
}),
136+
).toBeTruthy();
137+
138+
isCliProviderMock.mockImplementation((provider: string) => provider === "claude-cli");
139+
expect(
140+
await request.classifyResult?.({
141+
result: reasoningOnly,
142+
provider: "claude-cli",
143+
model: "claude-sonnet-4-6",
144+
attempt: 1,
145+
total: 2,
146+
}),
147+
).toBeNull();
148+
});
149+
});

0 commit comments

Comments
 (0)