diff --git a/CHANGELOG.md b/CHANGELOG.md index 29492b7f310..9d4fb49d8d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai - Plugins/install: add newly installed plugin ids to an existing `plugins.allow` list before enabling them, so allowlisted configs load installed plugins after restart. - Status: show `Fast` in `/status` when fast mode is enabled, including config/default-derived fast mode, and omit it when disabled. - 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. +- 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. - 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. - 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. - 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. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index a05e3871342..0890d6e50c9 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1271,6 +1271,7 @@ Codex app-server harness. - `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. - Environment overrides: `OPENCLAW_AGENT_RUNTIME=` overrides `runtime`; `OPENCLAW_AGENT_HARNESS_FALLBACK=none` disables PI fallback for that process. - For Codex-only deployments, set `model: "codex/gpt-5.4"`, `embeddedHarness.runtime: "codex"`, and `embeddedHarness.fallback: "none"`. +- 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`. - This only controls the embedded chat harness. Media generation, vision, PDF, music, video, and TTS still use their provider/model settings. **Built-in alias shorthands** (only apply when the model is in `agents.defaults.models`): diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index bcf000a8909..678109fa467 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -51,6 +51,21 @@ The Codex harness only claims `codex/*` model refs. Existing `openai/*`, `openai-codex/*`, Anthropic, Gemini, xAI, local, and custom provider refs keep their normal paths. +Harness selection is not a live session control. When an embedded turn runs, +OpenClaw records the selected harness id on that session and keeps using it for +later turns in the same session id. Change `embeddedHarness` config or +`OPENCLAW_AGENT_RUNTIME` when you want future sessions to use another harness; +use `/new` or `/reset` to start a fresh session before switching an existing +conversation between PI and Codex. This avoids replaying one transcript through +two incompatible native session systems. + +Legacy sessions created before harness pins are treated as PI-pinned once they +have transcript history. Use `/new` or `/reset` to opt that conversation into +Codex after changing config. + +`/status` shows the effective non-PI harness next to `Fast`, for example +`Fast 路 codex`. The default PI harness is omitted. + ## Requirements - OpenClaw with the bundled `codex` plugin available. @@ -218,7 +233,8 @@ auto-selection: Use normal session commands to switch agents and models. `/new` creates a fresh OpenClaw session and the Codex harness creates or resumes its sidecar app-server -thread as needed. `/reset` clears the OpenClaw session binding for that thread. +thread as needed. `/reset` clears the OpenClaw session binding for that thread +and lets the next turn resolve the harness from current config again. ## Model discovery diff --git a/docs/plugins/sdk-agent-harness.md b/docs/plugins/sdk-agent-harness.md index 455d50ed53d..e654ca1d640 100644 --- a/docs/plugins/sdk-agent-harness.md +++ b/docs/plugins/sdk-agent-harness.md @@ -87,11 +87,14 @@ export default definePluginEntry({ OpenClaw chooses a harness after provider/model resolution: -1. `OPENCLAW_AGENT_RUNTIME=` forces a registered harness with that id. -2. `OPENCLAW_AGENT_RUNTIME=pi` forces the built-in PI harness. -3. `OPENCLAW_AGENT_RUNTIME=auto` asks registered harnesses if they support the +1. An existing session's recorded harness id wins, so config/env changes do not + hot-switch that transcript to another runtime. +2. `OPENCLAW_AGENT_RUNTIME=` forces a registered harness with that id for + sessions that are not already pinned. +3. `OPENCLAW_AGENT_RUNTIME=pi` forces the built-in PI harness. +4. `OPENCLAW_AGENT_RUNTIME=auto` asks registered harnesses if they support the resolved provider/model. -4. If no registered harness matches, OpenClaw uses PI unless PI fallback is +5. If no registered harness matches, OpenClaw uses PI unless PI fallback is disabled. 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 replay that same turn through PI because that can change auth/runtime semantics or duplicate side effects. +The selected harness id is persisted with the session id after an embedded run. +Legacy sessions created before harness pins are treated as PI-pinned once they +have transcript history. Use a new/reset session when changing between PI and a +native plugin harness. `/status` shows non-default harness ids such as `codex` +next to `Fast`; PI stays hidden because it is the default compatibility path. + The bundled Codex plugin registers `codex` as its harness id. Core treats that as an ordinary plugin harness id; Codex-specific aliases belong in the plugin or operator config, not in the shared runtime selector. diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index a4061e8139d..1db013229c5 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -5,10 +5,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { FailoverError } from "../failover-error.js"; -import type { EmbeddedPiRunResult } from "../pi-embedded.js"; +import { runEmbeddedPiAgent, type EmbeddedPiRunResult } from "../pi-embedded.js"; import { persistCliTurnTranscript, runAgentAttempt } from "./attempt-execution.js"; const runCliAgentMock = vi.hoisted(() => vi.fn()); +const runEmbeddedPiAgentMock = vi.hoisted(() => vi.fn()); const ORIGINAL_HOME = process.env.HOME; vi.mock("../cli-runner.js", () => ({ @@ -21,7 +22,7 @@ vi.mock("../model-selection.js", () => ({ })); vi.mock("../pi-embedded.js", () => ({ - runEmbeddedPiAgent: vi.fn(), + runEmbeddedPiAgent: runEmbeddedPiAgentMock, })); function makeCliResult(text: string): EmbeddedPiRunResult { @@ -73,6 +74,7 @@ describe("CLI attempt execution", () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-attempt-")); storePath = path.join(tmpDir, "sessions.json"); runCliAgentMock.mockReset(); + runEmbeddedPiAgentMock.mockReset(); }); afterEach(async () => { @@ -386,3 +388,102 @@ describe("CLI attempt execution", () => { ); }); }); + +describe("embedded attempt harness pinning", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-embedded-attempt-")); + runEmbeddedPiAgentMock.mockReset(); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("treats legacy sessions with history as PI-pinned", async () => { + const sessionEntry: SessionEntry = { + sessionId: "legacy-session", + updatedAt: Date.now(), + }; + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + meta: { durationMs: 1 }, + } satisfies EmbeddedPiRunResult); + + await runAgentAttempt({ + providerOverride: "openai", + modelOverride: "gpt-5.4", + cfg: {} as OpenClawConfig, + sessionEntry, + sessionId: sessionEntry.sessionId, + sessionKey: "agent:main:main", + sessionAgentId: "main", + sessionFile: path.join(tmpDir, "session.jsonl"), + workspaceDir: tmpDir, + body: "continue", + isFallbackRetry: false, + resolvedThinkLevel: "medium", + timeoutMs: 1_000, + runId: "run-legacy-pi-pin", + opts: { senderIsOwner: false } as Parameters[0]["opts"], + runContext: {} as Parameters[0]["runContext"], + spawnedBy: undefined, + messageChannel: undefined, + skillsSnapshot: undefined, + resolvedVerboseLevel: undefined, + agentDir: tmpDir, + onAgentEvent: vi.fn(), + authProfileProvider: "openai", + sessionHasHistory: true, + }); + + expect(runEmbeddedPiAgent).toHaveBeenCalledWith( + expect.objectContaining({ + agentHarnessId: "pi", + }), + ); + }); + + it("leaves a fresh unpinned session on config-selected harness resolution", async () => { + const sessionEntry: SessionEntry = { + sessionId: "fresh-session", + updatedAt: Date.now(), + }; + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + meta: { durationMs: 1 }, + } satisfies EmbeddedPiRunResult); + + await runAgentAttempt({ + providerOverride: "openai", + modelOverride: "gpt-5.4", + cfg: {} as OpenClawConfig, + sessionEntry, + sessionId: sessionEntry.sessionId, + sessionKey: "agent:main:main", + sessionAgentId: "main", + sessionFile: path.join(tmpDir, "session.jsonl"), + workspaceDir: tmpDir, + body: "start", + isFallbackRetry: false, + resolvedThinkLevel: "medium", + timeoutMs: 1_000, + runId: "run-fresh-no-pin", + opts: { senderIsOwner: false } as Parameters[0]["opts"], + runContext: {} as Parameters[0]["runContext"], + spawnedBy: undefined, + messageChannel: undefined, + skillsSnapshot: undefined, + resolvedVerboseLevel: undefined, + agentDir: tmpDir, + onAgentEvent: vi.fn(), + authProfileProvider: "openai", + sessionHasHistory: false, + }); + + expect(runEmbeddedPiAgent).toHaveBeenCalledWith( + expect.objectContaining({ + agentHarnessId: undefined, + }), + ); + }); +}); diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index ef5e0b2ac4c..bcd0dd82834 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -262,6 +262,10 @@ export function runAgentAttempt(params: { ); const bootstrapPromptWarningSignature = bootstrapPromptWarningSignaturesSeen[bootstrapPromptWarningSignaturesSeen.length - 1]; + const sessionPinnedAgentHarnessId = + params.sessionEntry?.sessionId === params.sessionId + ? (params.sessionEntry.agentHarnessId ?? (params.sessionHasHistory ? "pi" : undefined)) + : undefined; const authProfileId = params.providerOverride === params.authProfileProvider ? params.sessionEntry?.authProfileOverride @@ -407,6 +411,7 @@ export function runAgentAttempt(params: { sessionFile: params.sessionFile, workspaceDir: params.workspaceDir, config: params.cfg, + agentHarnessId: sessionPinnedAgentHarnessId, skillsSnapshot: params.skillsSnapshot, prompt: effectivePrompt, images: params.isFallbackRetry ? undefined : params.opts.images, diff --git a/src/agents/command/session-store.test.ts b/src/agents/command/session-store.test.ts index 0c26b4acb03..a4f0ebf0d14 100644 --- a/src/agents/command/session-store.test.ts +++ b/src/agents/command/session-store.test.ts @@ -144,6 +144,99 @@ async function withTempSessionStore( } describe("updateSessionStoreAfterAgentRun", () => { + it("persists the selected embedded harness id on the session", async () => { + await withTempSessionStore(async ({ storePath }) => { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:explicit:test-harness-pin"; + const sessionId = "test-harness-pin-session"; + const sessionStore: Record = { + [sessionKey]: { + sessionId, + updatedAt: 1, + }, + }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); + + const result: EmbeddedPiRunResult = { + meta: { + durationMs: 1, + agentMeta: { + sessionId, + provider: "openai", + model: "gpt-5.4", + agentHarnessId: "codex", + }, + }, + }; + + await updateSessionStoreAfterAgentRun({ + cfg, + sessionId, + sessionKey, + storePath, + sessionStore, + defaultProvider: "openai", + defaultModel: "gpt-5.4", + result, + }); + + expect(sessionStore[sessionKey]?.agentHarnessId).toBe("codex"); + expect(loadSessionStore(storePath)[sessionKey]?.agentHarnessId).toBe("codex"); + }); + }); + + it("clears the embedded harness pin after a CLI run", async () => { + await withTempSessionStore(async ({ storePath }) => { + const cfg = { + agents: { + defaults: { + cliBackends: { + "claude-cli": { + command: "claude", + }, + }, + }, + }, + } as OpenClawConfig; + const sessionKey = "agent:main:explicit:test-harness-pin-cli"; + const sessionId = "test-harness-pin-cli-session"; + const sessionStore: Record = { + [sessionKey]: { + sessionId, + updatedAt: 1, + agentHarnessId: "codex", + }, + }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); + + const result: EmbeddedPiRunResult = { + meta: { + durationMs: 1, + executionTrace: { runner: "cli" }, + agentMeta: { + sessionId: "cli-session-123", + provider: "claude-cli", + model: "claude-sonnet-4-6", + }, + }, + }; + + await updateSessionStoreAfterAgentRun({ + cfg, + sessionId, + sessionKey, + storePath, + sessionStore, + defaultProvider: "claude-cli", + defaultModel: "claude-sonnet-4-6", + result, + }); + + expect(sessionStore[sessionKey]?.agentHarnessId).toBeUndefined(); + expect(loadSessionStore(storePath)[sessionKey]?.agentHarnessId).toBeUndefined(); + }); + }); + it("persists claude-cli session bindings when the backend is configured", async () => { await withTempSessionStore(async ({ storePath }) => { const cfg = { diff --git a/src/agents/command/session-store.ts b/src/agents/command/session-store.ts index 1ae8ed29e94..6e332327840 100644 --- a/src/agents/command/session-store.ts +++ b/src/agents/command/session-store.ts @@ -5,6 +5,7 @@ import { updateSessionStore, } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { clearCliSession, setCliSessionBinding, setCliSessionId } from "../cli-session.js"; import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; import { isCliProvider } from "../model-selection.js"; @@ -60,6 +61,7 @@ export async function updateSessionStoreAfterAgentRun(params: { const compactionsThisRun = Math.max(0, result.meta.agentMeta?.compactionCount ?? 0); const modelUsed = result.meta.agentMeta?.model ?? fallbackModel ?? defaultModel; const providerUsed = result.meta.agentMeta?.provider ?? fallbackProvider ?? defaultProvider; + const agentHarnessId = normalizeOptionalString(result.meta.agentMeta?.agentHarnessId); const contextTokens = typeof params.contextTokensOverride === "number" && params.contextTokensOverride > 0 ? params.contextTokensOverride @@ -85,6 +87,11 @@ export async function updateSessionStoreAfterAgentRun(params: { provider: providerUsed, model: modelUsed, }); + if (agentHarnessId) { + next.agentHarnessId = agentHarnessId; + } else if (result.meta.executionTrace?.runner === "cli") { + next.agentHarnessId = undefined; + } if (isCliProvider(providerUsed, cfg)) { const cliSessionBinding = result.meta.agentMeta?.cliSessionBinding; if (cliSessionBinding?.sessionId?.trim()) { diff --git a/src/agents/harness/selection.test.ts b/src/agents/harness/selection.test.ts index 5fdac6e696d..02780f01d7d 100644 --- a/src/agents/harness/selection.test.ts +++ b/src/agents/harness/selection.test.ts @@ -6,7 +6,11 @@ import type { EmbeddedRunAttemptResult, } from "../pi-embedded-runner/run/types.js"; import { clearAgentHarnesses, registerAgentHarness } from "./registry.js"; -import { runAgentHarnessAttemptWithFallback, selectAgentHarness } from "./selection.js"; +import { + maybeCompactAgentHarnessSession, + runAgentHarnessAttemptWithFallback, + selectAgentHarness, +} from "./selection.js"; import type { AgentHarness } from "./types.js"; const piRunAttempt = vi.fn(async () => createAttemptResult("pi")); @@ -182,4 +186,50 @@ describe("selectAgentHarness", () => { "pi", ); }); + + it("keeps an existing session pinned to PI even when config now forces a plugin harness", () => { + registerFailingCodexHarness(); + + expect( + selectAgentHarness({ + provider: "codex", + modelId: "gpt-5.4", + agentHarnessId: "pi", + config: { agents: { defaults: { embeddedHarness: { runtime: "codex" } } } }, + }).id, + ).toBe("pi"); + }); + + it("keeps an existing session pinned to its plugin harness even when env now forces PI", () => { + process.env.OPENCLAW_AGENT_RUNTIME = "pi"; + registerFailingCodexHarness(); + + expect( + selectAgentHarness({ + provider: "openai", + modelId: "gpt-5.4", + agentHarnessId: "codex", + }).id, + ).toBe("codex"); + }); + + it("does not compact a plugin-pinned session through PI when the plugin has no compactor", async () => { + registerFailingCodexHarness(); + + await expect( + maybeCompactAgentHarnessSession({ + sessionId: "session-1", + sessionKey: "agent:main:main", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp/workspace", + provider: "openai", + model: "gpt-5.4", + agentHarnessId: "codex", + }), + ).resolves.toEqual({ + ok: false, + compacted: false, + reason: 'Agent harness "codex" does not support compaction.', + }); + }); }); diff --git a/src/agents/harness/selection.ts b/src/agents/harness/selection.ts index e638096b409..02bb7d53602 100644 --- a/src/agents/harness/selection.ts +++ b/src/agents/harness/selection.ts @@ -49,8 +49,10 @@ export function selectAgentHarness(params: { config?: OpenClawConfig; agentId?: string; sessionKey?: string; + agentHarnessId?: string; }): AgentHarness { - const policy = resolveAgentHarnessPolicy(params); + const policy = + resolvePinnedAgentHarnessPolicy(params.agentHarnessId) ?? resolveAgentHarnessPolicy(params); // PI is intentionally not part of the plugin candidate list. It is the legacy // fallback path, so `fallback: "none"` can prove that only plugin harnesses run. const pluginHarnesses = listPluginAgentHarnesses(); @@ -115,13 +117,16 @@ export async function runAgentHarnessAttemptWithFallback( config: params.config, agentId: params.agentId, sessionKey: params.sessionKey, + agentHarnessId: params.agentHarnessId, }); if (harness.id === "pi") { - return harness.runAttempt(params); + const result = await harness.runAttempt(params); + return { ...result, agentHarnessId: harness.id }; } try { - return await harness.runAttempt(params); + const result = await harness.runAttempt(params); + return { ...result, agentHarnessId: harness.id }; } catch (error) { log.warn(`${harness.label} failed; not falling back to embedded PI backend`, { harnessId: harness.id, @@ -133,6 +138,16 @@ export async function runAgentHarnessAttemptWithFallback( } } +function resolvePinnedAgentHarnessPolicy( + agentHarnessId: string | undefined, +): AgentHarnessPolicy | undefined { + const runtime = normalizeEmbeddedAgentRuntime(agentHarnessId); + if (runtime === "auto") { + return undefined; + } + return { runtime, fallback: "none" }; +} + export async function maybeCompactAgentHarnessSession( params: CompactEmbeddedPiSessionParams, ): Promise { @@ -141,8 +156,16 @@ export async function maybeCompactAgentHarnessSession( modelId: params.model, config: params.config, sessionKey: params.sessionKey, + agentHarnessId: params.agentHarnessId, }); if (!harness.compact) { + if (harness.id !== "pi") { + return { + ok: false, + compacted: false, + reason: `Agent harness "${harness.id}" does not support compaction.`, + }; + } return undefined; } return harness.compact(params); diff --git a/src/agents/pi-embedded-runner/compact.types.ts b/src/agents/pi-embedded-runner/compact.types.ts index e056a62edee..6c2a6c24f7e 100644 --- a/src/agents/pi-embedded-runner/compact.types.ts +++ b/src/agents/pi-embedded-runner/compact.types.ts @@ -41,6 +41,8 @@ export type CompactEmbeddedPiSessionParams = { skillsSnapshot?: SkillSnapshot; provider?: string; model?: string; + /** Session-pinned embedded harness id. Prevents compaction hot-switching. */ + agentHarnessId?: string; thinkLevel?: ThinkLevel; reasoningLevel?: ReasoningLevel; bashElevated?: ExecElevatedDefaults; diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 997cf7c5f4f..b40f9bd7194 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -731,6 +731,7 @@ export async function runEmbeddedPiAgent( disableTools: params.disableTools, provider, modelId, + agentHarnessId: params.agentHarnessId, model: applyAuthHeaderOverride( applyLocalNoAuthHeaderOverride(effectiveModel, apiKeyInfo), // When runtime auth exchange produced a different credential @@ -1629,6 +1630,7 @@ export async function runEmbeddedPiAgent( sessionId: sessionIdUsed, provider: sessionLastAssistant?.provider ?? provider, model: sessionLastAssistant?.model ?? model.id, + agentHarnessId: attempt.agentHarnessId, usage: usageMeta.usage, lastCallUsage: usageMeta.lastCallUsage, promptTokens: usageMeta.promptTokens, diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index 522eafaab2b..1873f2cfbb3 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -86,6 +86,8 @@ export type RunEmbeddedPiAgentParams = { disableTools?: boolean; provider?: string; model?: string; + /** Session-pinned embedded harness id. Prevents runtime hot-switching. */ + agentHarnessId?: string; authProfileId?: string; authProfileIdSource?: "auto" | "user"; thinkLevel?: ThinkLevel; diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index dcf411af70d..2eafaab1bea 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -32,6 +32,8 @@ export type EmbeddedRunAttemptParams = EmbeddedRunAttemptBase & { authProfileIdSource?: "auto" | "user"; provider: string; modelId: string; + /** Session-pinned embedded harness id. Prevents runtime hot-switching. */ + agentHarnessId?: string; model: Model; authStorage: AuthStorage; modelRegistry: ModelRegistry; @@ -70,6 +72,7 @@ export type EmbeddedRunAttemptResult = { handled?: false; }; sessionIdUsed: string; + agentHarnessId?: string; bootstrapPromptWarningSignaturesSeen?: string[]; bootstrapPromptWarningSignature?: string; systemPromptReport?: SessionSystemPromptReport; diff --git a/src/agents/pi-embedded-runner/types.ts b/src/agents/pi-embedded-runner/types.ts index 337214aff89..c975ce326b5 100644 --- a/src/agents/pi-embedded-runner/types.ts +++ b/src/agents/pi-embedded-runner/types.ts @@ -5,6 +5,7 @@ export type EmbeddedPiAgentMeta = { sessionId: string; provider: string; model: string; + agentHarnessId?: string; cliSessionBinding?: CliSessionBinding; compactionCount?: number; promptTokens?: number; diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index c1f3476b690..f7c3fab3ecb 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -481,6 +481,8 @@ export async function runPreflightCompactionIfNeeded(params: { skillsSnapshot: entry.skillsSnapshot ?? params.followupRun.run.skillsSnapshot, provider: params.followupRun.run.provider, model: params.followupRun.run.model, + agentHarnessId: + entry.sessionId === params.followupRun.run.sessionId ? entry.agentHarnessId : undefined, thinkLevel: params.followupRun.run.thinkLevel, bashElevated: params.followupRun.run.bashElevated, trigger: "budget", diff --git a/src/auto-reply/reply/commands-compact.ts b/src/auto-reply/reply/commands-compact.ts index b1a88017616..faf0e0ebd6a 100644 --- a/src/auto-reply/reply/commands-compact.ts +++ b/src/auto-reply/reply/commands-compact.ts @@ -143,6 +143,8 @@ export const handleCompactCommand: CommandHandler = async (params) => { skillsSnapshot: targetSessionEntry.skillsSnapshot, provider: params.provider, model: params.model, + agentHarnessId: + targetSessionEntry.sessionId === sessionId ? targetSessionEntry.agentHarnessId : undefined, thinkLevel: params.resolvedThinkLevel ?? (await params.resolveDefaultThinkingLevel()), bashElevated: { enabled: false, diff --git a/src/auto-reply/reply/commands-status.test.ts b/src/auto-reply/reply/commands-status.test.ts index e6c29603bee..940bb6ada78 100644 --- a/src/auto-reply/reply/commands-status.test.ts +++ b/src/auto-reply/reply/commands-status.test.ts @@ -3,6 +3,8 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { normalizeTestText } from "../../../test/helpers/normalize-text.js"; import { withTempHome } from "../../../test/helpers/temp-home.js"; +import { clearAgentHarnesses, registerAgentHarness } from "../../agents/harness/registry.js"; +import type { AgentHarness } from "../../agents/harness/types.js"; import { addSubagentRunForTests, resetSubagentRegistryForTests, @@ -50,6 +52,23 @@ async function buildStatusReplyForTest(params: { sessionKey?: string; verbose?: }); } +function registerStatusCodexHarness(): void { + const harness: AgentHarness = { + id: "codex", + label: "Codex", + supports: (ctx) => + ctx.provider === "codex" ? { supported: true, priority: 100 } : { supported: false }, + runAttempt: async () => { + throw new Error("not used in status tests"); + }, + }; + registerAgentHarness(harness, { ownerPluginId: "codex" }); +} + +afterEach(() => { + clearAgentHarnesses(); +}); + function writeTranscriptUsageLog(params: { dir: string; agentId: string; @@ -461,4 +480,81 @@ describe("buildStatusReply subagent summary", () => { expect(normalizeTestText(text)).toContain("Context: 1.0k/32k"); }); }); + + it("shows the effective non-PI embedded harness in /status", async () => { + registerStatusCodexHarness(); + + const text = await buildStatusText({ + cfg: { + ...baseCfg, + agents: { + defaults: { + embeddedHarness: { runtime: "codex" }, + }, + }, + }, + sessionEntry: { + sessionId: "sess-status-codex", + updatedAt: 0, + fastMode: true, + }, + sessionKey: "agent:main:main", + parentSessionKey: "agent:main:main", + sessionScope: "per-sender", + statusChannel: "mobilechat", + provider: "openai", + model: "gpt-5.4", + contextTokens: 32_000, + resolvedFastMode: true, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolveDefaultThinkingLevel: async () => undefined, + isGroup: false, + defaultGroupActivation: () => "mention", + modelAuthOverride: "api-key", + activeModelAuthOverride: "api-key", + }); + + expect(normalizeTestText(text)).toContain("Fast 路 codex"); + }); + + it("keeps /status on a session-pinned PI harness after config changes", async () => { + registerStatusCodexHarness(); + + const text = await buildStatusText({ + cfg: { + ...baseCfg, + agents: { + defaults: { + embeddedHarness: { runtime: "codex" }, + }, + }, + }, + sessionEntry: { + sessionId: "sess-status-pinned-pi", + updatedAt: 0, + fastMode: true, + agentHarnessId: "pi", + }, + sessionKey: "agent:main:main", + parentSessionKey: "agent:main:main", + sessionScope: "per-sender", + statusChannel: "mobilechat", + provider: "openai", + model: "gpt-5.4", + contextTokens: 32_000, + resolvedFastMode: true, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolveDefaultThinkingLevel: async () => undefined, + isGroup: false, + defaultGroupActivation: () => "mention", + modelAuthOverride: "api-key", + activeModelAuthOverride: "api-key", + }); + + const normalized = normalizeTestText(text); + expect(normalized).toContain("Fast"); + expect(normalized).not.toContain("codex"); + }); }); diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index c68da05ec00..20ed0a289d7 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -382,6 +382,43 @@ describe("buildStatusMessage", () => { expect(normalizeTestText(text)).toContain("Fast"); }); + it("shows a non-PI harness next to fast mode when resolved", () => { + const text = buildStatusMessage({ + agent: { + model: "openai/gpt-5.4", + }, + sessionEntry: { + sessionId: "codex-harness", + updatedAt: 0, + fastMode: true, + }, + sessionKey: "agent:main:main", + queue: { mode: "collect", depth: 0 }, + resolvedHarness: "codex", + }); + + expect(normalizeTestText(text)).toContain("Fast 路 codex"); + }); + + it("hides the default PI harness label", () => { + const text = buildStatusMessage({ + agent: { + model: "openai/gpt-5.4", + }, + sessionEntry: { + sessionId: "pi-harness", + updatedAt: 0, + fastMode: true, + }, + sessionKey: "agent:main:main", + queue: { mode: "collect", depth: 0 }, + resolvedHarness: "pi", + }); + + expect(normalizeTestText(text)).toContain("Fast"); + expect(normalizeTestText(text)).not.toContain("路 pi"); + }); + it("hides fast mode when disabled", () => { const text = buildStatusMessage({ agent: { diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 039b4882c58..81569443e41 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -221,6 +221,12 @@ export type SessionEntry = { cacheWrite?: number; modelProvider?: string; model?: string; + /** + * Embedded agent harness selected for this session id. + * Prevents config/env changes from moving an existing transcript between + * incompatible runtime harnesses. + */ + agentHarnessId?: string; /** * Last selected/runtime model pair for which a fallback notice was emitted. * Used to avoid repeating the same fallback notice every turn. diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index a83b71570f0..7a948d17ed8 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -1571,6 +1571,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { config: cfg, provider: resolvedModel.provider, model: resolvedModel.model, + agentHarnessId: entry?.sessionId === sessionId ? entry.agentHarnessId : undefined, thinkLevel: normalizeThinkLevel(entry?.thinkingLevel), reasoningLevel: normalizeReasoningLevel(entry?.reasoningLevel), bashElevated: { diff --git a/src/status/status-message.ts b/src/status/status-message.ts index e3fbc1f4983..96d851118e5 100644 --- a/src/status/status-message.ts +++ b/src/status/status-message.ts @@ -87,6 +87,7 @@ export type StatusArgs = { groupActivation?: "mention" | "always"; resolvedThink?: ThinkLevel; resolvedFast?: boolean; + resolvedHarness?: string; resolvedVerbose?: VerboseLevel; resolvedReasoning?: ReasoningLevel; resolvedElevated?: ElevatedLevel; @@ -269,6 +270,14 @@ const formatFastModeLabel = (enabled: boolean) => { return "Fast"; }; +const formatHarnessLabel = (harnessId: string | undefined) => { + const normalized = normalizeOptionalLowercaseString(harnessId); + if (!normalized || normalized === "pi" || normalized === "auto") { + return null; + } + return normalized; +}; + const readUsageFromSessionLog = ( sessionId?: string, sessionEntry?: SessionEntry, @@ -744,6 +753,7 @@ export function buildStatusMessage(args: StatusArgs): string { `Runner: ${runnerLabel}`, `Think: ${thinkLevel}`, formatFastModeLabel(fastMode), + formatHarnessLabel(args.resolvedHarness), textVerbosity ? `Text: ${textVerbosity}` : null, verboseLabel, traceLabel, diff --git a/src/status/status-text.ts b/src/status/status-text.ts index 8fe05855769..9659453c9f9 100644 --- a/src/status/status-text.ts +++ b/src/status/status-text.ts @@ -6,6 +6,7 @@ import { resolveAgentModelFallbacksOverride, } from "../agents/agent-scope.js"; import { resolveFastModeState } from "../agents/fast-mode.js"; +import { selectAgentHarness } from "../agents/harness/selection.js"; import { resolveModelAuthLabel } from "../agents/model-auth-label.js"; import { resolveInternalSessionKey, @@ -52,6 +53,7 @@ export type BuildStatusTextParams = { contextTokens?: number; resolvedThinkLevel?: ThinkLevel; resolvedFastMode?: boolean; + resolvedHarness?: string; resolvedVerboseLevel: VerboseLevel; resolvedReasoningLevel: ReasoningLevel; resolvedElevatedLevel?: ElevatedLevel; @@ -131,6 +133,30 @@ function formatSessionTaskLine(sessionKey: string): string | undefined { return parts.length ? `馃搶 Tasks: ${parts.join(" 路 ")}` : undefined; } +function resolveStatusHarnessId(params: { + cfg: OpenClawConfig; + provider: string; + model: string; + agentId: string; + sessionKey: string; + sessionEntry?: SessionEntry; +}): string | undefined { + try { + const selected = selectAgentHarness({ + provider: params.provider, + modelId: params.model, + config: params.cfg, + agentId: params.agentId, + sessionKey: params.sessionKey, + agentHarnessId: params.sessionEntry?.agentHarnessId, + }); + const id = normalizeOptionalLowercaseString(selected.id); + return id && id !== "pi" ? id : undefined; + } catch { + return undefined; + } +} + function formatAgentTaskCountsLine(agentId: string): string | undefined { const snapshot = buildTaskStatusSnapshot(listTasksForAgentIdForStatus(agentId)); if (snapshot.totalCount === 0) { @@ -286,6 +312,16 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise