Skip to content

Commit 1713839

Browse files
committed
fix: pin embedded harness selection per session
1 parent 7248a77 commit 1713839

23 files changed

Lines changed: 518 additions & 11 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai
5858
- Plugins/install: add newly installed plugin ids to an existing `plugins.allow` list before enabling them, so allowlisted configs load installed plugins after restart.
5959
- Status: show `Fast` in `/status` when fast mode is enabled, including config/default-derived fast mode, and omit it when disabled.
6060
- OpenAI/image generation: detect Azure OpenAI-style image endpoints, use Azure `api-key` auth plus deployment-scoped image URLs, and honor `AZURE_OPENAI_API_VERSION` so image generation and edits work against Azure-hosted OpenAI resources. (#70570) Thanks @zhanggpcsu.
61+
- Codex harness/status: pin embedded harness selection per session, show active non-PI harness ids such as `codex` in `/status`, and keep legacy transcripts on PI until `/new` or `/reset` so config changes cannot hot-switch existing sessions.
6162
- Models/auth: merge provider-owned default-model additions from `openclaw models auth login` instead of replacing `agents.defaults.models`, so re-authenticating an OAuth provider such as OpenAI Codex no longer wipes other providers' aliases and per-model params. Migrations that must rename keys (Anthropic -> Claude CLI) opt in with `replaceDefaultModels`. Fixes #69414. (#70435) Thanks @neeravmakwana.
6263
- Media understanding/audio: prefer configured or key-backed STT providers before auto-detected local Whisper CLIs, so installed local transcription tools no longer shadow API providers such as Groq/OpenAI in `tools.media.audio` auto mode. Fixes #68727.
6364
- Providers/OpenAI: lock the auth picker wording for OpenAI API key, Codex browser login, and Codex device pairing so the setup choices no longer imply a mixed Codex/API-key auth path. (#67848) Thanks @tmlxrd.

docs/gateway/configuration-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1271,6 +1271,7 @@ Codex app-server harness.
12711271
- `fallback`: `"pi"` or `"none"`. `"pi"` keeps the built-in PI harness as the compatibility fallback when no plugin harness is selected. `"none"` makes missing or unsupported plugin harness selection fail instead of silently using PI. Selected plugin harness failures always surface directly.
12721272
- Environment overrides: `OPENCLAW_AGENT_RUNTIME=<id|auto|pi>` overrides `runtime`; `OPENCLAW_AGENT_HARNESS_FALLBACK=none` disables PI fallback for that process.
12731273
- For Codex-only deployments, set `model: "codex/gpt-5.4"`, `embeddedHarness.runtime: "codex"`, and `embeddedHarness.fallback: "none"`.
1274+
- Harness choice is pinned per session id after the first embedded run. Config/env changes affect new or reset sessions, not an existing transcript. Legacy sessions with transcript history but no recorded pin are treated as PI-pinned. `/status` shows non-PI harness ids such as `codex` next to `Fast`.
12741275
- This only controls the embedded chat harness. Media generation, vision, PDF, music, video, and TTS still use their provider/model settings.
12751276

12761277
**Built-in alias shorthands** (only apply when the model is in `agents.defaults.models`):

docs/plugins/codex-harness.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,21 @@ The Codex harness only claims `codex/*` model refs. Existing `openai/*`,
5151
`openai-codex/*`, Anthropic, Gemini, xAI, local, and custom provider refs keep
5252
their normal paths.
5353

54+
Harness selection is not a live session control. When an embedded turn runs,
55+
OpenClaw records the selected harness id on that session and keeps using it for
56+
later turns in the same session id. Change `embeddedHarness` config or
57+
`OPENCLAW_AGENT_RUNTIME` when you want future sessions to use another harness;
58+
use `/new` or `/reset` to start a fresh session before switching an existing
59+
conversation between PI and Codex. This avoids replaying one transcript through
60+
two incompatible native session systems.
61+
62+
Legacy sessions created before harness pins are treated as PI-pinned once they
63+
have transcript history. Use `/new` or `/reset` to opt that conversation into
64+
Codex after changing config.
65+
66+
`/status` shows the effective non-PI harness next to `Fast`, for example
67+
`Fast · codex`. The default PI harness is omitted.
68+
5469
## Requirements
5570

5671
- OpenClaw with the bundled `codex` plugin available.
@@ -218,7 +233,8 @@ auto-selection:
218233

219234
Use normal session commands to switch agents and models. `/new` creates a fresh
220235
OpenClaw session and the Codex harness creates or resumes its sidecar app-server
221-
thread as needed. `/reset` clears the OpenClaw session binding for that thread.
236+
thread as needed. `/reset` clears the OpenClaw session binding for that thread
237+
and lets the next turn resolve the harness from current config again.
222238

223239
## Model discovery
224240

docs/plugins/sdk-agent-harness.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,14 @@ export default definePluginEntry({
8787

8888
OpenClaw chooses a harness after provider/model resolution:
8989

90-
1. `OPENCLAW_AGENT_RUNTIME=<id>` forces a registered harness with that id.
91-
2. `OPENCLAW_AGENT_RUNTIME=pi` forces the built-in PI harness.
92-
3. `OPENCLAW_AGENT_RUNTIME=auto` asks registered harnesses if they support the
90+
1. An existing session's recorded harness id wins, so config/env changes do not
91+
hot-switch that transcript to another runtime.
92+
2. `OPENCLAW_AGENT_RUNTIME=<id>` forces a registered harness with that id for
93+
sessions that are not already pinned.
94+
3. `OPENCLAW_AGENT_RUNTIME=pi` forces the built-in PI harness.
95+
4. `OPENCLAW_AGENT_RUNTIME=auto` asks registered harnesses if they support the
9396
resolved provider/model.
94-
4. If no registered harness matches, OpenClaw uses PI unless PI fallback is
97+
5. If no registered harness matches, OpenClaw uses PI unless PI fallback is
9598
disabled.
9699

97100
Plugin harness failures surface as run failures. In `auto` mode, PI fallback is
@@ -100,6 +103,12 @@ provider/model. Once a plugin harness has claimed a run, OpenClaw does not
100103
replay that same turn through PI because that can change auth/runtime semantics
101104
or duplicate side effects.
102105

106+
The selected harness id is persisted with the session id after an embedded run.
107+
Legacy sessions created before harness pins are treated as PI-pinned once they
108+
have transcript history. Use a new/reset session when changing between PI and a
109+
native plugin harness. `/status` shows non-default harness ids such as `codex`
110+
next to `Fast`; PI stays hidden because it is the default compatibility path.
111+
103112
The bundled Codex plugin registers `codex` as its harness id. Core treats that
104113
as an ordinary plugin harness id; Codex-specific aliases belong in the plugin
105114
or operator config, not in the shared runtime selector.

src/agents/command/attempt-execution.cli.test.ts

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
55
import type { SessionEntry } from "../../config/sessions.js";
66
import type { OpenClawConfig } from "../../config/types.openclaw.js";
77
import { FailoverError } from "../failover-error.js";
8-
import type { EmbeddedPiRunResult } from "../pi-embedded.js";
8+
import { runEmbeddedPiAgent, type EmbeddedPiRunResult } from "../pi-embedded.js";
99
import { persistCliTurnTranscript, runAgentAttempt } from "./attempt-execution.js";
1010

1111
const runCliAgentMock = vi.hoisted(() => vi.fn());
12+
const runEmbeddedPiAgentMock = vi.hoisted(() => vi.fn());
1213
const ORIGINAL_HOME = process.env.HOME;
1314

1415
vi.mock("../cli-runner.js", () => ({
@@ -21,7 +22,7 @@ vi.mock("../model-selection.js", () => ({
2122
}));
2223

2324
vi.mock("../pi-embedded.js", () => ({
24-
runEmbeddedPiAgent: vi.fn(),
25+
runEmbeddedPiAgent: runEmbeddedPiAgentMock,
2526
}));
2627

2728
function makeCliResult(text: string): EmbeddedPiRunResult {
@@ -73,6 +74,7 @@ describe("CLI attempt execution", () => {
7374
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-attempt-"));
7475
storePath = path.join(tmpDir, "sessions.json");
7576
runCliAgentMock.mockReset();
77+
runEmbeddedPiAgentMock.mockReset();
7678
});
7779

7880
afterEach(async () => {
@@ -386,3 +388,102 @@ describe("CLI attempt execution", () => {
386388
);
387389
});
388390
});
391+
392+
describe("embedded attempt harness pinning", () => {
393+
let tmpDir: string;
394+
395+
beforeEach(async () => {
396+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-embedded-attempt-"));
397+
runEmbeddedPiAgentMock.mockReset();
398+
});
399+
400+
afterEach(async () => {
401+
await fs.rm(tmpDir, { recursive: true, force: true });
402+
});
403+
404+
it("treats legacy sessions with history as PI-pinned", async () => {
405+
const sessionEntry: SessionEntry = {
406+
sessionId: "legacy-session",
407+
updatedAt: Date.now(),
408+
};
409+
runEmbeddedPiAgentMock.mockResolvedValueOnce({
410+
meta: { durationMs: 1 },
411+
} satisfies EmbeddedPiRunResult);
412+
413+
await runAgentAttempt({
414+
providerOverride: "openai",
415+
modelOverride: "gpt-5.4",
416+
cfg: {} as OpenClawConfig,
417+
sessionEntry,
418+
sessionId: sessionEntry.sessionId,
419+
sessionKey: "agent:main:main",
420+
sessionAgentId: "main",
421+
sessionFile: path.join(tmpDir, "session.jsonl"),
422+
workspaceDir: tmpDir,
423+
body: "continue",
424+
isFallbackRetry: false,
425+
resolvedThinkLevel: "medium",
426+
timeoutMs: 1_000,
427+
runId: "run-legacy-pi-pin",
428+
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
429+
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
430+
spawnedBy: undefined,
431+
messageChannel: undefined,
432+
skillsSnapshot: undefined,
433+
resolvedVerboseLevel: undefined,
434+
agentDir: tmpDir,
435+
onAgentEvent: vi.fn(),
436+
authProfileProvider: "openai",
437+
sessionHasHistory: true,
438+
});
439+
440+
expect(runEmbeddedPiAgent).toHaveBeenCalledWith(
441+
expect.objectContaining({
442+
agentHarnessId: "pi",
443+
}),
444+
);
445+
});
446+
447+
it("leaves a fresh unpinned session on config-selected harness resolution", async () => {
448+
const sessionEntry: SessionEntry = {
449+
sessionId: "fresh-session",
450+
updatedAt: Date.now(),
451+
};
452+
runEmbeddedPiAgentMock.mockResolvedValueOnce({
453+
meta: { durationMs: 1 },
454+
} satisfies EmbeddedPiRunResult);
455+
456+
await runAgentAttempt({
457+
providerOverride: "openai",
458+
modelOverride: "gpt-5.4",
459+
cfg: {} as OpenClawConfig,
460+
sessionEntry,
461+
sessionId: sessionEntry.sessionId,
462+
sessionKey: "agent:main:main",
463+
sessionAgentId: "main",
464+
sessionFile: path.join(tmpDir, "session.jsonl"),
465+
workspaceDir: tmpDir,
466+
body: "start",
467+
isFallbackRetry: false,
468+
resolvedThinkLevel: "medium",
469+
timeoutMs: 1_000,
470+
runId: "run-fresh-no-pin",
471+
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
472+
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
473+
spawnedBy: undefined,
474+
messageChannel: undefined,
475+
skillsSnapshot: undefined,
476+
resolvedVerboseLevel: undefined,
477+
agentDir: tmpDir,
478+
onAgentEvent: vi.fn(),
479+
authProfileProvider: "openai",
480+
sessionHasHistory: false,
481+
});
482+
483+
expect(runEmbeddedPiAgent).toHaveBeenCalledWith(
484+
expect.objectContaining({
485+
agentHarnessId: undefined,
486+
}),
487+
);
488+
});
489+
});

src/agents/command/attempt-execution.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,10 @@ export function runAgentAttempt(params: {
262262
);
263263
const bootstrapPromptWarningSignature =
264264
bootstrapPromptWarningSignaturesSeen[bootstrapPromptWarningSignaturesSeen.length - 1];
265+
const sessionPinnedAgentHarnessId =
266+
params.sessionEntry?.sessionId === params.sessionId
267+
? (params.sessionEntry.agentHarnessId ?? (params.sessionHasHistory ? "pi" : undefined))
268+
: undefined;
265269
const authProfileId =
266270
params.providerOverride === params.authProfileProvider
267271
? params.sessionEntry?.authProfileOverride
@@ -407,6 +411,7 @@ export function runAgentAttempt(params: {
407411
sessionFile: params.sessionFile,
408412
workspaceDir: params.workspaceDir,
409413
config: params.cfg,
414+
agentHarnessId: sessionPinnedAgentHarnessId,
410415
skillsSnapshot: params.skillsSnapshot,
411416
prompt: effectivePrompt,
412417
images: params.isFallbackRetry ? undefined : params.opts.images,

src/agents/command/session-store.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,99 @@ async function withTempSessionStore<T>(
144144
}
145145

146146
describe("updateSessionStoreAfterAgentRun", () => {
147+
it("persists the selected embedded harness id on the session", async () => {
148+
await withTempSessionStore(async ({ storePath }) => {
149+
const cfg = {} as OpenClawConfig;
150+
const sessionKey = "agent:main:explicit:test-harness-pin";
151+
const sessionId = "test-harness-pin-session";
152+
const sessionStore: Record<string, SessionEntry> = {
153+
[sessionKey]: {
154+
sessionId,
155+
updatedAt: 1,
156+
},
157+
};
158+
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
159+
160+
const result: EmbeddedPiRunResult = {
161+
meta: {
162+
durationMs: 1,
163+
agentMeta: {
164+
sessionId,
165+
provider: "openai",
166+
model: "gpt-5.4",
167+
agentHarnessId: "codex",
168+
},
169+
},
170+
};
171+
172+
await updateSessionStoreAfterAgentRun({
173+
cfg,
174+
sessionId,
175+
sessionKey,
176+
storePath,
177+
sessionStore,
178+
defaultProvider: "openai",
179+
defaultModel: "gpt-5.4",
180+
result,
181+
});
182+
183+
expect(sessionStore[sessionKey]?.agentHarnessId).toBe("codex");
184+
expect(loadSessionStore(storePath)[sessionKey]?.agentHarnessId).toBe("codex");
185+
});
186+
});
187+
188+
it("clears the embedded harness pin after a CLI run", async () => {
189+
await withTempSessionStore(async ({ storePath }) => {
190+
const cfg = {
191+
agents: {
192+
defaults: {
193+
cliBackends: {
194+
"claude-cli": {
195+
command: "claude",
196+
},
197+
},
198+
},
199+
},
200+
} as OpenClawConfig;
201+
const sessionKey = "agent:main:explicit:test-harness-pin-cli";
202+
const sessionId = "test-harness-pin-cli-session";
203+
const sessionStore: Record<string, SessionEntry> = {
204+
[sessionKey]: {
205+
sessionId,
206+
updatedAt: 1,
207+
agentHarnessId: "codex",
208+
},
209+
};
210+
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
211+
212+
const result: EmbeddedPiRunResult = {
213+
meta: {
214+
durationMs: 1,
215+
executionTrace: { runner: "cli" },
216+
agentMeta: {
217+
sessionId: "cli-session-123",
218+
provider: "claude-cli",
219+
model: "claude-sonnet-4-6",
220+
},
221+
},
222+
};
223+
224+
await updateSessionStoreAfterAgentRun({
225+
cfg,
226+
sessionId,
227+
sessionKey,
228+
storePath,
229+
sessionStore,
230+
defaultProvider: "claude-cli",
231+
defaultModel: "claude-sonnet-4-6",
232+
result,
233+
});
234+
235+
expect(sessionStore[sessionKey]?.agentHarnessId).toBeUndefined();
236+
expect(loadSessionStore(storePath)[sessionKey]?.agentHarnessId).toBeUndefined();
237+
});
238+
});
239+
147240
it("persists claude-cli session bindings when the backend is configured", async () => {
148241
await withTempSessionStore(async ({ storePath }) => {
149242
const cfg = {

src/agents/command/session-store.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
updateSessionStore,
66
} from "../../config/sessions.js";
77
import type { OpenClawConfig } from "../../config/types.openclaw.js";
8+
import { normalizeOptionalString } from "../../shared/string-coerce.js";
89
import { clearCliSession, setCliSessionBinding, setCliSessionId } from "../cli-session.js";
910
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
1011
import { isCliProvider } from "../model-selection.js";
@@ -60,6 +61,7 @@ export async function updateSessionStoreAfterAgentRun(params: {
6061
const compactionsThisRun = Math.max(0, result.meta.agentMeta?.compactionCount ?? 0);
6162
const modelUsed = result.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
6263
const providerUsed = result.meta.agentMeta?.provider ?? fallbackProvider ?? defaultProvider;
64+
const agentHarnessId = normalizeOptionalString(result.meta.agentMeta?.agentHarnessId);
6365
const contextTokens =
6466
typeof params.contextTokensOverride === "number" && params.contextTokensOverride > 0
6567
? params.contextTokensOverride
@@ -85,6 +87,11 @@ export async function updateSessionStoreAfterAgentRun(params: {
8587
provider: providerUsed,
8688
model: modelUsed,
8789
});
90+
if (agentHarnessId) {
91+
next.agentHarnessId = agentHarnessId;
92+
} else if (result.meta.executionTrace?.runner === "cli") {
93+
next.agentHarnessId = undefined;
94+
}
8895
if (isCliProvider(providerUsed, cfg)) {
8996
const cliSessionBinding = result.meta.agentMeta?.cliSessionBinding;
9097
if (cliSessionBinding?.sessionId?.trim()) {

0 commit comments

Comments
 (0)