From 7793d1d56a809ebcec8b5197301b1cd9a0089134 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Jul 2026 20:16:34 -0700 Subject: [PATCH] fix(agents): honor subagent model routing (#112393) Forward explicit sessions_spawn model choices through direct and queued child launches, and persist the exact authorization so restart replay cannot silently fall back to the default route. Fixes #91171. Co-authored-by: mikasa0818 <0668001030@xydigit.com> --- src/agents/subagent-launch-authorization.ts | 23 +++++++ .../subagent-registry.persistence.test.ts | 3 + src/agents/subagent-registry.test.ts | 10 ++- src/agents/subagent-registry.ts | 7 +- src/agents/subagent-registry.types.ts | 3 + src/agents/subagent-spawn.test.ts | 64 ++++++++++++++++- src/agents/subagent-spawn.ts | 69 ++++++++++++++----- 7 files changed, 159 insertions(+), 20 deletions(-) create mode 100644 src/agents/subagent-launch-authorization.ts diff --git a/src/agents/subagent-launch-authorization.ts b/src/agents/subagent-launch-authorization.ts new file mode 100644 index 000000000000..9ca527485d30 --- /dev/null +++ b/src/agents/subagent-launch-authorization.ts @@ -0,0 +1,23 @@ +/** Authorization captured when a trusted sessions_spawn request selects a model. */ +export type SubagentLaunchAuthorization = { + modelOverride: { + provider?: string; + model: string; + }; +}; + +/** Applies only the exact model choice authorized during spawn planning. */ +export function applySubagentLaunchAuthorization( + request: Record, + authorization?: SubagentLaunchAuthorization, +): Record { + const modelOverride = authorization?.modelOverride; + if (!modelOverride) { + return request; + } + return { + ...request, + ...(modelOverride.provider ? { provider: modelOverride.provider } : {}), + model: modelOverride.model, + }; +} diff --git a/src/agents/subagent-registry.persistence.test.ts b/src/agents/subagent-registry.persistence.test.ts index a62d23116309..d3949ba671eb 100644 --- a/src/agents/subagent-registry.persistence.test.ts +++ b/src/agents/subagent-registry.persistence.test.ts @@ -580,6 +580,9 @@ describe("subagent registry persistence", () => { structuredOutput: { invalidAttempts: 1, schemaError: "answer is required" }, queuedLaunch: { request: { sessionKey: "agent:worker:subagent:swarm-in-flight" }, + authorization: { + modelOverride: { provider: "openai", model: "gpt-5.4" }, + }, timeoutMs: 1_000, schedulerGroupKey: '["agent:main:main","logical-group"]', maxConcurrent: 8, diff --git a/src/agents/subagent-registry.test.ts b/src/agents/subagent-registry.test.ts index a5ff04d43a62..3743d083cfe6 100644 --- a/src/agents/subagent-registry.test.ts +++ b/src/agents/subagent-registry.test.ts @@ -939,6 +939,9 @@ describe("subagent registry seam flow", () => { completion: { required: false }, queuedLaunch: { request: { sessionKey: `agent:main:subagent:${runId}`, idempotencyKey: runId }, + authorization: { + modelOverride: { provider: "openai", model: "gpt-5.4" }, + }, timeoutMs: 1_000, schedulerGroupKey: '["agent:main:main","logical-group"]', maxConcurrent: 2, @@ -969,7 +972,12 @@ describe("subagent registry seam flow", () => { ); expect(agentCalls).toHaveLength(1); expect(agentCalls[0]?.[0]).toMatchObject({ - params: { idempotencyKey: "run-queued-one" }, + params: { + idempotencyKey: "run-queued-one", + provider: "openai", + model: "gpt-5.4", + }, + scopes: ["operator.admin"], }); }); expect(mod.getSubagentRunByRunId("run-queued-one")?.execution?.status).toBe("running"); diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index 84b88a6ef01e..59a1040dfd36 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -9,6 +9,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { ResolveContextEngineOptions } from "../context-engine/registry.js"; import type { ContextEngine, SubagentEndReason } from "../context-engine/types.js"; import { callGateway } from "../gateway/call.js"; +import { ADMIN_SCOPE } from "../gateway/method-scopes.js"; import type { GatewayRecoveryRuntime } from "../gateway/server-instance-runtime.types.js"; import { getGatewayRecoveryRuntime } from "../gateway/server-recovery-runtime-context.js"; import { getAgentRunContext, onAgentEvent } from "../infra/agent-events.js"; @@ -50,6 +51,7 @@ import { getDeliveryLastError, isDeliverySuspended, } from "./subagent-delivery-state.js"; +import { applySubagentLaunchAuthorization } from "./subagent-launch-authorization.js"; import { SUBAGENT_ENDED_OUTCOME_KILLED, SUBAGENT_ENDED_REASON_COMPLETE, @@ -1143,7 +1145,10 @@ function restoreSubagentRunsOnce() { start: async () => { const response = await subagentRegistryDeps.callGateway({ method: "agent", - params: launch.request, + params: applySubagentLaunchAuthorization(launch.request, launch.authorization), + // Restart replay must restore the trusted launch capability; otherwise + // the queued child silently falls back to its session/default route. + ...(launch.authorization ? { scopes: [ADMIN_SCOPE] } : {}), timeoutMs: launch.timeoutMs, }); const gatewayRunId = readGatewayRunId(response) ?? runId; diff --git a/src/agents/subagent-registry.types.ts b/src/agents/subagent-registry.types.ts index d5b79e0e401c..752b1cc0f2c2 100644 --- a/src/agents/subagent-registry.types.ts +++ b/src/agents/subagent-registry.types.ts @@ -6,6 +6,7 @@ import type { DeliveryContext } from "../utils/delivery-context.types.js"; import type { AgentRunSessionTarget } from "./run-session-target.js"; import type { SubagentRunOutcome } from "./subagent-announce-output.js"; +import type { SubagentLaunchAuthorization } from "./subagent-launch-authorization.js"; import type { SubagentLifecycleEndedReason } from "./subagent-lifecycle-events.js"; import type { SpawnSubagentMode } from "./subagent-spawn.types.js"; @@ -71,6 +72,8 @@ export type SwarmStructuredOutputState = { export type SwarmQueuedLaunch = { request: Record; + /** Exact trusted launch capability, persisted so restart replay cannot lose it. */ + authorization?: SubagentLaunchAuthorization; timeoutMs: number; schedulerGroupKey: string; maxConcurrent: number; diff --git a/src/agents/subagent-spawn.test.ts b/src/agents/subagent-spawn.test.ts index 6f93c304df8e..9e7cf3bfb3bf 100644 --- a/src/agents/subagent-spawn.test.ts +++ b/src/agents/subagent-spawn.test.ts @@ -372,6 +372,35 @@ describe("spawnSubagentDirect seam flow", () => { expect(requireRecord(gatewayRequest("agent").params).idempotencyKey).toBe(result.runId); }); + it("carries explicit model authorization through a queued collector launch", async () => { + hoisted.configOverride = createConfigOverride({ tools: { swarm: true } }); + + const result = await spawnSubagentDirect( + { + task: "collect with the requested model", + model: "openai/gpt-5.4", + collect: true, + }, + { agentSessionKey: "agent:main:main", requesterRunId: "parent-run" }, + ); + + expect(result).toMatchObject({ status: "accepted", modelApplied: true }); + const queuedLaunch = requireRecord(firstRegisteredSubagentRun().queuedLaunch); + const queuedRequest = requireRecord(queuedLaunch.request); + expect(queuedRequest).not.toHaveProperty("provider"); + expect(queuedRequest).not.toHaveProperty("model"); + expect(queuedLaunch).toMatchObject({ + authorization: { + modelOverride: { provider: "openai", model: "gpt-5.4" }, + }, + }); + await vi.waitFor(() => expect(gatewayRequest("agent")).toBeDefined()); + expect(gatewayRequest("agent")).toMatchObject({ + scopes: ["operator.admin"], + params: { provider: "openai", model: "gpt-5.4" }, + }); + }); + it("aborts a collector cancelled while its gateway launch is in flight", async () => { hoisted.configOverride = createConfigOverride({ tools: { swarm: true } }); hoisted.startQueuedSubagentRunMock.mockReturnValue(false); @@ -892,7 +921,10 @@ describe("spawnSubagentDirect seam flow", () => { ); const agentRequest = gatewayRequest("agent"); const agentParams = requireRecord(agentRequest.params); + expect(agentRequest.scopes).toEqual(["operator.admin"]); expect(agentParams.sessionKey).toBe(childSessionKey); + expect(agentParams.provider).toBe("openai"); + expect(agentParams.model).toBe("gpt-5.4"); expect(agentParams.cleanupBundleMcpOnRunEnd).toBe(true); }); @@ -928,6 +960,37 @@ describe("spawnSubagentDirect seam flow", () => { timeoutMs: expect.any(Number), }), ); + const agentDispatch = hoisted.dispatchGatewayMethodInProcessMock.mock.calls.find( + ([method]) => method === "agent", + ); + const agentParams = requireRecord(agentDispatch?.[1]); + const agentOptions = requireRecord(agentDispatch?.[2]); + expect(agentParams.provider).toBeUndefined(); + expect(agentParams.model).toBeUndefined(); + expect(agentOptions.allowSyntheticModelOverride).toBeUndefined(); + }); + + it("authorizes explicit model overrides for in-process child launches", async () => { + hoisted.hasInProcessGatewayContextMock.mockReturnValue(true); + hoisted.callGatewayMock.mockRejectedValue(new Error("unexpected websocket gateway call")); + hoisted.dispatchGatewayMethodInProcessMock.mockImplementation(async (method: string) => { + return method === "agent" ? { runId: "run-in-process-model" } : { ok: true }; + }); + + const result = await spawnSubagentDirect( + { task: "spawn on the requested model", model: "openai/gpt-5.4" }, + { agentSessionKey: "agent:main:main" }, + ); + + expect(result).toMatchObject({ status: "accepted", runId: "run-in-process-model" }); + const agentDispatch = hoisted.dispatchGatewayMethodInProcessMock.mock.calls.find( + ([method]) => method === "agent", + ); + expect(agentDispatch?.[1]).toMatchObject({ provider: "openai", model: "gpt-5.4" }); + expect(agentDispatch?.[2]).toMatchObject({ + allowSyntheticModelOverride: true, + forceSyntheticClient: true, + }); }); it("keeps admin-scoped cleanup on in-process spawn failure", async () => { @@ -1599,7 +1662,6 @@ describe("spawnSubagentDirect seam flow", () => { const result = await spawnSubagentDirect( { task: "verify per-method scope routing", - model: "openai/gpt-5.4", }, { agentSessionKey: "agent:main:main", diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index d8845b11b11b..c6813f04cbdd 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -69,6 +69,10 @@ import { type SubagentAttachmentReceiptFile, } from "./subagent-attachments.js"; import { buildSubagentInitialUserMessage } from "./subagent-initial-user-message.js"; +import { + applySubagentLaunchAuthorization, + type SubagentLaunchAuthorization, +} from "./subagent-launch-authorization.js"; import { completeCollectorLaunchCleanup, listSwarmRunsForGroup, @@ -235,6 +239,7 @@ type SpawnSubagentResult = { async function callSubagentGateway( params: Parameters[0], + authorization?: SubagentLaunchAuthorization, ): Promise>> { // Subagent lifecycle requires methods spanning multiple scope tiers // (sessions.patch / sessions.delete → admin, agent → write). When each call @@ -246,18 +251,29 @@ async function callSubagentGateway( // Only admin-requiring calls are pinned to ADMIN_SCOPE; other methods (e.g. // "agent" -> write) keep their least-privilege scope. The params-aware // resolver keeps spawn-metadata sessions.patch calls on the admin tier. + const authorizedParams = + params.params != null && typeof params.params === "object" && !Array.isArray(params.params) + ? applySubagentLaunchAuthorization(params.params as Record, authorization) + : params.params; const leastPrivilegeScopes = resolveLeastPrivilegeOperatorScopesForMethod( params.method, - params.params, + authorizedParams, ); + const allowModelOverride = authorization !== undefined; + const hasInProcessGateway = subagentSpawnDeps.hasInProcessGatewayContext(); + const needsOutOfProcessModelOverrideAuth = allowModelOverride && !hasInProcessGateway; const scopes = - params.scopes ?? (leastPrivilegeScopes.includes(ADMIN_SCOPE) ? [ADMIN_SCOPE] : undefined); + params.scopes ?? + (leastPrivilegeScopes.includes(ADMIN_SCOPE) || needsOutOfProcessModelOverrideAuth + ? [ADMIN_SCOPE] + : undefined); const request = { ...params, + params: authorizedParams, ...(scopes != null ? { scopes } : {}), }; if ( - subagentSpawnDeps.hasInProcessGatewayContext() && + hasInProcessGateway && request.params != null && typeof request.params === "object" && !Array.isArray(request.params) @@ -272,6 +288,7 @@ async function callSubagentGateway( request.params as Record, { expectFinal: request.expectFinal, + ...(allowModelOverride ? { allowSyntheticModelOverride: true } : {}), ...(forceSyntheticClient ? { forceSyntheticClient: true } : {}), ...(typeof request.timeoutMs === "number" ? { timeoutMs: request.timeoutMs } : {}), ...(scopes != null ? { syntheticScopes: scopes } : {}), @@ -1299,6 +1316,16 @@ export async function spawnSubagentDirect( }; } const { resolvedModel, thinkingOverride } = plan; + const resolvedLaunchModel = splitModelRef(resolvedModel); + const launchAuthorization: SubagentLaunchAuthorization | undefined = + modelOverride?.trim() && resolvedLaunchModel.model + ? { + modelOverride: { + ...(resolvedLaunchModel.provider ? { provider: resolvedLaunchModel.provider } : {}), + model: resolvedLaunchModel.model, + }, + } + : undefined; if (params.outputSchema) { const outputModelError = await resolveCollectorOutputModelError({ cfg, @@ -1586,12 +1613,28 @@ export async function spawnSubagentDirect( : {}), ...publicSpawnedMetadata, }; + const childLaunch = { + request: childLaunchRequest, + ...(launchAuthorization ? { authorization: launchAuthorization } : {}), + timeoutMs: resolveSubagentAgentGatewayTimeoutMs(runTimeoutSeconds), + }; + const queuedLaunch = + params.collect && swarmSchedulerGroupKey + ? { + ...childLaunch, + schedulerGroupKey: swarmSchedulerGroupKey, + maxConcurrent: swarmConfig.maxConcurrent, + } + : undefined; const launchChildRun = async () => - await callSubagentGateway({ - method: "agent", - params: childLaunchRequest, - timeoutMs: resolveSubagentAgentGatewayTimeoutMs(runTimeoutSeconds), - }); + await callSubagentGateway( + { + method: "agent", + params: childLaunch.request, + timeoutMs: childLaunch.timeoutMs, + }, + childLaunch.authorization, + ); // "spawned"/"started" hooks mean an accepted Gateway run. Direct runs emit // after the shared pipeline; queued collectors emit from the scheduler start. @@ -1765,15 +1808,7 @@ export async function spawnSubagentDirect( : undefined, outputSchema: params.outputSchema, groupId: swarmGroupId, - queuedLaunch: - params.collect && swarmSchedulerGroupKey - ? { - request: childLaunchRequest, - timeoutMs: resolveSubagentAgentGatewayTimeoutMs(runTimeoutSeconds), - schedulerGroupKey: swarmSchedulerGroupKey, - maxConcurrent: swarmConfig.maxConcurrent, - } - : undefined, + queuedLaunch, queued: params.collect === true, attachmentsDir: attachmentAbsDir, attachmentsRootDir: attachmentRootDir,