mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(agents): fail closed on missing Codex harness
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
13
src/agents/harness/errors.ts
Normal file
13
src/agents/harness/errors.ts
Normal 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;
|
||||
}
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user