feat(agents): allow disabling PI harness fallback

This commit is contained in:
Peter Steinberger
2026-04-10 18:26:31 +01:00
parent 6e4d78ce80
commit 2d80bbc43d
12 changed files with 278 additions and 36 deletions

View File

@@ -1,4 +1,4 @@
a962c1d7ddffa15f2333854f77b03da4f6db07fada16f288377ee1daf50afc08 config-baseline.json
3c8455d44a63d495ad295d2c9d76fed7a190b80344dabaa0e78ba433bf2d253b config-baseline.core.json
df55c673a1cdbebc4fe68baaaf9d0d4289313be5034be92f0d510726a086b1d6 config-baseline.channel.json
3f6fccab66a9abe7e1dd412fb01b13b944ed24edbe09df55ada3323acc7f76fe config-baseline.plugin.json
c2705b6fbb297a6f06aefa6036db71aa5dbfea5a21ec3dafd53ed631cdc558f9 config-baseline.json
b8e245d02a00b696af2b4f0447553dd3b5bb98ca805aca650fb2ce5c0487eacb config-baseline.core.json
e1f94346a8507ce3dec763b598e79f3bb89ff2e33189ce977cc87d3b05e71c1d config-baseline.channel.json
9153501720ea74f9356432a011fa9b41c9b700084bfe0d156feb5647624b35ad config-baseline.plugin.json

View File

@@ -13,6 +13,7 @@ import { selectAgentHarness } from "./selection.js";
import type { AgentHarness } from "./types.js";
const originalRuntime = process.env.OPENCLAW_AGENT_RUNTIME;
const originalHarnessFallback = process.env.OPENCLAW_AGENT_HARNESS_FALLBACK;
afterEach(() => {
clearAgentHarnesses();
@@ -21,6 +22,11 @@ afterEach(() => {
} else {
process.env.OPENCLAW_AGENT_RUNTIME = originalRuntime;
}
if (originalHarnessFallback == null) {
delete process.env.OPENCLAW_AGENT_HARNESS_FALLBACK;
} else {
process.env.OPENCLAW_AGENT_HARNESS_FALLBACK = originalHarnessFallback;
}
});
function makeHarness(

View File

@@ -1,11 +1,12 @@
import type { Api, Model } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type {
EmbeddedRunAttemptParams,
EmbeddedRunAttemptResult,
} from "../pi-embedded-runner/run/types.js";
import { clearAgentHarnesses, registerAgentHarness } from "./registry.js";
import { runAgentHarnessAttemptWithFallback } from "./selection.js";
import { runAgentHarnessAttemptWithFallback, selectAgentHarness } from "./selection.js";
import type { AgentHarness } from "./types.js";
const piRunAttempt = vi.fn(async () => createAttemptResult("pi"));
@@ -20,6 +21,7 @@ vi.mock("./builtin-pi.js", () => ({
}));
const originalRuntime = process.env.OPENCLAW_AGENT_RUNTIME;
const originalHarnessFallback = process.env.OPENCLAW_AGENT_HARNESS_FALLBACK;
afterEach(() => {
clearAgentHarnesses();
@@ -29,9 +31,14 @@ afterEach(() => {
} else {
process.env.OPENCLAW_AGENT_RUNTIME = originalRuntime;
}
if (originalHarnessFallback == null) {
delete process.env.OPENCLAW_AGENT_HARNESS_FALLBACK;
} else {
process.env.OPENCLAW_AGENT_HARNESS_FALLBACK = originalHarnessFallback;
}
});
function createAttemptParams(): EmbeddedRunAttemptParams {
function createAttemptParams(config?: OpenClawConfig): EmbeddedRunAttemptParams {
return {
prompt: "hello",
sessionId: "session-1",
@@ -45,6 +52,7 @@ function createAttemptParams(): EmbeddedRunAttemptParams {
authStorage: {} as never,
modelRegistry: {} as never,
thinkLevel: "low",
config,
} as EmbeddedRunAttemptParams;
}
@@ -115,4 +123,64 @@ describe("runAgentHarnessAttemptWithFallback", () => {
);
expect(piRunAttempt).not.toHaveBeenCalled();
});
it("disables PI retry fallback when auto-selected harness fails and fallback is none", async () => {
process.env.OPENCLAW_AGENT_RUNTIME = "auto";
registerFailingCodexHarness();
await expect(
runAgentHarnessAttemptWithFallback(
createAttemptParams({ agents: { defaults: { embeddedHarness: { fallback: "none" } } } }),
),
).rejects.toThrow("codex startup failed");
expect(piRunAttempt).not.toHaveBeenCalled();
});
it("honors env fallback override over config fallback", async () => {
process.env.OPENCLAW_AGENT_RUNTIME = "auto";
process.env.OPENCLAW_AGENT_HARNESS_FALLBACK = "none";
registerFailingCodexHarness();
await expect(runAgentHarnessAttemptWithFallback(createAttemptParams())).rejects.toThrow(
"codex startup failed",
);
expect(piRunAttempt).not.toHaveBeenCalled();
});
});
describe("selectAgentHarness", () => {
it("fails instead of choosing PI when no plugin harness matches and fallback is none", () => {
expect(() =>
selectAgentHarness({
provider: "anthropic",
modelId: "sonnet-4.6",
config: { agents: { defaults: { embeddedHarness: { fallback: "none" } } } },
}),
).toThrow("PI fallback is disabled");
expect(piRunAttempt).not.toHaveBeenCalled();
});
it("allows per-agent embedded harness policy overrides", () => {
const config: OpenClawConfig = {
agents: {
defaults: { embeddedHarness: { fallback: "pi" } },
list: [
{ id: "main", default: true },
{ id: "strict", embeddedHarness: { fallback: "none" } },
],
},
};
expect(() =>
selectAgentHarness({
provider: "anthropic",
modelId: "sonnet-4.6",
config,
sessionKey: "agent:strict:session-1",
}),
).toThrow("PI fallback is disabled");
expect(selectAgentHarness({ provider: "anthropic", modelId: "sonnet-4.6", config }).id).toBe(
"pi",
);
});
});

View File

@@ -1,10 +1,20 @@
import type { OpenClawConfig } from "../../config/config.js";
import type { AgentEmbeddedHarnessConfig } from "../../config/types.agents-shared.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { listAgentEntries, resolveSessionAgentIds } from "../agent-scope.js";
import type { CompactEmbeddedPiSessionParams } from "../pi-embedded-runner/compact.js";
import type {
EmbeddedRunAttemptParams,
EmbeddedRunAttemptResult,
} from "../pi-embedded-runner/run/types.js";
import { resolveEmbeddedAgentRuntime } from "../pi-embedded-runner/runtime.js";
import {
normalizeEmbeddedAgentRuntime,
resolveEmbeddedAgentHarnessFallback,
resolveEmbeddedAgentRuntime,
type EmbeddedAgentHarnessFallback,
type EmbeddedAgentRuntime,
} from "../pi-embedded-runner/runtime.js";
import type { EmbeddedPiCompactResult } from "../pi-embedded-runner/types.js";
import { createPiAgentHarness } from "./builtin-pi.js";
import { listRegisteredAgentHarnesses } from "./registry.js";
@@ -12,8 +22,13 @@ import type { AgentHarness, AgentHarnessSupport } from "./types.js";
const log = createSubsystemLogger("agents/harness");
function listAvailableAgentHarnesses(): AgentHarness[] {
return [...listRegisteredAgentHarnesses().map((entry) => entry.harness), createPiAgentHarness()];
type AgentHarnessPolicy = {
runtime: EmbeddedAgentRuntime;
fallback: EmbeddedAgentHarnessFallback;
};
function listPluginAgentHarnesses(): AgentHarness[] {
return listRegisteredAgentHarnesses().map((entry) => entry.harness);
}
function compareHarnessSupport(
@@ -27,21 +42,39 @@ function compareHarnessSupport(
return left.harness.id.localeCompare(right.harness.id);
}
export function selectAgentHarness(params: { provider: string; modelId?: string }): AgentHarness {
const runtime = resolveEmbeddedAgentRuntime();
const harnesses = listAvailableAgentHarnesses();
export function selectAgentHarness(params: {
provider: string;
modelId?: string;
config?: OpenClawConfig;
agentId?: string;
sessionKey?: string;
}): AgentHarness {
const policy = 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();
const piHarness = createPiAgentHarness();
const runtime = policy.runtime;
if (runtime === "pi") {
return piHarness;
}
if (runtime !== "auto") {
const forced = harnesses.find((entry) => entry.id === runtime);
const forced = pluginHarnesses.find((entry) => entry.id === runtime);
if (forced) {
return forced;
}
if (policy.fallback === "none") {
throw new Error(
`Requested agent harness "${runtime}" is not registered and PI fallback is disabled.`,
);
}
log.warn("requested agent harness is not registered; falling back to embedded PI backend", {
requestedRuntime: runtime,
});
return createPiAgentHarness();
return piHarness;
}
const supported = harnesses
const supported = pluginHarnesses
.map((harness) => ({
harness,
support: harness.supports({
@@ -60,16 +93,34 @@ export function selectAgentHarness(params: { provider: string; modelId?: string
)
.toSorted(compareHarnessSupport);
return supported[0]?.harness ?? createPiAgentHarness();
const selected = supported[0]?.harness;
if (selected) {
return selected;
}
if (policy.fallback === "none") {
throw new Error(
`No registered agent harness supports ${formatProviderModel(params)} and PI fallback is disabled.`,
);
}
return piHarness;
}
export async function runAgentHarnessAttemptWithFallback(
params: EmbeddedRunAttemptParams,
): Promise<EmbeddedRunAttemptResult> {
const runtime = resolveEmbeddedAgentRuntime();
const policy = resolveAgentHarnessPolicy({
provider: params.provider,
modelId: params.modelId,
config: params.config,
agentId: params.agentId,
sessionKey: params.sessionKey,
});
const harness = selectAgentHarness({
provider: params.provider,
modelId: params.modelId,
config: params.config,
agentId: params.agentId,
sessionKey: params.sessionKey,
});
if (harness.id === "pi") {
return harness.runAttempt(params);
@@ -78,7 +129,7 @@ export async function runAgentHarnessAttemptWithFallback(
try {
return await harness.runAttempt(params);
} catch (error) {
if (runtime !== "auto") {
if (policy.runtime !== "auto" || policy.fallback === "none") {
throw error;
}
log.warn(`${harness.label} failed; falling back to embedded PI backend`, { error });
@@ -92,9 +143,64 @@ export async function maybeCompactAgentHarnessSession(
const harness = selectAgentHarness({
provider: params.provider ?? "",
modelId: params.model,
config: params.config,
sessionKey: params.sessionKey,
});
if (!harness.compact) {
return undefined;
}
return harness.compact(params);
}
export function resolveAgentHarnessPolicy(params: {
provider?: string;
modelId?: string;
config?: OpenClawConfig;
agentId?: string;
sessionKey?: string;
env?: NodeJS.ProcessEnv;
}): AgentHarnessPolicy {
const env = params.env ?? process.env;
// Harness policy can be session-scoped because users may switch between agents
// with different strictness requirements inside the same gateway process.
const agentPolicy = resolveAgentEmbeddedHarnessConfig(params.config, {
agentId: params.agentId,
sessionKey: params.sessionKey,
});
const defaultsPolicy = params.config?.agents?.defaults?.embeddedHarness;
const runtime = env.OPENCLAW_AGENT_RUNTIME?.trim()
? resolveEmbeddedAgentRuntime(env)
: normalizeEmbeddedAgentRuntime(agentPolicy?.runtime ?? defaultsPolicy?.runtime);
return {
runtime,
fallback:
resolveEmbeddedAgentHarnessFallback(env) ??
normalizeAgentHarnessFallback(agentPolicy?.fallback ?? defaultsPolicy?.fallback),
};
}
function resolveAgentEmbeddedHarnessConfig(
config: OpenClawConfig | undefined,
params: { agentId?: string; sessionKey?: string },
): AgentEmbeddedHarnessConfig | undefined {
if (!config) {
return undefined;
}
const { sessionAgentId } = resolveSessionAgentIds({
config,
agentId: params.agentId,
sessionKey: params.sessionKey,
});
return listAgentEntries(config).find((entry) => normalizeAgentId(entry.id) === sessionAgentId)
?.embeddedHarness;
}
function normalizeAgentHarnessFallback(
value: AgentEmbeddedHarnessConfig["fallback"] | undefined,
): EmbeddedAgentHarnessFallback {
return value === "none" ? "none" : "pi";
}
function formatProviderModel(params: { provider: string; modelId?: string }): string {
return params.modelId ? `${params.provider}/${params.modelId}` : params.provider;
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveEmbeddedAgentRuntime } from "../runtime.js";
import { resolveEmbeddedAgentHarnessFallback, resolveEmbeddedAgentRuntime } from "../runtime.js";
describe("resolveEmbeddedAgentRuntime", () => {
it("uses auto mode by default", () => {
@@ -28,3 +28,20 @@ describe("resolveEmbeddedAgentRuntime", () => {
);
});
});
describe("resolveEmbeddedAgentHarnessFallback", () => {
it("accepts the PI fallback kill switch", () => {
expect(resolveEmbeddedAgentHarnessFallback({ OPENCLAW_AGENT_HARNESS_FALLBACK: "none" })).toBe(
"none",
);
expect(resolveEmbeddedAgentHarnessFallback({ OPENCLAW_AGENT_HARNESS_FALLBACK: "pi" })).toBe(
"pi",
);
});
it("ignores unknown fallback values", () => {
expect(
resolveEmbeddedAgentHarnessFallback({ OPENCLAW_AGENT_HARNESS_FALLBACK: "custom" }),
).toBeUndefined();
});
});

View File

@@ -1,20 +1,35 @@
export type EmbeddedAgentRuntime = "pi" | "auto" | (string & {});
export type EmbeddedAgentHarnessFallback = "pi" | "none";
export function normalizeEmbeddedAgentRuntime(raw: string | undefined): EmbeddedAgentRuntime {
const value = raw?.trim();
if (!value) {
return "auto";
}
if (value === "pi") {
return "pi";
}
if (value === "codex" || value === "codex-app-server" || value === "app-server") {
return "codex";
}
if (value === "auto") {
return "auto";
}
return value;
}
export function resolveEmbeddedAgentRuntime(
env: NodeJS.ProcessEnv = process.env,
): EmbeddedAgentRuntime {
const raw = env.OPENCLAW_AGENT_RUNTIME?.trim();
if (!raw) {
return "auto";
}
if (raw === "pi") {
return "pi";
}
if (raw === "codex" || raw === "codex-app-server" || raw === "app-server") {
return "codex";
}
if (raw === "auto") {
return "auto";
}
return raw;
return normalizeEmbeddedAgentRuntime(env.OPENCLAW_AGENT_RUNTIME?.trim());
}
export function resolveEmbeddedAgentHarnessFallback(
env: NodeJS.ProcessEnv = process.env,
): EmbeddedAgentHarnessFallback | undefined {
const raw = env.OPENCLAW_AGENT_HARNESS_FALLBACK?.trim().toLowerCase();
if (raw === "pi" || raw === "none") {
return raw;
}
return undefined;
}

View File

@@ -1,4 +1,8 @@
import type { AgentModelConfig, AgentSandboxConfig } from "./types.agents-shared.js";
import type {
AgentEmbeddedHarnessConfig,
AgentModelConfig,
AgentSandboxConfig,
} from "./types.agents-shared.js";
import type {
BlockStreamingChunkConfig,
BlockStreamingCoalesceConfig,
@@ -127,6 +131,8 @@ export type CliBackendConfig = {
export type AgentDefaultsConfig = {
/** Global default provider params applied to all models before per-model and per-agent overrides. */
params?: Record<string, unknown>;
/** Default embedded agent harness policy. */
embeddedHarness?: AgentEmbeddedHarnessConfig;
/** Primary model and fallbacks (provider/model). Accepts string or {primary,fallbacks}. */
model?: AgentModelConfig;
/** Optional image-capable model and fallbacks (provider/model). Accepts string or {primary,fallbacks}. */

View File

@@ -14,6 +14,13 @@ export type AgentModelConfig =
fallbacks?: string[];
};
export type AgentEmbeddedHarnessConfig = {
/** Embedded harness id: "auto", "pi", or a registered plugin harness id. */
runtime?: string;
/** Fallback when no plugin harness matches or an auto-selected plugin harness fails. */
fallback?: "pi" | "none";
};
export type AgentSandboxConfig = {
mode?: "off" | "non-main" | "all";
/** Sandbox runtime backend id. Default: "docker". */

View File

@@ -1,6 +1,10 @@
import type { ChatType } from "../channels/chat-type.js";
import type { AgentDefaultsConfig } from "./types.agent-defaults.js";
import type { AgentModelConfig, AgentSandboxConfig } from "./types.agents-shared.js";
import type {
AgentEmbeddedHarnessConfig,
AgentModelConfig,
AgentSandboxConfig,
} from "./types.agents-shared.js";
import type { HumanDelayConfig, IdentityConfig } from "./types.base.js";
import type { GroupChatConfig } from "./types.messages.js";
import type { AgentToolsConfig, MemorySearchConfig } from "./types.tools.js";
@@ -66,6 +70,8 @@ export type AgentConfig = {
agentDir?: string;
/** Optional per-agent full system prompt replacement. */
systemPromptOverride?: AgentDefaultsConfig["systemPromptOverride"];
/** Optional per-agent embedded harness policy override. */
embeddedHarness?: AgentEmbeddedHarnessConfig;
model?: AgentModelConfig;
/** Optional per-agent default thinking level (overrides agents.defaults.thinkingDefault). */
thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive";

View File

@@ -3,6 +3,7 @@ import { isValidNonNegativeByteSizeString } from "./byte-size.js";
import {
HeartbeatSchema,
AgentSandboxSchema,
AgentEmbeddedHarnessSchema,
AgentModelSchema,
MemorySearchSchema,
} from "./zod-schema.agent-runtime.js";
@@ -18,6 +19,7 @@ export const AgentDefaultsSchema = z
.object({
/** Global default provider params applied to all models before per-model and per-agent overrides. */
params: z.record(z.string(), z.unknown()).optional(),
embeddedHarness: AgentEmbeddedHarnessSchema,
model: AgentModelSchema.optional(),
imageModel: AgentModelSchema.optional(),
imageGenerationModel: AgentModelSchema.optional(),

View File

@@ -781,6 +781,14 @@ const AgentRuntimeSchema = z
])
.optional();
export const AgentEmbeddedHarnessSchema = z
.object({
runtime: z.string().optional(),
fallback: z.enum(["pi", "none"]).optional(),
})
.strict()
.optional();
export const AgentEntrySchema = z
.object({
id: z.string(),
@@ -789,6 +797,7 @@ export const AgentEntrySchema = z
workspace: z.string().optional(),
agentDir: z.string().optional(),
systemPromptOverride: z.string().optional(),
embeddedHarness: AgentEmbeddedHarnessSchema,
model: AgentModelSchema.optional(),
thinkingDefault: z
.enum(["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"])

View File

@@ -67,7 +67,7 @@ async function prewarmConfiguredPrimaryModel(params: {
if (runtime !== "auto" && runtime !== "pi") {
return;
}
if (selectAgentHarness({ provider, modelId: model }).id !== "pi") {
if (selectAgentHarness({ provider, modelId: model, config: params.cfg }).id !== "pi") {
return;
}
const agentDir = resolveOpenClawAgentDir();