diff --git a/CHANGELOG.md b/CHANGELOG.md index fe22816408e..1cd997d0091 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai - Gateway: avoid repeated plugin tool descriptor config hashing so large runtime configs do not block reply startup and trigger reconnect/timeouts. (#75944) Thanks @joshavant. - Plugins/externalization: keep diagnostics ClawHub packages and persisted bundled-plugin relocation on npm-first install metadata for launch, and omit Discord from the core package now that its external package is published. Thanks @vincentkoc. - Setup/TUI: bound the Terminal hatch bootstrap run so a stalled provider request times out instead of leaving first-run hatching stuck behind the watchdog. (#76241) Thanks @joshavant. +- Cron/CLI runtimes: route isolated cron jobs through configured per-agent CLI runtimes only when the resolved model provider is compatible, so OpenAI job overrides no longer inherit a mismatched Claude CLI backend. Thanks @vishutdhar. - Plugins/Codex: allow the official npm Codex plugin to install without the unsafe-install override, keep `/codex` command ownership, and cover the real npm Docker live path through managed `.openclaw/npm` dependencies plus uninstall failure proof. - Gateway/status: add concrete service, config, listener-owner, and log collection next steps when gateway probes fail and Bonjour finds no local gateway, so frozen or port-conflict reports include the data needed for root-cause triage. Refs #49012. Thanks @vincentkoc. - Codex harness: forward OpenClaw workspace bootstrap files such as `SOUL.md` through native Codex config instructions while leaving `AGENTS.md` to Codex project-doc discovery. Fixes #76273. Thanks @zknicker. diff --git a/src/cron/isolated-agent.model-formatting.test.ts b/src/cron/isolated-agent.model-formatting.test.ts index df14ad6c648..86e714e3b30 100644 --- a/src/cron/isolated-agent.model-formatting.test.ts +++ b/src/cron/isolated-agent.model-formatting.test.ts @@ -65,6 +65,7 @@ type SelectModelOptions = { providerOverride?: string; }; isGmailHook?: boolean; + agentId?: string; }; function parseModelRef(raw: string): { provider: string; model: string } | { error: string } { @@ -126,6 +127,7 @@ async function selectModel(options: SelectModelOptions = {}) { sessionEntry: options.sessionEntry ?? {}, payload: options.payload ?? defaultPayload(), isGmailHook: options.isGmailHook ?? false, + agentId: options.agentId, }); } @@ -401,6 +403,94 @@ describe("cron model formatting and precedence edge cases", () => { }); }); + describe("CLI runtime compatibility", () => { + it("keeps the canonical Anthropic provider when a per-agent Claude CLI runtime is configured", async () => { + await expectSelectedModel( + { + cfg: { + agents: { + defaults: { + model: "anthropic/claude-opus-4-6", + }, + list: [ + { + id: "scheduler", + agentRuntime: { id: "claude-cli" }, + }, + ], + }, + }, + agentId: "scheduler", + }, + { provider: "anthropic", model: "claude-opus-4-6" }, + ); + }); + + it("keeps an OpenAI payload override on OpenAI when per-agent Claude CLI is configured", async () => { + await expectSelectedModel( + { + cfg: { + agents: { + defaults: { + model: "anthropic/claude-opus-4-6", + }, + list: [ + { + id: "scheduler", + agentRuntime: { id: "claude-cli" }, + }, + ], + }, + }, + agentId: "scheduler", + payload: { + kind: "agentTurn", + message: DEFAULT_MESSAGE, + model: "openai/gpt-4.1-mini", + }, + }, + { provider: "openai", model: "gpt-4.1-mini" }, + ); + }); + + it("keeps the canonical Anthropic provider when a default Claude CLI runtime is configured", async () => { + await expectSelectedModel( + { + cfg: { + agents: { + defaults: { + model: "anthropic/claude-opus-4-6", + agentRuntime: { id: "claude-cli" }, + }, + }, + }, + }, + { provider: "anthropic", model: "claude-opus-4-6" }, + ); + }); + + it("keeps an OpenAI payload override on OpenAI when default Claude CLI is configured", async () => { + await expectSelectedModel( + { + cfg: { + agents: { + defaults: { + model: "anthropic/claude-opus-4-6", + agentRuntime: { id: "claude-cli" }, + }, + }, + }, + payload: { + kind: "agentTurn", + message: DEFAULT_MESSAGE, + model: "openai/gpt-4.1-mini", + }, + }, + { provider: "openai", model: "gpt-4.1-mini" }, + ); + }); + }); + describe("stored session overrides", () => { it("stored modelOverride/providerOverride are applied", async () => { await expectSelectedModel( diff --git a/src/cron/isolated-agent/model-selection.ts b/src/cron/isolated-agent/model-selection.ts index 07d745731a8..9ae0b2d07df 100644 --- a/src/cron/isolated-agent/model-selection.ts +++ b/src/cron/isolated-agent/model-selection.ts @@ -28,6 +28,7 @@ export type ResolveCronModelSelectionParams = { sessionEntry: CronSessionModelOverrides; payload: CronJob["payload"]; isGmailHook: boolean; + agentId?: string; }; export type ResolveCronModelSelectionResult = diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts index e623996a84d..ceb5a864d1b 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -1,3 +1,4 @@ +import { resolveCliRuntimeExecutionProvider } from "../../agents/model-runtime-aliases.js"; import type { SkillSnapshot } from "../../agents/skills.js"; import { normalizeToolList } from "../../agents/tool-policy.js"; import type { ThinkLevel, VerboseLevel } from "../../auto-reply/thinking.js"; @@ -135,12 +136,18 @@ export function createCronPromptExecutor(params: { if (params.abortSignal?.aborted) { throw new Error(params.abortReason()); } + const executionProvider = + resolveCliRuntimeExecutionProvider({ + provider: providerOverride, + cfg: params.cfgWithAgentDefaults, + agentId: params.agentId, + }) ?? providerOverride; const bootstrapPromptWarningSignature = bootstrapPromptWarningSignaturesSeen[bootstrapPromptWarningSignaturesSeen.length - 1]; - if (isCliProvider(providerOverride, params.cfgWithAgentDefaults)) { + if (isCliProvider(executionProvider, params.cfgWithAgentDefaults)) { const cliSessionId = params.cronSession.isNewSession ? undefined - : await getCliSessionId(params.cronSession.sessionEntry, providerOverride); + : await getCliSessionId(params.cronSession.sessionEntry, executionProvider); const result = await runCliAgent({ sessionId: params.cronSession.sessionEntry.sessionId, sessionKey: params.runSessionKey, @@ -151,7 +158,7 @@ export function createCronPromptExecutor(params: { workspaceDir: params.workspaceDir, config: params.cfgWithAgentDefaults, prompt: promptText, - provider: providerOverride, + provider: executionProvider, model: modelOverride, thinkLevel: params.thinkLevel, timeoutMs: params.timeoutMs, diff --git a/src/cron/isolated-agent/run.payload-fallbacks.test.ts b/src/cron/isolated-agent/run.payload-fallbacks.test.ts index dd1b672636f..f131718ec17 100644 --- a/src/cron/isolated-agent/run.payload-fallbacks.test.ts +++ b/src/cron/isolated-agent/run.payload-fallbacks.test.ts @@ -5,8 +5,11 @@ import { setupRunCronIsolatedAgentTurnSuite, } from "./run.suite-helpers.js"; import { + isCliProviderMock, loadRunCronIsolatedAgentTurn, + resolveConfiguredModelRefMock, resolveAgentModelFallbacksOverrideMock, + runCliAgentMock, runWithModelFallbackMock, } from "./run.test-harness.js"; @@ -54,4 +57,53 @@ describe("runCronIsolatedAgentTurn — payload.fallbacks", () => { expect(runWithModelFallbackMock).toHaveBeenCalledOnce(); expect(runWithModelFallbackMock.mock.calls[0][0].fallbacksOverride).toEqual(expectedFallbacks); }); + + it("plans Anthropic fallbacks canonically while executing compatible attempts through Claude CLI", async () => { + isCliProviderMock.mockImplementation((provider: string) => provider === "claude-cli"); + resolveConfiguredModelRefMock.mockReturnValue({ + provider: "anthropic", + model: "claude-opus-4-6", + }); + runCliAgentMock.mockResolvedValue({ + payloads: [{ text: "fallback ok" }], + meta: { agentMeta: { usage: { input: 10, output: 20 } } }, + }); + runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { + const firstResult = await run(provider, model); + const secondResult = await run("anthropic", "claude-sonnet-4-6"); + return { + result: secondResult ?? firstResult, + provider: "anthropic", + model: "claude-sonnet-4-6", + attempts: [], + }; + }); + + const result = await runCronIsolatedAgentTurn( + makeIsolatedAgentTurnParams({ + cfg: { + agents: { + defaults: { + agentRuntime: { id: "claude-cli" }, + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["anthropic/claude-sonnet-4-6"], + }, + }, + }, + }, + }), + ); + + expect(result.status).toBe("ok"); + expect(runWithModelFallbackMock).toHaveBeenCalledOnce(); + expect(runWithModelFallbackMock.mock.calls[0][0]).toMatchObject({ + provider: "anthropic", + model: "claude-opus-4-6", + }); + expect(runCliAgentMock.mock.calls.map((call) => [call[0].provider, call[0].model])).toEqual([ + ["claude-cli", "claude-opus-4-6"], + ["claude-cli", "claude-sonnet-4-6"], + ]); + }); }); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index e6f5c2bd898..c85cbb32a5f 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -561,6 +561,7 @@ async function prepareCronRunContext(params: { sessionEntry: cronSession.sessionEntry, payload: input.job.payload, isGmailHook, + agentId, }); if (!resolvedModelSelection.ok) { return {