diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index 2bb18a32b57e..19b0366bec1b 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -3776,7 +3776,11 @@ describe("embedded attempt harness pinning", () => { sessionHasHistory: true, }); - expectMockArgFields(runEmbeddedAgentMock, { agentHarnessId: undefined }); + expectMockArgFields(runEmbeddedAgentMock, { + agentHarnessId: undefined, + agentHarnessRuntimeOverride: undefined, + agentHarnessRuntimePreparationHint: "codex", + }); }); it("auto-forwards OpenAI Codex auth profiles to default Codex harness runs", async () => { diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index c50ea04a84f1..59293909d910 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -1171,6 +1171,8 @@ export function runAgentAttempt(params: { agentHarnessId: embeddedAgentHarnessOverride, modelSelectionLocked: !isRawModelRun && params.sessionEntry?.modelSelectionLocked === true, agentHarnessRuntimeOverride: embeddedAgentHarnessOverride, + agentHarnessRuntimePreparationHint: + agentHarnessPolicy.runtimeSource !== "implicit" ? agentHarnessPolicy.runtime : undefined, skillsSnapshot: params.skillsSnapshot, prompt: embeddedModelPrompt, transcriptPrompt: embeddedPersistencePrompt, diff --git a/src/agents/embedded-agent-runner/run-orchestrator.ts b/src/agents/embedded-agent-runner/run-orchestrator.ts index 7990ee2a9887..b01fb3197d8a 100644 --- a/src/agents/embedded-agent-runner/run-orchestrator.ts +++ b/src/agents/embedded-agent-runner/run-orchestrator.ts @@ -222,7 +222,9 @@ async function runEmbeddedAgentInternal( provider: params.provider, model: params.model, }); - const requestedHarnessRuntime = params.agentHarnessId ?? params.agentHarnessRuntimeOverride; + const explicitHarnessRuntime = params.agentHarnessId ?? params.agentHarnessRuntimeOverride; + const requestedHarnessRuntime = + explicitHarnessRuntime ?? params.agentHarnessRuntimePreparationHint; const runtimePluginFallbacksOverride = params.modelFallbacksOverride ?? resolveRunModelFallbacksOverride({ @@ -245,8 +247,10 @@ async function runEmbeddedAgentInternal( model: requestedRuntimeSelection.modelId, requestedRouteResolution: "resolved", fallbacksOverride: runtimePluginFallbacksOverride, - }).map((candidate) => - requestedHarnessRuntime + }).map((candidate, index) => + requestedHarnessRuntime && + // Preparation hints apply only to the requested route; fallbacks resolve their own policy. + (index === 0 || explicitHarnessRuntime) ? { provider: candidate.provider, modelId: candidate.model, diff --git a/src/agents/embedded-agent-runner/run.prepared-harness-source-delivery.integration.test.ts b/src/agents/embedded-agent-runner/run.prepared-harness-source-delivery.integration.test.ts index 1edb39fc5271..72223073271a 100644 --- a/src/agents/embedded-agent-runner/run.prepared-harness-source-delivery.integration.test.ts +++ b/src/agents/embedded-agent-runner/run.prepared-harness-source-delivery.integration.test.ts @@ -33,11 +33,13 @@ import { registerAgentHarness } from "../harness/registry.js"; import { makeAttemptResult } from "./run.overflow-compaction.fixture.js"; import { loadRunOverflowCompactionHarness, + mockedAcquireAgentRunPreparedModelRuntime, mockedBuildEmbeddedRunPayloads, mockedGlobalHookRunner, mockedRunEmbeddedAttempt, useOpenAIPlatformAuthFixture, } from "./run.overflow-compaction.harness.js"; +import type { RunEmbeddedAgentInternalParams } from "./run/internal-params.js"; import { buildEmbeddedSystemPrompt } from "./system-prompt.js"; const runnerState = setupAgentRunnerExecutionTestState(); @@ -429,4 +431,109 @@ describe("prepared harness source delivery", () => { ); } }); + + it("prepares a Codex primary without pinning a plugin-owned fallback", async () => { + const { runEmbeddedAgent, registerPreparedAgentHarness } = + await loadRunOverflowCompactionHarness(); + registerPreparedAgentHarness({ + id: "fallback-owner", + label: "Fallback owner", + supports: ({ provider }) => + provider === "custom" ? { supported: true } : { supported: false }, + runAttempt: vi.fn(async () => ({}) as never), + }); + mockedGlobalHookRunner.hasHooks.mockReturnValue(false); + mockedBuildEmbeddedRunPayloads.mockReturnValue([{ text: "primary" }]); + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ assistantTexts: ["primary"] }), + ); + useOpenAIPlatformAuthFixture(); + + const runParams: RunEmbeddedAgentInternalParams = { + agentId: "worker", + sessionId: "runtime-preparation-hint", + workspaceDir: "/tmp/workspace", + prompt: "hello", + runId: "runtime-preparation-hint", + timeoutMs: 30_000, + provider: "openai", + model: "gpt-5.4", + agentHarnessRuntimePreparationHint: "codex", + modelFallbacksOverride: ["fast"], + config: { + agents: { + list: [ + { id: "main", default: true }, + { + id: "worker", + models: { + "openai/gpt-5.4": { agentRuntime: { id: "codex" } }, + "custom/plugin-fallback": { + alias: "fast", + agentRuntime: { id: "fallback-owner" }, + }, + }, + }, + ], + defaults: { + models: { + "custom/global-fallback": { alias: "fast" }, + }, + }, + }, + }, + }; + await runEmbeddedAgent(runParams); + + expect(mockedAcquireAgentRunPreparedModelRuntime).toHaveBeenCalledWith( + expect.objectContaining({ + runtimePluginSelections: [ + { provider: "openai", modelId: "gpt-5.4", runtime: "codex", agentId: "worker" }, + { provider: "custom", modelId: "plugin-fallback", agentId: "worker" }, + ], + }), + expect.any(Object), + ); + }); + + it.each([ + ["agentHarnessId", { agentHarnessId: "codex" }], + ["agentHarnessRuntimeOverride", { agentHarnessRuntimeOverride: "codex" }], + ] as const)("keeps %s authoritative across fallback preparation", async (_label, override) => { + const { runEmbeddedAgent } = await loadRunOverflowCompactionHarness(); + mockedGlobalHookRunner.hasHooks.mockReturnValue(false); + mockedBuildEmbeddedRunPayloads.mockReturnValue([{ text: "primary" }]); + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ assistantTexts: ["primary"] }), + ); + useOpenAIPlatformAuthFixture(); + + await runEmbeddedAgent({ + agentId: "worker", + sessionId: `authoritative-${_label}`, + workspaceDir: "/tmp/workspace", + prompt: "hello", + runId: `authoritative-${_label}`, + timeoutMs: 30_000, + provider: "openai", + model: "gpt-5.4", + modelFallbacksOverride: ["custom/plugin-fallback"], + ...override, + }); + + expect(mockedAcquireAgentRunPreparedModelRuntime).toHaveBeenCalledWith( + expect.objectContaining({ + runtimePluginSelections: [ + { provider: "openai", modelId: "gpt-5.4", runtime: "codex", agentId: "worker" }, + { + provider: "custom", + modelId: "plugin-fallback", + runtime: "codex", + agentId: "worker", + }, + ], + }), + expect.any(Object), + ); + }); }); diff --git a/src/agents/embedded-agent-runner/run/internal-params.ts b/src/agents/embedded-agent-runner/run/internal-params.ts index 449afebd2c76..c93a8c4d0849 100644 --- a/src/agents/embedded-agent-runner/run/internal-params.ts +++ b/src/agents/embedded-agent-runner/run/internal-params.ts @@ -6,6 +6,8 @@ import type { RunEmbeddedAgentParams } from "./params.js"; export type RunEmbeddedAgentInternalParams = RunEmbeddedAgentParams & { onSuccessfulAuthBinding?: (binding: AgentExecutionAuthBinding) => void; authProfileStateMode?: "read-write" | "read-only"; + /** Prepare only the requested candidate with this runtime; fallbacks keep their own policy. */ + agentHarnessRuntimePreparationHint?: string; /** Keep staged setup config and credentials outside configured Gateway ownership. */ preparedModelRuntimeMode?: "isolated-read-only"; /** Ring-zero tool override, supplied only by the OpenClaw orchestrator. */ diff --git a/src/auto-reply/reply/agent-runner-embedded-candidate.ts b/src/auto-reply/reply/agent-runner-embedded-candidate.ts index 4a936f3cbed4..da42ade48a7b 100644 --- a/src/auto-reply/reply/agent-runner-embedded-candidate.ts +++ b/src/auto-reply/reply/agent-runner-embedded-candidate.ts @@ -2,6 +2,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import type { PreparedAgentRunAdmission } from "../../agents/admitted-run-context.js"; import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js"; import type { BootstrapContextRunKind } from "../../agents/bootstrap-mode.js"; +import type { RunEmbeddedAgentInternalParams } from "../../agents/embedded-agent-runner/run/internal-params.js"; import type { RunEmbeddedAgentParams } from "../../agents/embedded-agent-runner/run/params.js"; import { runEmbeddedAgent } from "../../agents/embedded-agent.js"; import type { FastModeAutoProgressState } from "../../agents/fast-mode.js"; @@ -212,7 +213,7 @@ export async function runEmbeddedFallbackCandidate(params: { }); let eventHandler: ReturnType | undefined; const result = await params.timing.measure("embedded_run", () => { - const embeddedRunParams: Parameters[0] = { + const embeddedRunParams: RunEmbeddedAgentInternalParams = { preparedRunAdmission: params.preparedRunAdmission, githubPublicationAvailable: params.githubPublicationAvailable, ...embeddedContext, @@ -233,6 +234,8 @@ export async function runEmbeddedFallbackCandidate(params: { provider: embeddedRunProvider, agentHarnessId: embeddedRunHarnessOverride, agentHarnessRuntimeOverride: embeddedRunHarnessOverride, + agentHarnessRuntimePreparationHint: + agentHarnessPolicy.runtimeSource !== "implicit" ? agentHarnessPolicy.runtime : undefined, fastModeStartedAtMs: params.fastModeStartedAtMs, fastModeAutoProgressState: params.fastModeAutoProgressState, isFinalFallbackAttempt: params.isFinalFallbackAttempt, diff --git a/src/auto-reply/reply/agent-runner-execution-runtime.test.ts b/src/auto-reply/reply/agent-runner-execution-runtime.test.ts index 1007793fdadc..c41ef9439e9f 100644 --- a/src/auto-reply/reply/agent-runner-execution-runtime.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-runtime.test.ts @@ -243,6 +243,53 @@ describe("executeAgentTurn: runtime selection", () => { }); }); + it("forwards model-scoped Codex policy as a worker preparation hint", async () => { + state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({ + result: await params.run("openai", "gpt-5.5"), + provider: "openai", + model: "gpt-5.5", + attempts: [], + })); + state.runEmbeddedAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "worker" }], + meta: {}, + }); + + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const followupRun = createFollowupRun(); + followupRun.run.agentId = "worker"; + followupRun.run.sessionKey = "agent:worker:main"; + followupRun.run.provider = "openai"; + followupRun.run.model = "gpt-5.5"; + followupRun.run.config = { + agents: { + ownership: "explicit", + entries: { + main: {}, + worker: { + models: { + "openai/gpt-5.5": { agentRuntime: { id: "codex" } }, + }, + }, + }, + }, + }; + + const result = await executeAgentTurn({ + ...createMinimalRunAgentTurnParams({ followupRun }), + sessionKey: "agent:worker:main", + }); + + expect(result.kind).toBe("success"); + expectMockCallArgFields(state.runEmbeddedAgentMock, 0, "embedded run params", { + agentId: "worker", + githubPublicationAvailable: false, + agentHarnessId: undefined, + agentHarnessRuntimeOverride: undefined, + agentHarnessRuntimePreparationHint: "codex", + }); + }); + it("keeps catalog-adopted Codex sessions on Codex during heartbeat model overrides", async () => { state.isCliProviderMock.mockImplementation((provider: unknown) => provider === "claude-cli"); state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({