diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 14be3a3fdfc4..65d6d2518846 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -1908,7 +1908,7 @@ src/agents/harness/lifecycle-hook-helpers.ts 1 src/agents/harness/native-hook-relay-client.ts 2 src/agents/harness/native-hook-relay-codec.ts 2 src/agents/harness/native-hook-relay-state.ts 1 -src/agents/harness/selection.ts 6 +src/agents/harness/selection.ts 5 src/agents/harness/support.ts 1 src/agents/harness/tool-result-middleware.ts 1 src/agents/identity.ts 8 diff --git a/docs/concepts/agent-runtimes.md b/docs/concepts/agent-runtimes.md index 1b5fcdccdeba..4cf15ce24876 100644 --- a/docs/concepts/agent-runtimes.md +++ b/docs/concepts/agent-runtimes.md @@ -143,16 +143,28 @@ this order: `openclaw` as the compatibility runtime. Use an explicit runtime id when the run must be strict. -Whole-session and whole-agent runtime pins are ignored: `OPENCLAW_AGENT_RUNTIME`, -session `agentHarnessId`/`agentRuntimeOverride` state, `agents.defaults.agentRuntime`, -and `agents.entries.*.agentRuntime`. Run `openclaw doctor --fix` to remove stale -whole-agent runtime config and convert legacy runtime model refs where intent -can be preserved. +Historical `agentHarnessId` records which runtime produced the transcript; it +does not pin the next turn. Locked native transcripts retain their owner, and +compatible explicit session runtime overrides take precedence over configured +policy. ACP sessions retain their ACP backend. Legacy whole-agent runtime +config and `OPENCLAW_AGENT_RUNTIME` are ignored; use `openclaw doctor --fix` +to remove stale config and repair legacy model refs. -Explicit provider/model plugin runtimes fail closed: `agentRuntime.id: "codex"` -on a provider or model means Codex, or a clear selection/runtime error - it is -never silently routed back to OpenClaw. Only `auto` may route an unmatched -turn to OpenClaw. +Explicit provider/model plugin runtimes fail closed when the harness is missing +or cannot support the route or authentication. There is one selection-time +exception: a harness may declare that OpenClaw can reproduce the exact request. +Codex uses this fallback for authored request overrides such as headers, request +parameters, timeouts, or payload compatibility switches. It preserves those +settings instead of silently dropping them. Once a harness starts executing, +its failures are not replayed through another runtime. + +Affirmative `compat.supportsReasoningEffort: true` and a nonempty +`compat.supportedReasoningEfforts` list containing only `minimal`, `low`, +`medium`, `high`, `xhigh`, `max`, or `ultra` describe native reasoning +capabilities; they do not opt an otherwise compatible route out of Codex. +Disabling reasoning, custom effort labels, and other compatibility switches +remain request behavior. Model-level runtime controls such as `fastMode` and +`thinking` also preserve native selection when their values are valid. CLI backend aliases differ from embedded harness ids. Preferred Claude CLI form: @@ -187,7 +199,7 @@ harness. Explicit OpenClaw runtime config remains an opt-in compatibility route for `openai/*` agent turns; when paired with a selected `openai` OAuth profile, OpenClaw routes that path internally through the Codex-auth transport while keeping the public model ref as `openai/*`. Stale OpenAI -runtime session pins are ignored by runtime selection and can be cleaned with +historical producer fields do not pin the next turn and can be cleaned with `openclaw doctor --fix`. If `openclaw doctor` warns that the `codex` plugin is enabled while legacy @@ -251,7 +263,10 @@ diagnostics, not provider names: - A channel label such as Telegram or Discord is where the conversation is happening. If a run shows an unexpected runtime, inspect the selected provider/model -runtime policy first. Legacy session runtime pins no longer decide routing. +runtime policy first. Next-turn runtime metadata includes declared fallback +when the registered harness can determine it from the configured route. It does +not probe credentials or start a runtime; final route/auth preparation can still +reject the turn. The completed result records the runtime that actually ran. ## Related diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 5b32fbf7d5b8..3561db9f69f3 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -4,7 +4,7 @@ title: "Codex harness" read_when: - You want to use the official Codex app-server harness - You need Codex harness config examples - - You want Codex-only deployments to fail instead of falling back to OpenClaw + - You need explicit Codex runtime policy and fallback rules --- The official `codex` plugin runs embedded OpenAI agent turns through Codex @@ -735,13 +735,13 @@ Keep provider refs and runtime policy separate: | Send Codex feedback only | `/codex diagnostics [note]` | | Start an ACP/acpx task | ACP/acpx session commands, not `/codex` | -| Use case | Configure | Verify | Notes | -| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | ------------------------------------------------------- | -| Eligible OpenAI route with native Codex runtime | Exact official HTTPS Responses/ChatGPT route with no authored provider request override, plus enabled `codex` plugin | `/status` shows `Runtime: OpenAI Codex` | Valid Fast runtime controls do not disqualify this path | -| Fail closed if Codex is unavailable | Provider or model `agentRuntime.id: "codex"` | Turn fails instead of embedded fallback | Use for Codex-only deployments | -| Direct OpenAI API-key traffic through OpenClaw | Provider or model `agentRuntime.id: "openclaw"` and normal OpenAI auth | `/status` shows OpenClaw runtime | Use only when OpenClaw is intentional | -| Legacy config | legacy Codex GPT refs | `openclaw doctor --fix` rewrites it | Do not write new config this way | -| ACP/acpx Codex adapter | ACP `sessions_spawn({ runtime: "acp" })` | ACP task/session status | Separate from native Codex harness | +| Use case | Configure | Verify | Notes | +| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------- | +| Eligible OpenAI route with native Codex runtime | Exact official HTTPS Responses/ChatGPT route with no authored provider request override, plus enabled `codex` plugin | `/status` shows `Runtime: OpenAI Codex` | Valid Fast runtime controls do not disqualify this path | +| Fail closed if Codex is unavailable | Provider or model `agentRuntime.id: "codex"` | Missing harness fails the turn | Authored request overrides may still use declared fallback | +| Direct OpenAI API-key traffic through OpenClaw | Provider or model `agentRuntime.id: "openclaw"` and normal OpenAI auth | `/status` shows OpenClaw runtime | Use only when OpenClaw is intentional | +| Legacy config | legacy Codex GPT refs | `openclaw doctor --fix` rewrites it | Do not write new config this way | +| ACP/acpx Codex adapter | ACP `sessions_spawn({ runtime: "acp" })` | ACP task/session status | Separate from native Codex harness | `agents.defaults.imageModel` follows the same prefix split. Use `openai/gpt-*` for the normal OpenAI route and `codex/gpt-*` only when image understanding @@ -837,9 +837,12 @@ fail-closed rule: } ``` -With Codex forced, OpenClaw fails early if the effective route is not declared -Codex-compatible, the plugin is disabled, the app-server is too old, or the -app-server cannot start. +With Codex forced, OpenClaw fails early if the plugin is disabled, the app-server +is too old or cannot start, or route/auth support is rejected without a declared +fallback. Authored request overrides may instead use the +[selection-time OpenClaw fallback](/concepts/agent-runtimes#runtime-selection) +that preserves the exact request. Once Codex starts, its failures are not replayed +through OpenClaw. ## App-server policy @@ -1501,10 +1504,15 @@ configs. Select an `openai/gpt-*` model, enable **OpenClaw uses the built-in harness instead of Codex:** confirm the effective route is an exact official HTTPS Platform Responses or ChatGPT Responses route, -has no authored provider request override, and that the Codex plugin is installed and -enabled. The `openai/gpt-*` prefix alone is not enough. For strict proof while -testing, set provider or model `agentRuntime.id: "codex"`; forced Codex fails -instead of falling back when the route or harness is incompatible. +has no authored provider request override, and that the Codex plugin is installed +and enabled. Affirmative reasoning support and native reasoning-effort metadata +do not count as request overrides. Headers, request parameters, timeouts, and +payload compatibility switches still do: Codex declares an OpenClaw fallback +that preserves the exact request, including for explicit runtime selections. +Other unsupported routes/authentication and missing explicit harnesses fail +closed. The `openai/gpt-*` prefix and `agentRuntime.id: "codex"` alone are not +execution proof; inspect the actual harness in the completed result. See +[Runtime selection](/concepts/agent-runtimes#runtime-selection). **OpenAI Codex runtime falls back to the API-key path:** collect a redacted gateway excerpt that shows the model, runtime, selected provider, and diff --git a/docs/plugins/sdk-agent-harness.md b/docs/plugins/sdk-agent-harness.md index e5f1b60a70a7..1455c818bb1b 100644 --- a/docs/plugins/sdk-agent-harness.md +++ b/docs/plugins/sdk-agent-harness.md @@ -280,11 +280,13 @@ error. Do not use harness scope after a request or tool action may have produced side effects. Configured runtime policy remains authoritative about the desired runtime. A -persisted session `agentHarnessId` keeps ownership of its native transcript +locked session `agentHarnessId` keeps ownership of its native transcript while route/auth preparation is still pending. Neither makes an incompatible route compatible: once prepared facts exist, the selected or pinned harness -must support them or the run fails closed. `/status` shows the effective runtime -selected from policy, persisted ownership, and route support. +must support them, declare the exact-request OpenClaw fallback, or the run fails +closed. Next-turn metadata uses the same registered support decision and retains +its model/provider/session source. An unlocked historical producer does not pin +the next turn. Projection never loads a harness or reads credentials. Prepared status is explicit: missing `runtimePolicy` stays undeclared instead of being inferred from whichever transport fields happen to be present. When harness-owned auth leaves multiple physical routes unresolved, the @@ -492,10 +494,10 @@ model refs remain compatibility aliases for the native harness. When this mode runs, Codex owns the native thread id, resume behavior, compaction, and app-server execution. OpenClaw still owns the chat channel, visible transcript mirror, tool policy, approvals, media delivery, and session -selection. Use provider/model `agentRuntime.id: "codex"` when you need to -prove that only the Codex app-server path can claim the run. Explicit plugin -runtimes fail closed; Codex app-server selection failures and runtime failures -are not retried through another runtime. +selection. Use provider/model `agentRuntime.id: "codex"` to require a registered +Codex harness. Unsupported routes/auth fail closed unless the harness declares +an exact-request fallback before execution. Codex runtime failures are not +retried through another runtime. ## Runtime strictness @@ -509,7 +511,7 @@ incompatible route compatible. Selected plugin harness failures always fail hard. This does not block an explicit provider/model `agentRuntime.id: "openclaw"`. -For Codex-only embedded runs: +To request Codex for embedded runs: ```json { @@ -585,10 +587,10 @@ Legacy whole-agent runtime examples like this are ignored: ``` With an explicit plugin runtime, a session fails early when the requested -harness is not registered, does not support the resolved provider/model, or -fails before producing turn side effects. That is intentional for Codex-only -deployments and for live tests that must prove the Codex app-server path is -actually in use. +harness is not registered or rejects the resolved provider/model without a +declared fallback. An authored transport override may select OpenClaw through +that fallback even with an explicit runtime. To prove native execution, inspect +the actual harness in the completed result; configured intent alone is not proof. This setting only controls the embedded agent harness. It does not disable image, video, music, TTS, PDF, or other provider-specific model routing. diff --git a/docs/providers/openai.md b/docs/providers/openai.md index d1a56a500761..0151e4d94f85 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -80,16 +80,17 @@ endpoint and adapter: | Explicit exact official endpoint using HTTP | Rejected | | Route with an authored provider/model request override | OpenClaw | -Valid model-scoped `params.fastMode` / `params.fast_mode` values and valid -cutoff keys are typed agent-runtime controls, not authored provider request -params. They do not disqualify implicit Codex selection or select a runtime by -themselves. Pin `agentRuntime.id: "openclaw"` or `agentRuntime.id: "codex"` -when a recipe depends on one runtime. +Valid model-scoped `params.fastMode` / `params.fast_mode`, cutoff, and `thinking` +values are typed agent-runtime controls, not authored provider request params. +Affirmative reasoning support and native reasoning-effort metadata also preserve +Codex selection. See [Runtime selection](/concepts/agent-runtimes#runtime-selection) +for the supported capability values and the request overrides that remain protected. -An explicit non-default provider/model `agentRuntime.id` remains authoritative. -For example, `agentRuntime.id: "openclaw"` keeps an otherwise Codex-eligible -route on OpenClaw, while `agentRuntime.id: "codex"` requires Codex and fails -closed when the effective route is not declared Codex-compatible. +An explicit `agentRuntime.id: "openclaw"` keeps a Codex-eligible route on +OpenClaw. Explicit `agentRuntime.id: "codex"` requires a registered Codex harness; +unsupported routes/auth fail closed, except that authored request overrides may +use Codex's declared exact-request OpenClaw fallback before execution. Inspect +the completed result's actual harness when a recipe depends on native execution. Runtime selection does not change credential type or billing: Platform API-key auth and ChatGPT/Codex subscription auth remain distinct. @@ -1307,9 +1308,10 @@ request settings, so an otherwise eligible `auto` route stays on OpenClaw instead of selecting Codex implicitly. Valid `fastMode` / `fast_mode` values and valid cutoff keys are typed agent-runtime controls and do not select a runtime. Runtime-specific examples therefore pin `agentRuntime.id` explicitly. -The native Codex app-server harness owns its own transport and request settings; -explicit `agentRuntime.id: "codex"` fails closed when the effective route is -not declared Codex-compatible. +The native Codex app-server harness owns its transport and request settings. +Authored embedded-provider settings can therefore select the declared OpenClaw +fallback even with explicit `agentRuntime.id: "codex"`; see +[Runtime selection](/concepts/agent-runtimes#runtime-selection). diff --git a/src/agents/agent-runtime-metadata.ts b/src/agents/agent-runtime-metadata.ts index e290f09e49c3..ef50356b636a 100644 --- a/src/agents/agent-runtime-metadata.ts +++ b/src/agents/agent-runtime-metadata.ts @@ -2,7 +2,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { applyAcpRuntimeOverlay, type AgentRuntimeMetadata } from "./acp-runtime-overlay.js"; import { isDefaultAgentRuntimeId } from "./agent-runtime-id.js"; -import { resolveAgentHarnessPolicy } from "./harness/policy.js"; +import { resolveAvailableAgentHarnessPolicy } from "./harness/availability.js"; import { resolveDefaultModelForAgent } from "./model-selection.js"; import { resolvePersistedSessionRuntimeId, @@ -20,6 +20,7 @@ type ModelAgentRuntimeMetadataParams = { acpRuntime?: boolean; /** Persisted ACP backend id, falling back to acpx when absent. */ acpBackend?: string; + agentHarnessRuntimeOverride?: string; }; /** Resolves the runtime id/source that should be reported for a model-backed agent session. */ @@ -39,12 +40,14 @@ export function resolveModelAgentRuntimeMetadata( params.provider && params.model ? { provider: params.provider, model: params.model } : resolveDefaultModelForAgent({ cfg: params.cfg, agentId: params.agentId }); - const policy = resolveAgentHarnessPolicy({ + const policy = resolveAvailableAgentHarnessPolicy({ + mode: "projection", provider: resolved.provider, modelId: resolved.model, config: params.cfg, agentId: params.agentId, sessionKey: params.sessionKey, + agentHarnessRuntimeOverride: params.agentHarnessRuntimeOverride, }); const meta: AgentRuntimeMetadata = { id: policy.runtime, @@ -58,17 +61,22 @@ export function resolveCurrentSessionAgentRuntimeMetadata( params: ModelAgentRuntimeMetadataParams, ): AgentRuntimeMetadata { const { sessionEntry, ...configuredParams } = params; - const configuredRuntime = resolveModelAgentRuntimeMetadata(configuredParams); const sessionRuntime = resolveSessionRuntimeOverrideForProvider({ provider: params.provider, entry: sessionEntry, cfg: params.cfg, }); if (params.acpRuntime || !sessionRuntime) { - return configuredRuntime; + return resolveModelAgentRuntimeMetadata(configuredParams); } + const runtime = resolveModelAgentRuntimeMetadata({ + ...configuredParams, + ...(sessionEntry?.modelSelectionLocked === true + ? { sessionEntry } + : { agentHarnessRuntimeOverride: sessionRuntime }), + }); return { - id: sessionRuntime, + id: runtime.id, source: sessionEntry?.modelSelectionLocked === true ? "session" : "session-key", }; } diff --git a/src/agents/harness/availability.ts b/src/agents/harness/availability.ts new file mode 100644 index 000000000000..6717af3259eb --- /dev/null +++ b/src/agents/harness/availability.ts @@ -0,0 +1,88 @@ +/** Lightweight runtime availability shared by execution and next-turn projections. */ +import { isDefaultAgentRuntimeId, normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; +import { resolveAgentHarnessPolicy, type AgentHarnessPolicy } from "./policy.js"; +import { getRegisteredAgentHarness } from "./registry.js"; +import { buildAgentHarnessSupportContext } from "./support.js"; +import type { AgentHarnessSupport, AgentHarnessSupportContext } from "./types.js"; + +type AgentHarnessAvailabilityParams = Parameters[0] & { + modelProvider?: AgentHarnessSupportContext["modelProvider"]; + /** Projection can inspect loaded support, but absence before registration is not a fallback. */ + mode?: "execution" | "projection"; + agentHarnessId?: string; + agentHarnessRuntimeOverride?: string; + preparedModelProvider?: boolean; + /** Execution supplies ownership lazily; read-only projections never discover plugins. */ + resolveProviderOwnership?: () => Parameters< + typeof buildAgentHarnessSupportContext + >[0]["providerOwnership"]; +}; + +type AgentHarnessAvailabilityDecision = { + kind: "available" | "implicit-unavailable" | "implicit-unsupported" | "declared-fallback"; + policy: AgentHarnessPolicy; + /** Reuse the exact support decision during execution, without another probe. */ + support?: AgentHarnessSupport; +}; + +export function resolveAvailableAgentHarnessPolicy( + params: AgentHarnessAvailabilityParams, +): AgentHarnessPolicy { + return resolveAgentHarnessAvailabilityDecision(params).policy; +} + +export function resolveAgentHarnessAvailabilityDecision( + params: AgentHarnessAvailabilityParams, +): AgentHarnessAvailabilityDecision { + const configured = resolveAgentHarnessPolicy({ + ...params, + modelApi: params.modelProvider?.api ?? params.modelApi, + modelBaseUrl: params.modelProvider?.baseUrl ?? params.modelBaseUrl, + requestTransportOverrides: + params.modelProvider?.requestTransportOverrides ?? params.requestTransportOverrides, + }); + const pinnedHarnessId = normalizeOptionalAgentRuntimeId(params.agentHarnessId); + const runtimeOverride = + pinnedHarnessId ?? normalizeOptionalAgentRuntimeId(params.agentHarnessRuntimeOverride); + const policy: AgentHarnessPolicy = + runtimeOverride && !isDefaultAgentRuntimeId(runtimeOverride) + ? { ...configured, runtime: runtimeOverride, runtimeSource: "model" } + : configured; + const implicit = policy.runtime === "codex" && policy.runtimeSource === "implicit"; + if (policy.runtime === "auto" || policy.runtime === "openclaw") { + return { kind: "available", policy }; + } + const registered = getRegisteredAgentHarness(policy.runtime); + if (!registered) { + return implicit && params.mode !== "projection" + ? { kind: "implicit-unavailable", policy: { ...policy, runtime: "openclaw" } } + : { kind: "available", policy }; + } + // A pinned native transcript owns early selection. Final prepared routes must + // revalidate that owner; historical producer metadata is not a next-turn pin. + if (pinnedHarnessId === policy.runtime && !params.preparedModelProvider) { + return { kind: "available", policy }; + } + const provider = params.provider?.trim() ?? ""; + if (params.provider === undefined) { + return { kind: "available", policy }; + } + const support = registered.harness.supports( + buildAgentHarnessSupportContext({ + ...params, + provider, + requestedRuntime: policy.runtime, + providerOwnership: params.resolveProviderOwnership?.(), + }), + ); + if (!support.supported && !policy.forcedByEnvironment) { + if (implicit || support.fallbackRuntime === "openclaw") { + return { + kind: implicit ? "implicit-unsupported" : "declared-fallback", + policy: { ...policy, runtime: "openclaw" }, + support, + }; + } + } + return { kind: "available", policy, support }; +} diff --git a/src/agents/harness/registry.test.ts b/src/agents/harness/registry.test.ts index 5998222c0c40..cbfefa41515e 100644 --- a/src/agents/harness/registry.test.ts +++ b/src/agents/harness/registry.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; import { withPluginRegistrationContext } from "../../plugins/runtime.js"; +import { withPluginRuntimeRegistryScope } from "../../plugins/runtime/gateway-request-scope.js"; import { clearAgentHarnesses, disposeRegisteredAgentHarnesses, @@ -161,6 +162,33 @@ describe("agent harness registry", () => { expect(building.agentHarnesses[1]?.pluginId).toBe("builder-plugin"); }); + it("keeps harness reads in registration, request, then active registry order", () => { + registerAgentHarness(makeHarness("shared"), { ownerPluginId: "active-plugin" }); + const request = createEmptyPluginRegistry(); + const building = createEmptyPluginRegistry(); + const expectOwner = (ownerPluginId: string) => { + expect(getRegisteredAgentHarness("shared")?.ownerPluginId).toBe(ownerPluginId); + expect(listRegisteredAgentHarnesses().map((entry) => entry.ownerPluginId)).toEqual([ + ownerPluginId, + ]); + }; + + withPluginRuntimeRegistryScope(request, () => { + expect(getRegisteredAgentHarness("shared")).toBeUndefined(); + expect(listRegisteredAgentHarnesses()).toEqual([]); + registerAgentHarness(makeHarness("shared"), { ownerPluginId: "request-plugin" }); + expectOwner("request-plugin"); + withPluginRegistrationContext(building, "builder-plugin", () => { + expect(getRegisteredAgentHarness("shared")).toBeUndefined(); + expect(listRegisteredAgentHarnesses()).toEqual([]); + registerAgentHarness(makeHarness("shared")); + expectOwner("builder-plugin"); + }); + expectOwner("request-plugin"); + }); + expectOwner("active-plugin"); + }); + it("dispatches generic session reset to registered harnesses", async () => { const resets: unknown[] = []; registerAgentHarness({ diff --git a/src/agents/harness/registry.ts b/src/agents/harness/registry.ts index 09e7063a3dfb..35f1e3fd1a92 100644 --- a/src/agents/harness/registry.ts +++ b/src/agents/harness/registry.ts @@ -4,6 +4,7 @@ import { createSubsystemLogger } from "../../logging/subsystem.js"; import { assertDirectPluginRegistrationReplacement, + getPluginRegistryForContext, requireActivePluginRegistry, resolveDirectPluginRegistrationOwner, } from "../../plugins/runtime.js"; @@ -19,7 +20,7 @@ const log = createSubsystemLogger("agents/harness"); const CODEX_NATIVE_COMPACTION_OWNER_ID = "codex"; function getAgentHarnesses() { - return requireActivePluginRegistry().agentHarnesses; + return getPluginRegistryForContext()?.agentHarnesses ?? []; } /** Registers or replaces an agent harness under its trimmed id. */ @@ -28,7 +29,7 @@ export function registerAgentHarness( options?: AgentHarnessRegistrationOptions & { ownerPluginId?: string }, ): void { const id = harness.id.trim(); - const harnesses = getAgentHarnesses(); + const harnesses = requireActivePluginRegistry().agentHarnesses; const pluginId = resolveDirectPluginRegistrationOwner(options?.ownerPluginId) ?? "core"; if (id === "openclaw") { throw new Error('agent harness id "openclaw" is reserved for the built-in runtime'); diff --git a/src/agents/harness/selection.test.ts b/src/agents/harness/selection.test.ts index 574651fd384e..a713e66a854c 100644 --- a/src/agents/harness/selection.test.ts +++ b/src/agents/harness/selection.test.ts @@ -1643,6 +1643,30 @@ describe("runAgentHarnessAttempt", () => { }); describe("selectAgentHarness", () => { + it("rejects a harness replaced during its support probe", () => { + const replacement: AgentHarness = { + id: "codex", + label: "Replacement", + supports: () => ({ supported: true }), + runAttempt: async () => createAttemptResult("replacement"), + }; + registerAgentHarness({ + ...replacement, + supports: () => { + registerAgentHarness(replacement); + return { supported: true }; + }, + }); + + expect(() => + selectAgentHarness({ + provider: "openai", + modelId: "gpt-5.6-sol", + agentHarnessRuntimeOverride: "codex", + }), + ).toThrow("changed during owner resolution"); + }); + it("does not select Codex from a non-OpenAI model name", () => { registerSuccessfulCodexHarness(); @@ -2140,82 +2164,168 @@ describe("selectAgentHarness", () => { }, ); - it("keeps native model run controls compatible with Codex", () => { - expect( - buildAgentHarnessSupportContext({ - provider: "openai", - modelId: "gpt-5.6-sol", - modelProvider: { - api: "openai-responses", - baseUrl: "https://api.openai.com/v1", - requestTransportOverrides: "none", - }, - requestedRuntime: "codex", - config: { - agents: { - defaults: { - models: { - "openai/gpt-5.6-sol": { - params: { thinking: "xhigh", fastMode: true, fastAutoOnSeconds: 30 }, + it.each([ + ["Platform", "openai-responses", "https://api.openai.com/v1"], + ["ChatGPT", "openai-chatgpt-responses", "https://chatgpt.com/backend-api/codex"], + ] as const)( + "keeps authored reasoning metadata and native controls on %s Codex", + (_label, api, baseUrl) => { + const config: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + models: [ + { + id: "gpt-5.6-sol", + name: "Sol", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + maxTokens: 8192, + api, + baseUrl, + compat: { + supportsReasoningEffort: true, + supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"], + }, }, - }, + ], }, }, - } as OpenClawConfig, - }).modelProvider, - ).toMatchObject({ - requestTransportOverrides: "none", - runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, - }); - }); - - it("uses a harness-declared OpenClaw fallback for explicit request params", () => { - const supports = vi.fn((ctx: Parameters[0]) => - ctx.modelProvider?.requestTransportOverrides === "present" - ? { - supported: false as const, - reason: "authored request params are unsupported", - fallbackRuntime: "openclaw" as const, - } - : { supported: true as const }, - ); - registerAgentHarness({ - id: "codex", - label: "Codex", - supports, - runAttempt: vi.fn(async () => createAttemptResult("codex")), - }); - - expect( - selectAgentHarness({ - provider: "openai", - modelId: "gpt-5.6-sol", - modelProvider: { - api: "openai-responses", - baseUrl: "https://api.openai.com/v1", - requestTransportOverrides: "none", - runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, }, - config: { - agents: { - defaults: { - models: { - "openai/gpt-5.6-sol": { - params: { responsesServerCompaction: true }, - agentRuntime: { id: "codex" }, - }, + agents: { + defaults: { + models: { + "openai/gpt-5.6-sol": { + params: { thinking: "xhigh", fastMode: true, fastAutoOnSeconds: 30 }, }, }, }, }, - }).id, - ).toBe("openclaw"); - expect(supports).toHaveBeenCalledWith( - expect.objectContaining({ - modelProvider: expect.objectContaining({ requestTransportOverrides: "present" }), - }), - ); - }); + }; + registerAgentHarness({ + id: "codex", + label: "Codex", + supports: (ctx) => + ctx.modelProvider?.requestTransportOverrides === "present" + ? { supported: false, fallbackRuntime: "openclaw" } + : { supported: true }, + runAttempt: async () => createAttemptResult("codex"), + }); + for (const runtime of [undefined, "codex", "openclaw"]) { + expect( + selectAgentHarness({ + provider: "openai", + modelId: "gpt-5.6-sol", + config, + agentHarnessRuntimeOverride: runtime, + }).id, + ).toBe(runtime ?? "codex"); + } + expect( + buildAgentHarnessSupportContext({ + provider: "openai", + modelId: "gpt-5.6-sol", + requestedRuntime: "codex", + config, + }).modelProvider, + ).toMatchObject({ + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }); + }, + ); + + it.each([ + ["model request params", {}, {}, { responsesServerCompaction: true }], + ["provider headers", { headers: { "x-route": "required" } }, {}, {}], + ["provider params", { params: { store: false } }, {}, {}], + ["provider timeout", { timeoutSeconds: 90 }, {}, {}], + ["model headers", {}, { headers: { "x-route": "required" } }, {}], + ["model params", {}, { params: { store: false } }, {}], + ["store compatibility", {}, { compat: { supportsStore: false } }, {}], + [ + "mixed compatibility", + {}, + { + compat: { + supportsReasoningEffort: true, + supportedReasoningEfforts: ["high"], + supportsStore: false, + }, + }, + {}, + ], + ])( + "uses a harness-declared fallback preserving %s", + (_label, providerPatch, modelPatch, params) => { + const supports = vi.fn((ctx: Parameters[0]) => + ctx.modelProvider?.requestTransportOverrides === "present" + ? { + supported: false as const, + reason: "authored request params are unsupported", + fallbackRuntime: "openclaw" as const, + } + : { supported: true as const }, + ); + registerAgentHarness({ + id: "codex", + label: "Codex", + supports, + runAttempt: vi.fn(async () => createAttemptResult("codex")), + }); + + expect( + selectAgentHarness({ + provider: "openai", + modelId: "gpt-5.6-sol", + modelProvider: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }, + config: { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + ...providerPatch, + models: [ + { + id: "gpt-5.6-sol", + name: "Sol", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + maxTokens: 8192, + ...modelPatch, + }, + ], + }, + }, + }, + agents: { + defaults: { + models: { + "openai/gpt-5.6-sol": { + params, + agentRuntime: { id: "codex" }, + }, + }, + }, + }, + }, + }).id, + ).toBe("openclaw"); + expect(supports).toHaveBeenCalledWith( + expect.objectContaining({ + modelProvider: expect.objectContaining({ requestTransportOverrides: "present" }), + }), + ); + }, + ); it("keeps a private-QA forced runtime despite a plugin-declared fallback", () => { vi.stubEnv("OPENCLAW_BUILD_PRIVATE_QA", "1"); diff --git a/src/agents/harness/selection.ts b/src/agents/harness/selection.ts index 3cc26725a7a1..d96eda3ce6b4 100644 --- a/src/agents/harness/selection.ts +++ b/src/agents/harness/selection.ts @@ -12,7 +12,6 @@ import { import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { resolveProviderRefOwnership } from "../../plugins/providers.js"; -import { isDefaultAgentRuntimeId, normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; import { resolveGroupToolPolicy } from "../agent-tools.policy.js"; import { isHostScopedAgentToolActive, @@ -39,6 +38,7 @@ import { } from "../tool-policy.js"; import type { SystemAgentToolOptions } from "../tools/system-agent-tool.js"; import { resolveAgentHarnessAutoSelectionHint } from "./auto-selection.js"; +import { resolveAgentHarnessAvailabilityDecision } from "./availability.js"; import { createOpenClawAgentHarness, isBuiltInOpenClawAgentHarness } from "./builtin-openclaw.js"; import { selectContextEngineForTranscriptHost } from "./context-engine-logical-turn.js"; import { drainPendingContextEngineTurnsBeforeRun } from "./context-engine-turn-attempt.js"; @@ -48,15 +48,8 @@ import { runAgentHarnessLifecycleAttempt, runAgentHarnessLifecycleFinalization, } from "./lifecycle.js"; -import { - resolveAgentHarnessPolicy as resolveConfiguredAgentHarnessPolicy, - type AgentHarnessPolicy, -} from "./policy.js"; -import { - getRegisteredAgentHarness, - listRegisteredAgentHarnesses, - resolveAgentHarnessOwnerPluginId, -} from "./registry.js"; +import type { AgentHarnessPolicy } from "./policy.js"; +import { listRegisteredAgentHarnesses, resolveAgentHarnessOwnerPluginId } from "./registry.js"; import { buildAgentHarnessSupportContext, compareHarnessSupport, @@ -67,17 +60,7 @@ import type { AgentHarness, AgentHarnessSupport, AgentHarnessSupportContext } fr const log = createSubsystemLogger("agents/harness"); export { resolveAgentHarnessPolicy } from "./policy.js"; - -type AgentHarnessAvailabilityParams = { - provider?: string; - modelId?: string; - modelProvider?: AgentHarnessSupportContext["modelProvider"]; - config?: OpenClawConfig; - agentId?: string; - sessionKey?: string; - env?: NodeJS.ProcessEnv; - preparedModelProvider?: boolean; -}; +export { resolveAvailableAgentHarnessPolicy } from "./availability.js"; type AgentHarnessSelectionParams = { provider: string; @@ -99,11 +82,6 @@ export type AgentHarnessPreparedModelProvider = NonNullable< AgentHarnessSupportContext["modelProvider"] >; -type AgentHarnessAvailabilityDecision = - | { kind: "available"; policy: AgentHarnessPolicy } - | { kind: "implicit-unavailable"; policy: AgentHarnessPolicy } - | { kind: "implicit-unsupported"; policy: AgentHarnessPolicy }; - const PLUGIN_HARNESS_SENDER_DENY_ALL_PROMPT = "Tool and file actions are disabled for this sender by chat policy. If asked to edit files or use tools, say this sender is not allowed by policy; do not imply retrying will help."; const PLUGIN_HARNESS_GROUP_DENY_ALL_PROMPT = @@ -191,56 +169,6 @@ function listPluginAgentHarnesses(): AgentHarness[] { return listRegisteredAgentHarnesses().map((entry) => entry.harness); } -export function resolveAvailableAgentHarnessPolicy( - params: AgentHarnessAvailabilityParams, -): AgentHarnessPolicy { - return resolveAgentHarnessAvailabilityDecision(params).policy; -} - -function resolveAgentHarnessAvailabilityDecision( - params: AgentHarnessAvailabilityParams, -): AgentHarnessAvailabilityDecision { - const policy = resolveConfiguredAgentHarnessPolicy({ - ...params, - modelApi: params.modelProvider?.api, - modelBaseUrl: params.modelProvider?.baseUrl, - requestTransportOverrides: params.modelProvider?.requestTransportOverrides, - }); - if (policy.runtime !== "codex" || policy.runtimeSource !== "implicit") { - return { kind: "available", policy }; - } - const codexHarness = getRegisteredAgentHarness("codex"); - if (!codexHarness) { - return { - kind: "implicit-unavailable", - policy: { ...policy, runtime: "openclaw" }, - }; - } - const provider = params.provider?.trim(); - if (!provider) { - return { kind: "available", policy }; - } - const support = codexHarness.harness.supports( - buildAgentHarnessSupportContext({ - provider, - modelId: params.modelId, - modelProvider: params.modelProvider, - requestedRuntime: policy.runtime, - config: params.config, - agentId: params.agentId, - sessionKey: params.sessionKey, - preparedModelProvider: params.preparedModelProvider, - }), - ); - if (support.supported) { - return { kind: "available", policy }; - } - return { - kind: "implicit-unsupported", - policy: { ...policy, runtime: "openclaw" }, - }; -} - export function selectAgentHarness(params: AgentHarnessSelectionParams): AgentHarness { return selectAgentHarnessDecision(params).harness; } @@ -290,47 +218,30 @@ export function agentHarnessExposesOpenClawTools(harnessId: string): boolean { function selectAgentHarnessDecision( params: AgentHarnessSelectionDecisionParams, ): AgentHarnessSelectionDecision { - const pinnedHarnessId = normalizeOptionalAgentRuntimeId(params.agentHarnessId); - const runtimeOverride = normalizeOptionalAgentRuntimeId(params.agentHarnessRuntimeOverride); - const requestedRuntimeOverride = pinnedHarnessId ?? runtimeOverride; - const selectedRuntimeOverride = - requestedRuntimeOverride && !isDefaultAgentRuntimeId(requestedRuntimeOverride) - ? requestedRuntimeOverride - : undefined; - // Persisted ownership and explicit model policy are already authoritative. - // Avoid probing implicit harness support before those overrides are applied. - const availability: AgentHarnessAvailabilityDecision = selectedRuntimeOverride - ? { - kind: "available", - policy: resolveConfiguredAgentHarnessPolicy({ - ...params, - modelApi: params.modelProvider?.api, - modelBaseUrl: params.modelProvider?.baseUrl, - requestTransportOverrides: params.modelProvider?.requestTransportOverrides, - }), - } - : resolveAgentHarnessAvailabilityDecision(params); - const resolvedPolicy = availability.policy; - const policy = selectedRuntimeOverride - ? ({ - ...resolvedPolicy, - runtime: selectedRuntimeOverride, - runtimeSource: "model", - } as AgentHarnessPolicy) - : resolvedPolicy; + // Keep the probed instance: owner validation must reject replacement during supports(). + const pluginHarnesses = listPluginAgentHarnesses(); + const availability = resolveAgentHarnessAvailabilityDecision({ + ...params, + resolveProviderOwnership: () => + resolveProviderRefOwnership({ + provider: params.provider, + config: params.config, + }), + }); + const policy = availability.policy; // OpenClaw's built-in harness is intentionally not part of the plugin candidate list. Explicit plugin // runtimes fail closed unless the selected plugin declares OpenClaw as a lossless fallback. - const pluginHarnesses = listPluginAgentHarnesses(); const openClawHarness = createOpenClawAgentHarness(); const runtime = policy.runtime; if (runtime === "openclaw") { - const selectedReason = selectedRuntimeOverride - ? "forced_openclaw" - : availability.kind === "implicit-unavailable" + const selectedReason = + availability.kind === "implicit-unavailable" ? "implicit_plugin_unavailable_openclaw" : availability.kind === "implicit-unsupported" ? "implicit_plugin_unsupported_openclaw" - : "forced_openclaw"; + : availability.kind === "declared-fallback" + ? "plugin_declared_fallback_openclaw" + : "forced_openclaw"; return buildSelectionDecision({ harness: openClawHarness, policy, @@ -341,55 +252,17 @@ function selectAgentHarnessDecision( if (runtime !== "auto") { const forced = pluginHarnesses.find((entry) => entry.id === runtime); if (forced) { - // A persisted harness owns the native transcript before route/auth preparation. The - // finalized entrypoint sets preparedModelProvider and must always revalidate that owner. - if (pinnedHarnessId === runtime && !params.preparedModelProvider) { - return buildSelectionDecision({ - harness: forced, - policy, - selectedReason: "forced_plugin", - candidates: listHarnessCandidates(pluginHarnesses), - }); - } - const supportContext = buildAgentHarnessSupportContext({ - provider: params.provider, - modelId: params.modelId, - modelProvider: params.modelProvider, - requestedRuntime: runtime, - config: params.config, - agentId: params.agentId, - sessionKey: params.sessionKey, - preparedModelProvider: params.preparedModelProvider, - providerOwnership: resolveProviderRefOwnership({ - provider: params.provider, - config: params.config, - }), - }); - const support = forced.supports(supportContext); - if (support.supported) { - return buildSelectionDecision({ - harness: forced, - policy, - selectedReason: "forced_plugin", - candidates: listHarnessCandidates(pluginHarnesses), - }); - } - if (support.fallbackRuntime === "openclaw") { - if (policy.forcedByEnvironment) { + const support = availability.support; + if (!support || support.supported || support.fallbackRuntime === "openclaw") { + if (support && !support.supported) { log.info( `agent harness selected requested=${runtime} selected=${forced.id} reason=private_qa_forced_runtime`, ); - return buildSelectionDecision({ - harness: forced, - policy, - selectedReason: "forced_plugin", - candidates: listHarnessCandidates(pluginHarnesses), - }); } return buildSelectionDecision({ - harness: openClawHarness, - policy: { ...policy, runtime: "openclaw" }, - selectedReason: "plugin_declared_fallback_openclaw", + harness: forced, + policy, + selectedReason: "forced_plugin", candidates: listHarnessCandidates(pluginHarnesses), }); } @@ -410,17 +283,6 @@ function selectAgentHarnessDecision( }.`, ); } - if (runtime === "codex" && policy.runtimeSource === "implicit") { - return buildSelectionDecision({ - harness: openClawHarness, - policy: { - ...policy, - runtime: "openclaw", - }, - selectedReason: "implicit_plugin_unavailable_openclaw", - candidates: listHarnessCandidates(pluginHarnesses), - }); - } if ( isCliRuntimeAliasForProvider({ runtime, diff --git a/src/agents/thinking-runtime.test.ts b/src/agents/thinking-runtime.test.ts index c45af50a74dd..21a39e4113ac 100644 --- a/src/agents/thinking-runtime.test.ts +++ b/src/agents/thinking-runtime.test.ts @@ -148,17 +148,29 @@ describe("resolveEffectiveAgentRuntime", () => { expect(supports).not.toHaveBeenCalled(); }); - it("prefers explicit session overrides", () => { - const cfg = openAIConfig("openclaw"); - expect( - resolveEffectiveAgentRuntime({ - cfg, - provider: "openai", - modelId: "gpt-5.6-luna", - sessionEntry: { agentRuntimeOverride: "codex", agentHarnessId: "openclaw" }, - }), - ).toBe("codex"); - }); + it.each([false, true])( + "projects explicit session overrides with declared fallback=%s", + (fallback) => { + registerAgentHarness({ + id: "codex", + label: "Codex", + supports: () => + fallback ? { supported: false, fallbackRuntime: "openclaw" } : { supported: true }, + runAttempt: async () => { + throw new Error("projection must not execute"); + }, + }); + const cfg = openAIConfig("openclaw"); + expect( + resolveEffectiveAgentRuntime({ + cfg, + provider: "openai", + modelId: "gpt-5.6-luna", + sessionEntry: { agentRuntimeOverride: "codex", agentHarnessId: "openclaw" }, + }), + ).toBe(fallback ? "openclaw" : "codex"); + }, + ); it("ignores legacy harness ids when choosing a runtime", () => { const cfg = openAIConfig("openclaw"); diff --git a/src/agents/thinking-runtime.ts b/src/agents/thinking-runtime.ts index 6cb6dc0c4d01..4639ba46fef4 100644 --- a/src/agents/thinking-runtime.ts +++ b/src/agents/thinking-runtime.ts @@ -9,7 +9,7 @@ import { /** Resolves the concrete harness runtime that owns the next agent turn. */ import type { SessionEntry } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { resolveAgentHarnessPolicy } from "./harness/policy.js"; +import { resolveAvailableAgentHarnessPolicy } from "./harness/availability.js"; import { resolveAutoAgentHarnessId } from "./harness/support.js"; import { resolveSessionRuntimeOverrideForProvider } from "./session-runtime-compat.js"; @@ -65,24 +65,27 @@ export function resolveEffectiveAgentRuntime(params: { modelBaseUrl?: unknown; agentId?: string; sessionKey?: string; - sessionEntry?: Pick; + sessionEntry?: Pick< + SessionEntry, + "agentHarnessId" | "agentRuntimeOverride" | "modelSelectionLocked" + >; }): string { const sessionRuntime = resolveSessionRuntimeOverrideForProvider({ provider: params.provider, entry: params.sessionEntry, cfg: params.cfg, }); - const runtime = - sessionRuntime ?? - resolveAgentHarnessPolicy({ - provider: params.provider, - modelId: params.modelId, - modelApi: params.modelApi, - modelBaseUrl: params.modelBaseUrl, - config: params.cfg, - agentId: params.agentId, - sessionKey: params.sessionKey, - }).runtime; + const runtime = resolveAvailableAgentHarnessPolicy({ + ...params, + mode: "projection", + config: params.cfg, + modelProvider: { + api: params.modelApi ?? undefined, + baseUrl: normalizeOptionalString(params.modelBaseUrl), + }, + agentHarnessId: params.sessionEntry?.modelSelectionLocked ? sessionRuntime : undefined, + agentHarnessRuntimeOverride: sessionRuntime, + }).runtime; if (runtime === "auto") { // Reuse the loaded harness registry without triggering plugin discovery. // This keeps thinking policy aligned with the harness that would own the turn. diff --git a/src/commands/doctor-node-hosting-preconditions.test.ts b/src/commands/doctor-node-hosting-preconditions.test.ts index b2b33469a5a5..ed564e4976ca 100644 --- a/src/commands/doctor-node-hosting-preconditions.test.ts +++ b/src/commands/doctor-node-hosting-preconditions.test.ts @@ -209,7 +209,7 @@ describe("node-hosting preconditions", () => { ).toEqual([]); }); - it("does not activate plugins or reject an unknown external runtime", () => { + it.each(["codex", "auto"])("does not activate plugins or reject a cold %s runtime", (runtime) => { resetPluginRuntimeStateForTest(); expect( @@ -218,7 +218,7 @@ describe("node-hosting preconditions", () => { agents: { defaults: { model: "openai/gpt-5.6-sol", - models: { "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } } }, + models: { "openai/gpt-5.6-sol": { agentRuntime: { id: runtime } } }, }, }, }), diff --git a/src/commands/sessions.acp-runtime-metadata.test.ts b/src/commands/sessions.acp-runtime-metadata.test.ts index 3c0162271f05..7e4600136797 100644 --- a/src/commands/sessions.acp-runtime-metadata.test.ts +++ b/src/commands/sessions.acp-runtime-metadata.test.ts @@ -1,9 +1,15 @@ // Sessions ACP runtime metadata tests cover session-owned runtime overlays. -import { describe, expect, it } from "vitest"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveCurrentSessionAgentRuntimeMetadata, resolveModelAgentRuntimeMetadata, } from "../agents/agent-runtime-metadata.js"; +import { + clearAgentHarnesses, + listRegisteredAgentHarnesses, + registerAgentHarness, +} from "../agents/harness/registry.js"; +import { restoreRegisteredAgentHarnesses } from "../agents/harness/registry.test-support.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; @@ -36,7 +42,87 @@ function computeSessionAgentRuntime(params: { }); } +const registeredHarnesses = listRegisteredAgentHarnesses(); +beforeEach(() => clearAgentHarnesses()); +afterAll(() => restoreRegisteredAgentHarnesses(registeredHarnesses)); + describe("session ACP runtime metadata", () => { + it.each(["model", "provider", "session-key", "implicit"] as const)( + "projects a declared fallback for the next turn while retaining %s attribution", + (source) => { + const supports = vi.fn((_context: unknown) => ({ + supported: false as const, + fallbackRuntime: "openclaw" as const, + })); + registerAgentHarness({ + id: "codex", + label: "Codex", + supports, + runAttempt: async () => { + throw new Error("projection must not execute"); + }, + }); + const cfg: OpenClawConfig = { + models: { + providers: { + openai: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + ...(source === "provider" ? { agentRuntime: { id: "codex" } } : {}), + models: [ + { + id: "gpt-5.6-sol", + name: "Sol", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8192, + compat: { supportsStore: false }, + }, + ], + }, + }, + }, + agents: { + defaults: { + models: { + "openai/gpt-5.6-sol": source === "model" ? { agentRuntime: { id: "codex" } } : {}, + }, + }, + }, + }; + const params = { + cfg, + agentId: "main", + provider: "openai", + model: "gpt-5.6-sol", + sessionKey: NON_ACP_SESSION_KEY, + sessionEntry: { + agentHarnessId: "codex", + ...(source === "session-key" ? { agentRuntimeOverride: "codex" } : {}), + }, + }; + expect(resolveCurrentSessionAgentRuntimeMetadata(params)).toEqual({ id: "openclaw", source }); + expect( + resolveCurrentSessionAgentRuntimeMetadata({ + ...params, + sessionEntry: { ...params.sessionEntry, modelSelectionLocked: true }, + }), + ).toEqual({ id: "codex", source: "session" }); + expect( + resolveCurrentSessionAgentRuntimeMetadata({ + ...params, + sessionKey: "agent:main:acp:runtime-test", + acpRuntime: true, + }), + ).toEqual({ id: "acpx", source: "session-key" }); + // Projection consumes registered support only; it never discovers provider ownership. + for (const [context] of supports.mock.calls) { + expect(context).not.toHaveProperty("providerOwnerStatus"); + } + }, + ); it("prefers an explicit ACP backend", () => { const agentRuntime = computeSessionAgentRuntime({ cfg: buildConfigWithoutAgentRuntimePolicy(), @@ -97,28 +183,31 @@ describe("session ACP runtime metadata", () => { expect(agentRuntime).toEqual({ id: "codex", source: "session" }); }); - it("reports current model policy instead of an unlocked historical producer", () => { - const agentRuntime = resolveCurrentSessionAgentRuntimeMetadata({ - cfg: { - agents: { - defaults: { - models: { - "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } }, + it.each([undefined, "codex"])( + "reports current %s policy instead of an unlocked historical producer", + (runtime) => { + const agentRuntime = resolveCurrentSessionAgentRuntimeMetadata({ + cfg: { + agents: { + defaults: { + models: { + "openai/gpt-5.6-sol": runtime ? { agentRuntime: { id: runtime } } : {}, + }, }, }, + } as OpenClawConfig, + agentId: "main", + provider: "openai", + model: "gpt-5.6-sol", + sessionKey: NON_ACP_SESSION_KEY, + sessionEntry: { + agentHarnessId: "openclaw", }, - } as OpenClawConfig, - agentId: "main", - provider: "openai", - model: "gpt-5.6-sol", - sessionKey: NON_ACP_SESSION_KEY, - sessionEntry: { - agentHarnessId: "openclaw", - }, - }); + }); - expect(agentRuntime).toEqual({ id: "codex", source: "model" }); - }); + expect(agentRuntime).toEqual({ id: "codex", source: runtime ? "model" : "implicit" }); + }, + ); it("keeps an explicit compatible runtime override", () => { const agentRuntime = resolveCurrentSessionAgentRuntimeMetadata({ diff --git a/src/config/model-provider-config.test.ts b/src/config/model-provider-config.test.ts index f1356af7d0b3..64525516e887 100644 --- a/src/config/model-provider-config.test.ts +++ b/src/config/model-provider-config.test.ts @@ -50,15 +50,37 @@ describe("resolveMergedModelProviderModels", () => { }); describe("resolveModelProviderRouteOverridePresence", () => { - it("treats authored model compatibility as request behavior", () => { + it.each([ + ["empty metadata", {}, "none"], + ["affirmative reasoning support", { supportsReasoningEffort: true }, "none"], + [ + "native reasoning efforts", + { supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, + "none", + ], + [ + "combined reasoning metadata", + { supportsReasoningEffort: true, supportedReasoningEfforts: ["low", "high"] }, + "none", + ], + ["disabled reasoning", { supportsReasoningEffort: false }, "present"], + ["malformed reasoning support", { supportsReasoningEffort: "true" }, "present"], + ["empty effort list", { supportedReasoningEfforts: [] }, "present"], + ["non-native effort", { supportedReasoningEfforts: ["high", "custom"] }, "present"], + ["disabled effort", { supportedReasoningEfforts: ["none"] }, "present"], + ["malformed effort", { supportedReasoningEfforts: ["high", false] }, "present"], + ["store behavior", { supportsStore: false }, "present"], + [ + "mixed metadata and behavior", + { supportsReasoningEffort: true, supportedReasoningEfforts: ["high"], supportsStore: false }, + "present", + ], + ])("classifies %s without discarding request behavior", (_label, compat, expected) => { const config = { models: { providers: { openai: { - models: [ - { id: "gpt-5.5", compat: { supportsStore: false } }, - { id: "gpt-5.5-empty", compat: {} }, - ], + models: [{ id: "gpt-5.6-sol", compat }], }, }, }, @@ -67,17 +89,10 @@ describe("resolveModelProviderRouteOverridePresence", () => { expect( resolveModelProviderRouteOverridePresence({ provider: "openai", - modelId: "gpt-5.5", + modelId: "gpt-5.6-sol", authoredConfig: config, }), - ).toBe("present"); - expect( - resolveModelProviderRouteOverridePresence({ - provider: "openai", - modelId: "gpt-5.5-empty", - authoredConfig: config, - }), - ).toBe("none"); + ).toBe(expected); }); it("treats a provider request timeout as authored behavior", () => { diff --git a/src/config/model-provider-config.ts b/src/config/model-provider-config.ts index 267358f9317b..61f2e3eb9c74 100644 --- a/src/config/model-provider-config.ts +++ b/src/config/model-provider-config.ts @@ -129,6 +129,28 @@ function hasNonEmptyRecord(value: unknown): boolean { return record !== undefined && Object.keys(record).length > 0; } +function hasRequestCompatOverrides(compat: ModelDefinitionConfig["compat"]): boolean { + return Object.entries(compat ?? {}).some(([key, value]) => { + // Native runtimes consume affirmative reasoning capabilities as turn controls. + // Disabling reasoning, custom labels, and payload shaping still require the authored adapter. + if (key === "supportsReasoningEffort") { + return value !== true; + } + if (key === "supportedReasoningEfforts") { + return !( + Array.isArray(value) && + value.length > 0 && + value.every( + (effort) => + typeof effort === "string" && + /^(minimal|low|medium|high|xhigh|max|ultra)$/u.test(effort), + ) + ); + } + return true; + }); +} + /** Projects authored request behavior without exposing values or local commands. */ export function resolveModelProviderRouteOverridePresence(params: { provider: string; @@ -166,7 +188,7 @@ export function resolveModelProviderRouteOverridePresence(params: { return configuredModel && (hasNonEmptyRecord(configuredModel.headers) || hasNonEmptyRecord(configuredModel.params) || - hasNonEmptyRecord(configuredModel.compat)) + hasRequestCompatOverrides(configuredModel.compat)) ? "present" : "none"; } diff --git a/src/gateway/session-utils-model.acp-owner.test.ts b/src/gateway/session-utils-model.acp-owner.test.ts index f6afd4dae0d5..14c6c407896c 100644 --- a/src/gateway/session-utils-model.acp-owner.test.ts +++ b/src/gateway/session-utils-model.acp-owner.test.ts @@ -1,5 +1,11 @@ // Session model projection tests verify ACP metadata reads preserve row ownership. -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearAgentHarnesses, + listRegisteredAgentHarnesses, + registerAgentHarness, +} from "../agents/harness/registry.js"; +import { restoreRegisteredAgentHarnesses } from "../agents/harness/registry.test-support.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; const readAcpSessionMeta = vi.hoisted(() => vi.fn(() => undefined)); @@ -9,9 +15,73 @@ vi.mock("../acp/runtime/session-meta.js", () => ({ readAcpSessionMeta })); import { resolveGatewaySessionThinkingProjectionInternal } from "./session-utils-model.js"; describe("resolveGatewaySessionThinkingProjectionInternal", () => { + const registeredHarnesses = listRegisteredAgentHarnesses(); beforeEach(() => { + clearAgentHarnesses(); readAcpSessionMeta.mockClear(); }); + afterAll(() => restoreRegisteredAgentHarnesses(registeredHarnesses)); + + it.each([false, true])( + "projects the effective model runtime with authored transport=%s", + (transportOverride) => { + registerAgentHarness({ + id: "codex", + label: "Codex", + supports: (ctx) => + ctx.modelProvider?.requestTransportOverrides === "present" + ? { supported: false, fallbackRuntime: "openclaw" } + : { supported: true }, + runAttempt: async () => { + throw new Error("projection must not execute"); + }, + }); + const cfg: OpenClawConfig = { + agents: { + defaults: { models: { "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } } } }, + }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-responses", + models: [ + { + id: "gpt-5.6-sol", + name: "Sol", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + maxTokens: 8192, + compat: { + supportsReasoningEffort: true, + supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"], + ...(transportOverride ? { supportsStore: false } : {}), + }, + }, + ], + }, + }, + }, + }; + const projection = resolveGatewaySessionThinkingProjectionInternal({ + cfg, + agentId: "main", + provider: "openai", + model: "gpt-5.6-sol", + sessionKey: "agent:main:main", + entry: { + sessionId: "runtime-projection", + updatedAt: 1, + agentHarnessId: transportOverride ? "codex" : "openclaw", + }, + }); + expect(projection.agentRuntime).toEqual({ + id: transportOverride ? "openclaw" : "codex", + source: "model", + }); + }, + ); it("reads bare-key ACP metadata under the resolved row owner", () => { const cfg: OpenClawConfig = { diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index 13464df6b7f3..679e559afc0a 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -259,22 +259,26 @@ export function getActivePluginRegistryWorkspaceDir(): string | undefined { return state.workspaceDir ?? undefined; } +/** Reads registration/request/active registry precedence without initializing a cold runtime. */ +export function getPluginRegistryForContext(): PluginRegistry | null { + return ( + state.registrationContext?.registry ?? + getPluginRuntimeGatewayRequestScope()?.pluginRegistry ?? + getActivePluginRegistry() + ); +} + export function requireActivePluginRegistry(): PluginRegistry { - if (state.registrationContext) { - return state.registrationContext.registry; + const registry = getPluginRegistryForContext(); + if (registry) { + return registry; } - const scopedRegistry = getPluginRuntimeGatewayRequestScope()?.pluginRegistry; - if (scopedRegistry) { - return scopedRegistry; - } - if (!state.activeRegistry) { - state.activeRegistry = createEmptyPluginRegistry(); - markPluginRegistryActive(state.activeRegistry); - state.activeVersion += 1; - settlePreparedMessageToolCatalog(state.activeRegistry, state.activeVersion); - syncPluginAgentEventBridge(); - } - return asPluginRegistry(state.activeRegistry)!; + state.activeRegistry = createEmptyPluginRegistry(); + markPluginRegistryActive(state.activeRegistry); + state.activeVersion += 1; + settlePreparedMessageToolCatalog(state.activeRegistry, state.activeVersion); + syncPluginAgentEventBridge(); + return state.activeRegistry; } /** Binds unchanged direct SDK facades to the registry currently running synchronous register(). */