mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-06 03:11:40 +00:00
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:
committed by
GitHub
parent
d4a9bbbe87
commit
7793d1d56a
23
src/agents/subagent-launch-authorization.ts
Normal file
23
src/agents/subagent-launch-authorization.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user