mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:50:43 +00:00
fix: pin embedded harness selection per session
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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=<id|auto|pi>` 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`):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -87,11 +87,14 @@ export default definePluginEntry({
|
||||
|
||||
OpenClaw chooses a harness after provider/model resolution:
|
||||
|
||||
1. `OPENCLAW_AGENT_RUNTIME=<id>` 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=<id>` 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.
|
||||
|
||||
@@ -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<typeof runAgentAttempt>[0]["opts"],
|
||||
runContext: {} as Parameters<typeof runAgentAttempt>[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<typeof runAgentAttempt>[0]["opts"],
|
||||
runContext: {} as Parameters<typeof runAgentAttempt>[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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -144,6 +144,99 @@ async function withTempSessionStore<T>(
|
||||
}
|
||||
|
||||
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<string, SessionEntry> = {
|
||||
[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<string, SessionEntry> = {
|
||||
[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 = {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<EmbeddedPiCompactResult | undefined> {
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Api>;
|
||||
authStorage: AuthStorage;
|
||||
modelRegistry: ModelRegistry;
|
||||
@@ -70,6 +72,7 @@ export type EmbeddedRunAttemptResult = {
|
||||
handled?: false;
|
||||
};
|
||||
sessionIdUsed: string;
|
||||
agentHarnessId?: string;
|
||||
bootstrapPromptWarningSignaturesSeen?: string[];
|
||||
bootstrapPromptWarningSignature?: string;
|
||||
systemPromptReport?: SessionSystemPromptReport;
|
||||
|
||||
@@ -5,6 +5,7 @@ export type EmbeddedPiAgentMeta = {
|
||||
sessionId: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
agentHarnessId?: string;
|
||||
cliSessionBinding?: CliSessionBinding;
|
||||
compactionCount?: number;
|
||||
promptTokens?: number;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<st
|
||||
agentId: statusAgentId,
|
||||
sessionEntry,
|
||||
}).enabled;
|
||||
const effectiveHarness =
|
||||
params.resolvedHarness ??
|
||||
resolveStatusHarnessId({
|
||||
cfg,
|
||||
provider,
|
||||
model,
|
||||
agentId: statusAgentId,
|
||||
sessionKey,
|
||||
sessionEntry,
|
||||
});
|
||||
const agentFallbacksOverride = resolveAgentModelFallbacksOverride(cfg, statusAgentId);
|
||||
const { buildStatusMessage } = await loadStatusMessageRuntime();
|
||||
const explicitThinkingDefault =
|
||||
@@ -319,6 +355,7 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
|
||||
resolvedThink:
|
||||
resolvedThinkLevel ?? explicitThinkingDefault ?? (await resolveDefaultThinkingLevel()),
|
||||
resolvedFast: effectiveFastMode,
|
||||
resolvedHarness: effectiveHarness,
|
||||
resolvedVerbose: resolvedVerboseLevel,
|
||||
resolvedReasoning: resolvedReasoningLevel,
|
||||
resolvedElevated: resolvedElevatedLevel,
|
||||
|
||||
Reference in New Issue
Block a user