fix(agents): fail closed on missing Codex harness

This commit is contained in:
Vincent Koc
2026-05-18 12:42:24 +08:00
parent cd15ce35a0
commit 8f27b3e21f
15 changed files with 505 additions and 2 deletions

View File

@@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
- Gateway/skills: preflight remote macOS skill-bin refreshes with a WebSocket connectivity check so stale node sessions skip quickly instead of logging slow `system.which` timeout warnings.
- GitHub Copilot: drop unsafe native Responses reasoning replay items with non-replayable IDs before dispatch, preventing affected Copilot sessions from failing with `invalid_request_body`. Fixes #83220. Thanks @galiniliev.
- Agents/Codex: fail closed when an explicitly requested Codex harness is not registered instead of silently trying configured model fallbacks. Fixes #83349. Thanks @r2-vibes.
- QA-Lab: make runtime tool coverage fail on missing required tool exercise instead of treating pass/pass parity envelope drift as missing coverage.
- Core/plugins: harden clawpatch-reported edge cases across gateway auth cleanup, Claude session id paths, plugin activation policy, apply-patch hunk handling, diagnostic redaction, and plugin metadata validation.
- Mac app: prefer explicit private/Tailscale/LAN Gateway endpoints over SSH tunnels, preserve legacy loopback tunnel configs, persist transport choices, and show captured SSH stderr when tunneling really fails.

View File

@@ -1133,6 +1133,19 @@ async function agentCommandInternal(
...modelManifestContext,
runId,
agentDir,
agentId: sessionAgentId,
sessionKey: sessionKey ?? sessionId,
prepareAgentHarnessRuntime: async ({ provider, model, agentHarnessRuntimeOverride }) => {
await ensureSelectedAgentHarnessPlugin({
config: cfg,
provider,
modelId: model,
agentId: sessionAgentId,
sessionKey,
agentHarnessRuntimeOverride,
workspaceDir,
});
},
fallbacksOverride: effectiveFallbacksOverride,
onFallbackStep: (step) => {
fallbackTrajectoryRecorder?.recordEvent("model.fallback_step", step);

View File

@@ -0,0 +1,13 @@
export class MissingAgentHarnessError extends Error {
readonly harnessId: string;
constructor(harnessId: string) {
super(`Requested agent harness "${harnessId}" is not registered.`);
this.name = "MissingAgentHarnessError";
this.harnessId = harnessId;
}
}
export function isMissingAgentHarnessError(err: unknown): err is MissingAgentHarnessError {
return err instanceof MissingAgentHarnessError;
}

View File

@@ -21,6 +21,7 @@ import {
} from "../subagent-capabilities.js";
import { expandToolGroups, normalizeToolName } from "../tool-policy.js";
import { createPiAgentHarness } from "./builtin-pi.js";
import { MissingAgentHarnessError } from "./errors.js";
import {
resolveAgentHarnessPolicy as resolveConfiguredAgentHarnessPolicy,
type AgentHarnessPolicy,
@@ -170,7 +171,7 @@ function selectAgentHarnessDecision(params: {
candidates: listHarnessCandidates(pluginHarnesses),
});
}
throw new Error(`Requested agent harness "${runtime}" is not registered.`);
throw new MissingAgentHarnessError(runtime);
}
const candidates = pluginHarnesses.map((harness) => ({

View File

@@ -13,6 +13,7 @@ import { CommandLaneTaskTimeoutError } from "../process/command-queue.js";
import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js";
import type { AuthProfileStore } from "./auth-profiles/types.js";
import { FailoverError } from "./failover-error.js";
import { MissingAgentHarnessError } from "./harness/errors.js";
import { LiveSessionModelSwitchError } from "./live-model-switch-error.js";
import {
FallbackSummaryError,
@@ -556,6 +557,201 @@ describe("runWithModelFallback", () => {
expect(result.attempts[0].reason).toBe("unknown");
});
it("fails closed when a strict plugin harness is missing", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-5.5",
fallbacks: ["anthropic/claude-sonnet-4-6"],
},
},
},
});
const run = vi
.fn()
.mockRejectedValueOnce(new MissingAgentHarnessError("codex"))
.mockResolvedValueOnce("wrong fallback");
await expect(
runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-5.5",
run,
}),
).rejects.toThrow('Requested agent harness "codex" is not registered.');
expect(run).toHaveBeenCalledTimes(1);
});
it("fails closed before auth cooldown skips when a strict plugin harness is missing", async () => {
const cfg = makeCfg({
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
agentRuntime: { id: "codex" },
models: [],
},
},
},
agents: {
defaults: {
model: {
primary: "openai/gpt-5.5",
fallbacks: ["anthropic/claude-sonnet-4-6"],
},
},
},
});
const tempDir = await makeAuthTempDir();
setAuthRuntimeStore(tempDir, {
version: AUTH_STORE_VERSION,
profiles: {
"openai:default": { type: "api_key", provider: "openai", key: "test-key" },
"anthropic:default": { type: "api_key", provider: "anthropic", key: "test-key" },
},
usageStats: {
"openai:default": {
cooldownUntil: Date.now() + 60_000,
cooldownReason: "rate_limit",
failureCounts: { rate_limit: 1 },
},
},
});
const run = vi.fn().mockResolvedValueOnce("wrong fallback");
await expect(
runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-5.5",
agentDir: tempDir,
run,
}),
).rejects.toThrow('Requested agent harness "codex" is not registered.');
expect(run).not.toHaveBeenCalled();
});
it("uses agent runtime context before auth cooldown skips", async () => {
const cfg = makeCfg({
agents: {
list: [
{ id: "main", default: true },
{
id: "worker",
models: {
"openai/gpt-5.5": { agentRuntime: { id: "codex" } },
},
},
],
defaults: {
model: {
primary: "openai/gpt-5.5",
fallbacks: ["anthropic/claude-sonnet-4-6"],
},
},
},
});
const tempDir = await makeAuthTempDir();
setAuthRuntimeStore(tempDir, {
version: AUTH_STORE_VERSION,
profiles: {
"openai:default": { type: "api_key", provider: "openai", key: "test-key" },
"anthropic:default": { type: "api_key", provider: "anthropic", key: "test-key" },
},
usageStats: {
"openai:default": {
cooldownUntil: Date.now() + 60_000,
cooldownReason: "rate_limit",
failureCounts: { rate_limit: 1 },
},
},
});
const run = vi.fn().mockResolvedValueOnce("wrong fallback");
await expect(
runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-5.5",
agentDir: tempDir,
agentId: "worker",
run,
}),
).rejects.toThrow('Requested agent harness "codex" is not registered.');
expect(run).not.toHaveBeenCalled();
});
it("uses session runtime overrides before auth cooldown skips", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-5.5",
fallbacks: ["anthropic/claude-sonnet-4-6"],
},
},
},
});
const tempDir = await makeAuthTempDir();
setAuthRuntimeStore(tempDir, {
version: AUTH_STORE_VERSION,
profiles: {
"openai:default": { type: "api_key", provider: "openai", key: "test-key" },
"anthropic:default": { type: "api_key", provider: "anthropic", key: "test-key" },
},
usageStats: {
"openai:default": {
cooldownUntil: Date.now() + 60_000,
cooldownReason: "rate_limit",
failureCounts: { rate_limit: 1 },
},
},
});
const run = vi.fn().mockResolvedValueOnce("wrong fallback");
await expect(
runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-5.5",
agentDir: tempDir,
resolveAgentHarnessRuntimeOverride: (provider) =>
provider === "openai" ? "codex" : undefined,
run,
}),
).rejects.toThrow('Requested agent harness "codex" is not registered.');
expect(run).not.toHaveBeenCalled();
});
it("lets configured CLI runtimes reach the run callback", async () => {
const cfg = makeCfg({
agents: {
defaults: {
models: {
"anthropic/*": { agentRuntime: { id: "claude-cli" } },
},
model: {
primary: "anthropic/claude-sonnet-4-6",
},
},
},
});
const run = vi.fn().mockResolvedValueOnce("cli ok");
const result = await runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-sonnet-4-6",
run,
});
expect(result.result).toBe("cli ok");
expect(run).toHaveBeenCalledTimes(1);
expect(run.mock.calls[0]).toEqual(["anthropic", "claude-sonnet-4-6"]);
});
it("does not treat command-lane watchdog timeouts as model fallback failures", async () => {
const cfg = makeCfg();
const timeoutError = new CommandLaneTaskTimeoutError("cron-nested", 330_000);

View File

@@ -26,6 +26,9 @@ import {
shouldPreserveTransientCooldownProbeSlot,
shouldUseTransientCooldownProbeSlot,
} from "./failover-policy.js";
import { MissingAgentHarnessError, isMissingAgentHarnessError } from "./harness/errors.js";
import { resolveAgentHarnessPolicy } from "./harness/policy.js";
import { getRegisteredAgentHarness } from "./harness/registry.js";
import { LiveSessionModelSwitchError } from "./live-model-switch-error.js";
import {
isModelFallbackDecisionLogEnabled,
@@ -34,6 +37,8 @@ import {
type ModelFallbackStepFields,
} from "./model-fallback-observation.js";
import type { FallbackAttempt, ModelCandidate } from "./model-fallback.types.js";
import { isCliRuntimeAlias } from "./model-runtime-aliases.js";
import { isCliProvider } from "./model-selection-cli.js";
import {
type ModelManifestNormalizationContext,
modelKey,
@@ -91,6 +96,18 @@ export type ModelFallbackRunOptions = {
allowTransientCooldownProbe?: boolean;
};
type ModelFallbackRuntimeContext = {
cfg?: OpenClawConfig;
agentId?: string;
sessionKey?: string;
resolveAgentHarnessRuntimeOverride?: (provider: string, model: string) => string | undefined;
prepareAgentHarnessRuntime?: (params: {
provider: string;
model: string;
agentHarnessRuntimeOverride?: string;
}) => Promise<void> | void;
};
type ModelFallbackRunFn<T> = (
provider: string,
model: string,
@@ -322,6 +339,55 @@ function sameModelCandidate(a: ModelCandidate, b: ModelCandidate): boolean {
return a.provider === b.provider && a.model === b.model;
}
function isCliAgentRuntime(runtime: string | undefined, cfg: OpenClawConfig | undefined): boolean {
const normalized = normalizeOptionalString(runtime);
if (!normalized) {
return false;
}
return isCliRuntimeAlias(normalized) || isCliProvider(normalized, cfg);
}
async function assertModelFallbackCandidateHarnessAvailable(
params: ModelFallbackRuntimeContext & ModelCandidate,
): Promise<void> {
if (!params.cfg) {
return;
}
const agentHarnessRuntimeOverride = params.resolveAgentHarnessRuntimeOverride?.(
params.provider,
params.model,
);
if (isCliProvider(params.provider, params.cfg)) {
return;
}
const agentRuntimeOverride = normalizeOptionalString(agentHarnessRuntimeOverride);
const harnessPolicy = resolveAgentHarnessPolicy({
provider: params.provider,
modelId: params.model,
config: params.cfg,
agentId: params.agentId,
sessionKey: params.sessionKey,
});
const agentRuntime = agentRuntimeOverride ?? harnessPolicy.runtime;
const agentRuntimeSource = agentRuntimeOverride ? "model" : harnessPolicy.runtimeSource;
if (isCliAgentRuntime(agentRuntime, params.cfg)) {
return;
}
await params.prepareAgentHarnessRuntime?.({
provider: params.provider,
model: params.model,
agentHarnessRuntimeOverride,
});
if (
agentRuntime !== "auto" &&
agentRuntime !== "pi" &&
!(agentRuntime === "codex" && agentRuntimeSource === "implicit") &&
!getRegisteredAgentHarness(agentRuntime)
) {
throw new MissingAgentHarnessError(agentRuntime);
}
}
function recordFailedCandidateAttempt(params: {
attempts: FallbackAttempt[];
candidate: ModelCandidate;
@@ -842,6 +908,14 @@ export async function runWithModelFallback<T>(
model: string;
runId?: string;
sessionId?: string;
agentId?: string;
sessionKey?: string;
resolveAgentHarnessRuntimeOverride?: (provider: string, model: string) => string | undefined;
prepareAgentHarnessRuntime?: (params: {
provider: string;
model: string;
agentHarnessRuntimeOverride?: string;
}) => Promise<void> | void;
lane?: string;
agentDir?: string;
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
@@ -902,6 +976,14 @@ export async function runWithModelFallback<T>(
for (let i = 0; i < candidates.length; i += 1) {
const candidate = candidates[i];
await assertModelFallbackCandidateHarnessAvailable({
cfg: params.cfg,
agentId: params.agentId,
sessionKey: params.sessionKey,
resolveAgentHarnessRuntimeOverride: params.resolveAgentHarnessRuntimeOverride,
prepareAgentHarnessRuntime: params.prepareAgentHarnessRuntime,
...candidate,
});
const isPrimary = i === 0;
const requestedModel = requestedCandidate
? sameModelCandidate(candidate, requestedCandidate)
@@ -1129,6 +1211,9 @@ export async function runWithModelFallback<T>(
if (isLikelyContextOverflowError(errMessage)) {
throw err;
}
if (isMissingAgentHarnessError(err)) {
throw err;
}
const normalized =
coerceToFailoverError(err, {
provider: candidate.provider,

View File

@@ -58,6 +58,7 @@ import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../d
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
import { resolveOpenClawReferencePaths } from "../docs-path.js";
import { coerceToFailoverError, describeFailoverError } from "../failover-error.js";
import { ensureSelectedAgentHarnessPlugin } from "../harness/runtime-plugin.js";
import { resolveAgentHarnessPolicy } from "../harness/selection.js";
import { resolveHeartbeatPromptForSystemPrompt } from "../heartbeat-system-prompt.js";
import {
@@ -425,6 +426,11 @@ export async function compactEmbeddedPiSessionDirect(
const primaryProvider = resolvedCompactionTarget.provider ?? DEFAULT_PROVIDER;
const primaryModel = resolvedCompactionTarget.model ?? DEFAULT_MODEL;
const fallbacksOverride = resolveCompactionFallbacksOverride(params);
const fallbackAgentId = resolveSessionAgentIds({
sessionKey: params.sandboxSessionKey ?? params.sessionKey,
config: params.config,
}).sessionAgentId;
const fallbackSessionKey = params.sandboxSessionKey ?? params.sessionKey ?? params.sessionId;
try {
const fallbackResult = await runWithModelFallback<EmbeddedPiCompactResult>({
cfg: params.config,
@@ -432,6 +438,19 @@ export async function compactEmbeddedPiSessionDirect(
model: primaryModel,
runId: params.runId ?? params.sessionId,
agentDir: params.agentDir,
agentId: fallbackAgentId,
sessionKey: fallbackSessionKey,
prepareAgentHarnessRuntime: async ({ provider, model, agentHarnessRuntimeOverride }) => {
await ensureSelectedAgentHarnessPlugin({
config: params.config,
provider,
modelId: model,
agentId: fallbackAgentId,
sessionKey: fallbackSessionKey,
agentHarnessRuntimeOverride,
workspaceDir: params.workspaceDir,
});
},
fallbacksOverride,
classifyResult: ({ result, provider, model }) =>
classifyCompactionFallbackResult(result, provider, model),

View File

@@ -17,6 +17,7 @@ import {
import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js";
import { getCliSessionBinding } from "../../agents/cli-session.js";
import { resolveContextTokensForModel } from "../../agents/context.js";
import { ensureSelectedAgentHarnessPlugin } from "../../agents/harness/runtime-plugin.js";
import { resolveAgentHarnessPolicy } from "../../agents/harness/selection.js";
import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js";
import { runWithModelFallback, isFallbackSummaryError } from "../../agents/model-fallback.js";
@@ -1523,6 +1524,22 @@ export async function runAgentTurnWithFallback(params: {
runId,
sessionId: params.followupRun.run.sessionId,
lane: runLane,
resolveAgentHarnessRuntimeOverride: (provider) =>
resolveSessionRuntimeOverrideForProvider({
provider,
entry: params.getActiveSessionEntry(),
}),
prepareAgentHarnessRuntime: async ({ provider, model, agentHarnessRuntimeOverride }) => {
await ensureSelectedAgentHarnessPlugin({
config: runtimeConfig,
provider,
modelId: model,
agentId: params.followupRun.run.agentId,
sessionKey: params.followupRun.run.runtimePolicySessionKey ?? params.sessionKey,
agentHarnessRuntimeOverride,
workspaceDir: params.followupRun.run.workspaceDir,
});
},
onFallbackStep: (step) => {
emitModelFallbackStepLifecycle({
runId,

View File

@@ -21,6 +21,7 @@ const runWithModelFallbackMock = vi.fn();
const runEmbeddedPiAgentMock = vi.fn();
const refreshQueuedFollowupSessionMock = vi.fn();
const incrementCompactionCountMock = vi.fn();
const ensureSelectedAgentHarnessPluginMock = vi.fn();
function registerMemoryFlushPlanResolverForTest(resolver: MemoryFlushPlanResolver): void {
registerMemoryCapability("memory-core", { flushPlanResolver: resolver });
@@ -44,7 +45,15 @@ type RefreshQueuedFollowupSessionParams = {
type ModelFallbackParams = {
provider?: string;
model?: string;
agentId?: string;
sessionKey?: string;
fallbacksOverride?: unknown[];
resolveAgentHarnessRuntimeOverride?: (provider: string, model: string) => string | undefined;
prepareAgentHarnessRuntime?: (params: {
provider: string;
model: string;
agentHarnessRuntimeOverride?: string;
}) => Promise<void> | void;
};
type EmbeddedPiAgentParams = {
@@ -132,6 +141,7 @@ describe("runMemoryFlushIfNeeded", () => {
});
runEmbeddedPiAgentMock.mockReset().mockResolvedValue({ payloads: [], meta: {} });
refreshQueuedFollowupSessionMock.mockReset();
ensureSelectedAgentHarnessPluginMock.mockReset().mockResolvedValue(undefined);
incrementCompactionCountMock.mockReset().mockImplementation(async (params) => {
const sessionKey = String(params.sessionKey ?? "");
if (!sessionKey || !params.sessionStore?.[sessionKey]) {
@@ -166,6 +176,7 @@ describe("runMemoryFlushIfNeeded", () => {
runEmbeddedPiAgent: runEmbeddedPiAgentMock as never,
refreshQueuedFollowupSession: refreshQueuedFollowupSessionMock as never,
incrementCompactionCount: incrementCompactionCountMock as never,
ensureSelectedAgentHarnessPlugin: ensureSelectedAgentHarnessPluginMock as never,
registerAgentRunContext: vi.fn() as never,
randomUUID: () => "00000000-0000-0000-0000-000000000001",
now: () => 1_700_000_000_000,
@@ -499,6 +510,69 @@ describe("runMemoryFlushIfNeeded", () => {
expect(agentCall.authProfileIdSource).toBeUndefined();
});
it("loads the selected harness before memory-flush fallback preflight", async () => {
const cfg = {
agents: {
defaults: {
compaction: {
memoryFlush: {},
},
},
},
};
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
totalTokens: 80_000,
compactionCount: 1,
agentRuntimeOverride: "codex",
};
const runtimePolicySessionKey = "agent:main:telegram:default:direct:12345";
await runMemoryFlushIfNeeded({
cfg,
followupRun: createTestFollowupRun({
agentId: "main",
sessionKey: "main",
runtimePolicySessionKey,
workspaceDir: "/workspace",
provider: "openai",
model: "gpt-5.4",
}),
sessionCtx: { Provider: "telegram" } as unknown as TemplateContext,
defaultModel: "openai/gpt-5.4",
agentCfgContextTokens: 100_000,
resolvedVerboseLevel: "off",
sessionEntry,
sessionStore: { main: sessionEntry },
sessionKey: "main",
runtimePolicySessionKey,
isHeartbeat: false,
replyOperation: createReplyOperation(),
});
const fallbackCall = requireModelFallbackCall();
expect(fallbackCall.agentId).toBe("main");
expect(fallbackCall.sessionKey).toBe(runtimePolicySessionKey);
expect(fallbackCall.resolveAgentHarnessRuntimeOverride?.("openai", "gpt-5.4")).toBe("codex");
await fallbackCall.prepareAgentHarnessRuntime?.({
provider: "openai",
model: "gpt-5.4",
agentHarnessRuntimeOverride: "codex",
});
expect(ensureSelectedAgentHarnessPluginMock).toHaveBeenCalledWith({
config: cfg,
provider: "openai",
modelId: "gpt-5.4",
agentId: "main",
sessionKey: runtimePolicySessionKey,
agentHarnessRuntimeOverride: "codex",
workspaceDir: "/workspace",
});
});
it("skips memory flush for CLI providers", async () => {
const sessionEntry: SessionEntry = {
sessionId: "session",

View File

@@ -4,7 +4,9 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js";
import { estimateMessagesTokens } from "../../agents/compaction.js";
import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js";
import { ensureSelectedAgentHarnessPlugin } from "../../agents/harness/runtime-plugin.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
import { listLegacyRuntimeModelProviderAliases } from "../../agents/model-runtime-aliases.js";
import { isCliProvider } from "../../agents/model-selection.js";
import { resolveContextConfigProviderForRuntime } from "../../agents/openai-codex-routing.js";
import { resolveSandboxConfigForAgent, resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
@@ -31,7 +33,10 @@ import { isAbortError } from "../../infra/unhandled-rejections.js";
import { resolveMemoryFlushPlan } from "../../plugins/memory-state.js";
import { CommandLane } from "../../process/lanes.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../../shared/string-coerce.js";
import type { TemplateContext } from "../templating.js";
import type { VerboseLevel } from "../thinking.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
@@ -83,6 +88,7 @@ async function runEmbeddedPiAgentDefault(
const memoryDeps = {
compactEmbeddedPiSession: compactEmbeddedPiSessionDefault,
runWithModelFallback,
ensureSelectedAgentHarnessPlugin,
runEmbeddedPiAgent: runEmbeddedPiAgentDefault,
registerAgentRunContext,
refreshQueuedFollowupSession,
@@ -95,6 +101,7 @@ const memoryDeps = {
export function setAgentRunnerMemoryTestDeps(overrides?: Partial<typeof memoryDeps>): void {
Object.assign(memoryDeps, {
runWithModelFallback,
ensureSelectedAgentHarnessPlugin,
compactEmbeddedPiSession: compactEmbeddedPiSessionDefault,
runEmbeddedPiAgent: runEmbeddedPiAgentDefault,
registerAgentRunContext,
@@ -165,6 +172,28 @@ function resolveMemoryFlushModelFallbackOptions(
};
}
function resolveMemoryFlushRuntimeOverrideForProvider(params: {
provider: string;
entry?: Pick<SessionEntry, "agentRuntimeOverride">;
}): string | undefined {
const provider = normalizeLowercaseStringOrEmpty(params.provider);
const runtime = normalizeLowercaseStringOrEmpty(params.entry?.agentRuntimeOverride);
if (!runtime || runtime === "auto" || runtime === "default") {
return undefined;
}
if (runtime === "pi") {
return "pi";
}
if (provider === "openai" && runtime === "codex") {
return "codex";
}
return listLegacyRuntimeModelProviderAliases().find(
(alias) =>
normalizeLowercaseStringOrEmpty(alias.provider) === provider &&
normalizeLowercaseStringOrEmpty(alias.runtime) === runtime,
)?.runtime;
}
function resolveFollowupContextConfigProvider(params: {
cfg: OpenClawConfig;
followupRun: FollowupRun;
@@ -1008,6 +1037,25 @@ export async function runMemoryFlushIfNeeded(params: {
runId: flushRunId,
sessionId: activeSessionEntry?.sessionId ?? params.followupRun.run.sessionId,
lane: CommandLane.Main,
resolveAgentHarnessRuntimeOverride: (provider) =>
resolveMemoryFlushRuntimeOverrideForProvider({
provider,
entry: activeSessionEntry,
}),
prepareAgentHarnessRuntime: async ({ provider, model, agentHarnessRuntimeOverride }) => {
await memoryDeps.ensureSelectedAgentHarnessPlugin({
config: params.cfg,
provider,
modelId: model,
agentId: params.followupRun.run.agentId,
sessionKey:
params.runtimePolicySessionKey ??
params.followupRun.run.runtimePolicySessionKey ??
params.sessionKey,
agentHarnessRuntimeOverride,
workspaceDir: params.followupRun.run.workspaceDir,
});
},
run: async (provider, model, runOptions) => {
const { embeddedContext, senderContext, runBaseParams } = buildEmbeddedRunExecutionParams({
run: params.followupRun.run,

View File

@@ -45,6 +45,8 @@ export function resolveModelFallbackOptions(
provider: run.provider,
model: run.model,
agentDir: run.agentDir,
agentId: run.agentId,
sessionKey: run.runtimePolicySessionKey ?? run.sessionKey,
fallbacksOverride,
};
}

View File

@@ -80,6 +80,8 @@ describe("agent-runner-utils", () => {
provider: run.provider,
model: run.model,
agentDir: run.agentDir,
agentId: run.agentId,
sessionKey: run.sessionKey,
fallbacksOverride: ["fallback-model"],
});
});

View File

@@ -9,6 +9,7 @@ import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-bu
import { getCliSessionBinding } from "../../agents/cli-session.js";
import { resolveContextTokensForModel } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { ensureSelectedAgentHarnessPlugin } from "../../agents/harness/runtime-plugin.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
import { resolveCliRuntimeExecutionProvider } from "../../agents/model-runtime-aliases.js";
import { isCliProvider } from "../../agents/model-selection-cli.js";
@@ -534,6 +535,22 @@ export function createFollowupRunner(params: {
...resolveModelFallbackOptions(run, runtimeConfig),
cfg: runtimeConfig,
runId,
resolveAgentHarnessRuntimeOverride: (provider) =>
resolveSessionRuntimeOverrideForProvider({
provider,
entry: activeSessionEntry,
}),
prepareAgentHarnessRuntime: async ({ provider, model, agentHarnessRuntimeOverride }) => {
await ensureSelectedAgentHarnessPlugin({
config: runtimeConfig,
provider,
modelId: model,
agentId: run.agentId,
sessionKey: run.runtimePolicySessionKey ?? replySessionKey,
agentHarnessRuntimeOverride,
workspaceDir: run.workspaceDir,
});
},
classifyResult: ({ result, provider, model }) =>
outcomePlan.classifyRunResult({ result, provider, model }),
run: async (provider, model, runOptions) => {

View File

@@ -4,6 +4,7 @@ export {
} from "../../agents/agent-scope.js";
export { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js";
export { resolveCronAgentLane } from "../../agents/lanes.js";
export { ensureSelectedAgentHarnessPlugin } from "../../agents/harness/runtime-plugin.js";
export { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js";
export { runWithModelFallback } from "../../agents/model-fallback.js";
export { isCliProvider } from "../../agents/model-selection-cli.js";

View File

@@ -15,6 +15,7 @@ import {
import { resolveCronPayloadOutcome } from "./helpers.js";
import {
getCliSessionId,
ensureSelectedAgentHarnessPlugin,
isCliProvider,
LiveSessionModelSwitchError,
logWarn,
@@ -167,6 +168,19 @@ export function createCronPromptExecutor(params: {
sessionId: params.cronSession.sessionEntry.sessionId,
lane: resolveCronAgentLane(params.lane),
agentDir: params.agentDir,
agentId: params.agentId,
sessionKey: params.runSessionKey,
prepareAgentHarnessRuntime: async ({ provider, model, agentHarnessRuntimeOverride }) => {
await ensureSelectedAgentHarnessPlugin({
config: params.cfgWithAgentDefaults,
provider,
modelId: model,
agentId: params.agentId,
sessionKey: params.runSessionKey,
agentHarnessRuntimeOverride,
workspaceDir: params.workspaceDir,
});
},
fallbacksOverride: cronFallbacksOverride,
run: async (providerOverride, modelOverride, runOptions) => {
if (params.abortSignal?.aborted) {