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>
This commit is contained in:
Peter Steinberger
2026-07-21 20:16:34 -07:00
committed by GitHub
parent d4a9bbbe87
commit 7793d1d56a
7 changed files with 159 additions and 20 deletions

View File

@@ -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<string, unknown>,
authorization?: SubagentLaunchAuthorization,
): Record<string, unknown> {
const modelOverride = authorization?.modelOverride;
if (!modelOverride) {
return request;
}
return {
...request,
...(modelOverride.provider ? { provider: modelOverride.provider } : {}),
model: modelOverride.model,
};
}

View File

@@ -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,

View File

@@ -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");

View File

@@ -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;

View File

@@ -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<string, unknown>;
/** Exact trusted launch capability, persisted so restart replay cannot lose it. */
authorization?: SubagentLaunchAuthorization;
timeoutMs: number;
schedulerGroupKey: string;
maxConcurrent: number;

View File

@@ -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",

View File

@@ -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<typeof callGateway>[0],
authorization?: SubagentLaunchAuthorization,
): Promise<Awaited<ReturnType<typeof callGateway>>> {
// 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<string, unknown>, 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<string, unknown>,
{
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,