fix(agents): preserve compatible CLI session runtime pins

Preserves provider-compatible CLI runtime session pins across reply execution, follow-up execution, dispatch visibility, preflight compaction, and memory flush.

This keeps sessions pinned to compatible CLI runtimes such as `claude-cli` from leaking into embedded OpenClaw maintenance paths while still rejecting cross-provider runtime pins.

Original PR by @yu-xin-c; includes maintainer follow-up for the sibling memory paths.

Verification:
- `node scripts/run-vitest.mjs src/auto-reply/reply/agent-runner-execution.test.ts src/auto-reply/reply/agent-runner-memory.test.ts src/agents/model-runtime-aliases.test.ts --maxWorkers=1`
- autoreview clean
- Crabbox AWS `cbx_44400b494e97` / `coral-prawn`, run `run_69dd43475e39`: `check:changed` passed
- exact PR head CI green: `303b2f794f6c01fcf21b62b27c536b5f6eceb421`
This commit is contained in:
Stellar鱼
2026-06-14 06:54:51 +08:00
committed by GitHub
parent 924f4c1964
commit 9974641d1e
6 changed files with 185 additions and 4 deletions
@@ -1,6 +1,7 @@
// Tests agent runner execution setup, command args, and model fallback routing.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OAuthRefreshFailureError } from "../../agents/auth-profiles/oauth-refresh-failure.js";
import { testing as cliBackendsTesting } from "../../agents/cli-backends.js";
import { FailoverError } from "../../agents/failover-error.js";
import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js";
import { MissingProviderAuthError } from "../../agents/model-auth.js";
@@ -49,6 +50,10 @@ const GENERIC_RUN_FAILURE_TEXT =
"⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session.";
describe("resolveSessionRuntimeOverrideForProvider", () => {
afterEach(() => {
cliBackendsTesting.resetDepsForTest();
});
it("ignores unsupported session runtime pins", () => {
expect(
resolveSessionRuntimeOverrideForProvider({
@@ -57,6 +62,48 @@ describe("resolveSessionRuntimeOverrideForProvider", () => {
}),
).toBeUndefined();
});
it("keeps CLI runtime pins only when the runtime serves the selected provider", () => {
cliBackendsTesting.setDepsForTest({
resolveRuntimeCliBackends: () => [],
resolvePluginSetupCliBackend: ({ backend, config }) =>
backend === "claude-cli" && config
? {
pluginId: "anthropic",
backend: {
id: "claude-cli",
modelProvider: "anthropic",
config: { command: "claude" },
bundleMcp: false,
},
}
: undefined,
});
const cfg = {
agents: {
defaults: {
cliBackends: {
"claude-cli": { command: "claude" },
},
},
},
};
expect(
resolveSessionRuntimeOverrideForProvider({
provider: "anthropic",
entry: { agentRuntimeOverride: "claude-cli" },
cfg,
}),
).toBe("claude-cli");
expect(
resolveSessionRuntimeOverrideForProvider({
provider: "openai",
entry: { agentRuntimeOverride: "claude-cli" },
cfg,
}),
).toBeUndefined();
});
});
function makeTestModel(id: string, contextTokens: number): ModelDefinitionConfig {
+10 -1
View File
@@ -47,7 +47,10 @@ import { ensureSelectedAgentHarnessPlugin } from "../../agents/harness/runtime-p
import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js";
import { isMissingProviderAuthError } from "../../agents/model-auth.js";
import { runWithModelFallback, isFallbackSummaryError } from "../../agents/model-fallback.js";
import { resolveCliRuntimeExecutionProvider } from "../../agents/model-runtime-aliases.js";
import {
isCliRuntimeAliasForProvider,
resolveCliRuntimeExecutionProvider,
} from "../../agents/model-runtime-aliases.js";
import {
isCliProvider,
resolveModelRefFromString,
@@ -1420,6 +1423,7 @@ function emitModelFallbackStepLifecycle(params: {
export function resolveSessionRuntimeOverrideForProvider(params: {
provider: string;
entry?: Pick<SessionEntry, "agentRuntimeOverride">;
cfg?: OpenClawConfig;
}): string | undefined {
const provider = normalizeLowercaseStringOrEmpty(params.provider);
const runtime = normalizeLowercaseStringOrEmpty(params.entry?.agentRuntimeOverride);
@@ -1429,6 +1433,9 @@ export function resolveSessionRuntimeOverrideForProvider(params: {
if (provider === "openai" && runtime === "codex") {
return "codex";
}
if (isCliRuntimeAliasForProvider({ provider, runtime, cfg: params.cfg })) {
return runtime;
}
return undefined;
}
@@ -2020,6 +2027,7 @@ export async function runAgentTurnWithFallback(params: {
resolveSessionRuntimeOverrideForProvider({
provider,
entry: params.getActiveSessionEntry(),
cfg: runtimeConfig,
}),
prepareAgentHarnessRuntime: async ({ provider, model, agentHarnessRuntimeOverride }) => {
await agentTurnTiming.measure("fallback_prepare_harness", () =>
@@ -2104,6 +2112,7 @@ export async function runAgentTurnWithFallback(params: {
const resolvedSessionRuntimeOverride = resolveSessionRuntimeOverrideForProvider({
provider,
entry: params.getActiveSessionEntry(),
cfg: runtimeConfig,
});
const resolvedSelectedAuthProfile = resolveRunAuthProfile(candidateRun, provider, {
config: runtimeConfig,
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { testing as cliBackendsTesting } from "../../agents/cli-backends.js";
import type { SessionEntry } from "../../config/sessions.js";
import {
clearMemoryPluginState,
@@ -226,6 +227,7 @@ describe("runMemoryFlushIfNeeded", () => {
afterEach(async () => {
setAgentRunnerMemoryTestDeps();
cliBackendsTesting.resetDepsForTest();
clearMemoryPluginState();
await fs.rm(rootDir, { recursive: true, force: true });
});
@@ -912,6 +914,46 @@ describe("runMemoryFlushIfNeeded", () => {
expect(runEmbeddedAgentMock).not.toHaveBeenCalled();
});
it("skips memory flush for compatible CLI session runtime pins", async () => {
cliBackendsTesting.setDepsForTest({
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
modelProvider: "anthropic",
pluginId: "anthropic",
config: { command: "claude" },
},
],
});
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
totalTokens: 80_000,
compactionCount: 1,
agentRuntimeOverride: "claude-cli",
};
const entry = await runMemoryFlushIfNeeded({
cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } },
followupRun: createTestFollowupRun({
provider: "anthropic",
model: "claude-opus-4-6",
}),
sessionCtx: { Provider: "whatsapp" } as unknown as TemplateContext,
defaultModel: "anthropic/claude-opus-4-6",
agentCfgContextTokens: 100_000,
resolvedVerboseLevel: "off",
sessionEntry,
sessionStore: { main: sessionEntry },
sessionKey: "main",
isHeartbeat: false,
replyOperation: createReplyOperation(),
});
expect(entry).toBe(sessionEntry);
expect(runEmbeddedAgentMock).not.toHaveBeenCalled();
});
it("uses runtime policy session key when checking memory-flush sandbox writability", async () => {
const sessionEntry: SessionEntry = {
sessionId: "session",
@@ -1619,6 +1661,61 @@ describe("runMemoryFlushIfNeeded", () => {
expect(compactEmbeddedAgentSessionMock).not.toHaveBeenCalled();
});
it("skips preflight compaction for compatible CLI session runtime pins", async () => {
cliBackendsTesting.setDepsForTest({
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
modelProvider: "anthropic",
pluginId: "anthropic",
config: { command: "claude" },
},
],
});
registerMemoryFlushPlanResolverForTest(() => ({
softThresholdTokens: 4_000,
forceFlushTranscriptBytes: 1_000_000_000,
reserveTokensFloor: 0,
prompt: "Pre-compaction memory flush.\nNO_REPLY",
systemPrompt: "Write memory to memory/YYYY-MM-DD.md.",
relativePath: "memory/2023-11-14.md",
}));
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
totalTokens: 347_000,
totalTokensFresh: true,
agentRuntimeOverride: "claude-cli",
};
const entry = await runPreflightCompactionIfNeeded({
cfg: {
models: {
providers: {
anthropic: { models: [{ id: "claude-opus-4-6", contextWindow: 350_000 }] },
},
},
agents: { defaults: { compaction: { memoryFlush: {} } } },
} as never,
followupRun: createTestFollowupRun({
provider: "anthropic",
model: "claude-opus-4-6",
sessionId: "session",
sessionKey: "main",
}),
defaultModel: "anthropic/claude-opus-4-6",
sessionEntry,
sessionStore: { main: sessionEntry },
sessionKey: "main",
storePath: path.join(rootDir, "sessions.json"),
isHeartbeat: false,
replyOperation: createReplyOperation(),
});
expect(entry).toBe(sessionEntry);
expect(compactEmbeddedAgentSessionMock).not.toHaveBeenCalled();
});
it("keeps the OpenAI API context window for persisted OpenClaw runtime overrides", async () => {
registerMemoryFlushPlanResolverForTest(() => ({
softThresholdTokens: 4_000,
+28 -3
View File
@@ -12,6 +12,7 @@ import { classifyCompactionReason } from "../../agents/embedded-agent-runner/com
import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js";
import { ensureSelectedAgentHarnessPlugin } from "../../agents/harness/runtime-plugin.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
import { isCliRuntimeAliasForProvider } from "../../agents/model-runtime-aliases.js";
import { isCliProvider } from "../../agents/model-selection.js";
import { resolveContextConfigProviderForRuntime } from "../../agents/openai-routing.js";
import type { AgentMessage } from "../../agents/runtime/index.js";
@@ -235,6 +236,22 @@ function resolveMemoryFlushRuntimeOverrideForProvider(params: {
return undefined;
}
function followupUsesCliRuntime(params: {
cfg: OpenClawConfig;
followupRun: FollowupRun;
sessionEntry?: Pick<SessionEntry, "agentRuntimeOverride">;
}): boolean {
const provider = params.followupRun.run.provider;
if (isCliProvider(provider, params.cfg)) {
return true;
}
return isCliRuntimeAliasForProvider({
provider,
runtime: params.sessionEntry?.agentRuntimeOverride,
cfg: params.cfg,
});
}
function resolveFollowupContextConfigProvider(params: {
cfg: OpenClawConfig;
followupRun: FollowupRun;
@@ -709,7 +726,11 @@ export async function runPreflightCompactionIfNeeded(params: {
return entry ?? params.sessionEntry;
}
const isCli = isCliProvider(params.followupRun.run.provider, params.cfg);
const isCli = followupUsesCliRuntime({
cfg: params.cfg,
followupRun: params.followupRun,
sessionEntry: entry,
});
if (params.isHeartbeat || isCli) {
return entry ?? params.sessionEntry;
}
@@ -1026,11 +1047,15 @@ export async function runMemoryFlushIfNeeded(params: {
return sandboxCfg.workspaceAccess === "rw";
})();
const isCli = isCliProvider(params.followupRun.run.provider, params.cfg);
const canAttemptFlush = memoryFlushWritable && !params.isHeartbeat && !isCli;
let entry =
params.sessionEntry ??
(params.sessionKey ? params.sessionStore?.[params.sessionKey] : undefined);
const isCli = followupUsesCliRuntime({
cfg: params.cfg,
followupRun: params.followupRun,
sessionEntry: entry,
});
const canAttemptFlush = memoryFlushWritable && !params.isHeartbeat && !isCli;
const contextWindowTokens = resolveMemoryFlushContextWindowTokens({
cfg: params.cfg,
provider: resolveFollowupContextConfigProvider({
@@ -647,6 +647,7 @@ const resolveHarnessSourceVisibleRepliesDefault = (params: {
const agentHarnessRuntimeOverride = resolveSessionRuntimeOverrideForProvider({
provider: candidate.provider,
entry: params.entry,
cfg: params.cfg,
});
const harness = selectAgentHarness({
provider: candidate.provider,
+2
View File
@@ -803,6 +803,7 @@ export function createFollowupRunner(params: {
resolveSessionRuntimeOverrideForProvider({
provider,
entry: activeSessionEntry,
cfg: runtimeConfig,
}),
prepareAgentHarnessRuntime: async ({ provider, model, agentHarnessRuntimeOverride }) => {
await ensureSelectedAgentHarnessPlugin({
@@ -837,6 +838,7 @@ export function createFollowupRunner(params: {
const sessionRuntimeOverride = resolveSessionRuntimeOverrideForProvider({
provider,
entry: activeSessionEntry,
cfg: runtimeConfig,
});
const cliExecutionProvider =
(sessionRuntimeOverride && isCliProvider(sessionRuntimeOverride, runtimeConfig)