perf(agents): extract subagent spawn planning seams

This commit is contained in:
Peter Steinberger
2026-04-07 06:59:25 +01:00
parent f5c0356b37
commit 7b36fa7672
9 changed files with 589 additions and 701 deletions

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createPerSenderSessionConfig } from "./test-helpers/session-config.js";
const callGatewayMock = vi.fn();
@@ -61,27 +61,14 @@ function seedDepthTwoAncestryStore(params?: { sessionIds?: boolean }) {
return { depth1, callerKey };
}
async function loadFreshSessionsSpawnModulesForTest() {
vi.resetModules();
vi.doMock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
vi.doMock("../config/config.js", async () => {
const actual =
await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
return {
...actual,
loadConfig: () => configOverride,
};
});
beforeAll(async () => {
({ addSubagentRunForTests, resetSubagentRegistryForTests } =
await import("./subagent-registry.js"));
({ createSessionsSpawnTool } = await import("./tools/sessions-spawn-tool.js"));
}
});
describe("sessions_spawn depth + child limits", () => {
beforeEach(async () => {
await loadFreshSessionsSpawnModulesForTest();
beforeEach(() => {
resetSubagentRegistryForTests();
callGatewayMock.mockClear();
storeTemplatePath = path.join(

View File

@@ -1,410 +1,299 @@
import { beforeEach, describe, expect, it } from "vitest";
import "./test-helpers/fast-core-tools.js";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
getCallGatewayMock,
getSessionsSpawnTool,
resetSessionsSpawnConfigOverride,
setSessionsSpawnConfigOverride,
} from "./openclaw-tools.subagents.sessions-spawn.test-harness.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
createSubagentSpawnTestConfig,
loadSubagentSpawnModuleForTest,
setupAcceptedSubagentGatewayMock,
} from "./subagent-spawn.test-helpers.js";
const callGatewayMock = getCallGatewayMock();
describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
function setAllowAgents(allowAgents: string[]) {
setSessionsSpawnConfigOverride({
session: {
mainKey: "main",
scope: "per-sender",
const hoisted = vi.hoisted(() => ({
callGatewayMock: vi.fn(),
configOverride: {
session: { mainKey: "main", scope: "per-sender" },
tools: {
sessions_spawn: {
attachments: {
enabled: true,
maxFiles: 50,
maxFileBytes: 1 * 1024 * 1024,
maxTotalBytes: 5 * 1024 * 1024,
},
},
},
agents: {
defaults: {
workspace: "/tmp",
},
},
},
}));
let resetSubagentRegistryForTests: typeof import("./subagent-registry.js").resetSubagentRegistryForTests;
let spawnSubagentDirect: typeof import("./subagent-spawn.js").spawnSubagentDirect;
function resolveAgentConfigFromList(cfg: Record<string, unknown>, agentId: string) {
const agents = (cfg.agents as { list?: Array<Record<string, unknown>> } | undefined)?.list;
return agents?.find((entry) => entry.id === agentId);
}
function readSandboxMode(value: unknown) {
return value && typeof value === "object" ? (value as { mode?: string }).mode : undefined;
}
function resolveSandboxRuntimeStatusFromConfig(params: {
cfg?: Record<string, unknown>;
sessionKey?: string;
}) {
const agentId =
typeof params.sessionKey === "string"
? (params.sessionKey.split(":").slice(0, 2).at(1) ?? undefined)
: undefined;
const cfg = params.cfg ?? {};
const targetAgentConfig =
typeof agentId === "string" ? resolveAgentConfigFromList(cfg, agentId) : undefined;
const explicitMode = readSandboxMode(
(targetAgentConfig as { sandbox?: unknown } | undefined)?.sandbox,
);
const defaultMode = readSandboxMode(
(cfg.agents as { defaults?: { sandbox?: unknown } } | undefined)?.defaults?.sandbox,
);
const sandboxed =
explicitMode === "all" ? true : explicitMode === "off" ? false : defaultMode === "all";
return { sandboxed };
}
function setConfig(next: Record<string, unknown>) {
hoisted.configOverride = createSubagentSpawnTestConfig(undefined, next);
}
async function spawn(params: {
task?: string;
agentId?: string;
sandbox?: "inherit" | "require";
requesterSessionKey?: string;
requesterChannel?: string;
}) {
return await spawnSubagentDirect(
{
task: params.task ?? "do thing",
...(params.agentId ? { agentId: params.agentId } : {}),
...(params.sandbox ? { sandbox: params.sandbox } : {}),
},
{
agentSessionKey: params.requesterSessionKey ?? "main",
agentChannel: params.requesterChannel ?? "whatsapp",
},
);
}
beforeAll(async () => {
({ resetSubagentRegistryForTests, spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({
callGatewayMock: hoisted.callGatewayMock,
loadConfig: () => hoisted.configOverride,
resolveAgentConfig: (cfg, agentId) => resolveAgentConfigFromList(cfg, agentId),
resolveSandboxRuntimeStatus: (params: { cfg?: Record<string, unknown>; sessionKey?: string }) =>
resolveSandboxRuntimeStatusFromConfig(params),
resetModules: false,
sessionStorePath: "/tmp/subagent-spawn-allowlist-session-store.json",
}));
});
describe("subagent spawn allowlist + sandbox guards", () => {
beforeEach(() => {
resetSubagentRegistryForTests();
hoisted.callGatewayMock.mockReset();
setupAcceptedSubagentGatewayMock(hoisted.callGatewayMock);
setConfig({});
});
it("only allows same-agent spawns by default", async () => {
const result = await spawn({ agentId: "beta" });
expect(result).toMatchObject({ status: "forbidden" });
expect(hoisted.callGatewayMock).not.toHaveBeenCalled();
});
it("forbids cross-agent spawning when not allowlisted", async () => {
setConfig({
agents: {
list: [
{
id: "main",
subagents: {
allowAgents,
},
},
],
list: [{ id: "main", subagents: { allowAgents: ["alpha"] } }],
},
});
}
const result = await spawn({ agentId: "beta" });
expect(result).toMatchObject({ status: "forbidden" });
expect(hoisted.callGatewayMock).not.toHaveBeenCalled();
});
function setDefaultAllowAgents(allowAgents: string[]) {
setSessionsSpawnConfigOverride({
session: {
mainKey: "main",
scope: "per-sender",
},
it("allows cross-agent spawning when configured", async () => {
setConfig({
agents: {
defaults: {
subagents: {
allowAgents,
},
},
list: [{ id: "main", subagents: { allowAgents: ["beta"] } }],
},
});
const result = await spawn({ agentId: "beta" });
expect(result).toMatchObject({
status: "accepted",
runId: "run-1",
childSessionKey: expect.stringMatching(/^agent:beta:subagent:/),
});
});
it("falls back to default allowlist when agent config omits allowAgents", async () => {
setConfig({
agents: {
defaults: { subagents: { allowAgents: ["beta"] } },
list: [{ id: "main" }],
},
});
}
function mockAcceptedSpawn(acceptedAt: number) {
let childSessionKey: string | undefined;
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
if (request.method === "agent") {
const params = request.params as { sessionKey?: string } | undefined;
childSessionKey = params?.sessionKey;
return { runId: "run-1", status: "accepted", acceptedAt };
}
if (request.method === "agent.wait") {
return { status: "timeout" };
}
return {};
});
return () => childSessionKey;
}
async function executeSpawn(callId: string, agentId: string, sandbox?: "inherit" | "require") {
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
});
return tool.execute(callId, { task: "do thing", agentId, sandbox });
}
function setResearchUnsandboxedConfig(params?: { includeSandboxedDefault?: boolean }) {
setSessionsSpawnConfigOverride({
session: {
mainKey: "main",
scope: "per-sender",
},
agents: {
...(params?.includeSandboxedDefault
? {
defaults: {
sandbox: {
mode: "all",
},
},
}
: {}),
list: [
{
id: "main",
subagents: {
allowAgents: ["research"],
},
},
{
id: "research",
sandbox: {
mode: "off",
},
},
],
},
});
}
async function expectAllowedSpawn(params: {
allowAgents: string[];
agentId: string;
callId: string;
acceptedAt: number;
}) {
setAllowAgents(params.allowAgents);
const getChildSessionKey = mockAcceptedSpawn(params.acceptedAt);
const result = await executeSpawn(params.callId, params.agentId);
expect(result.details).toMatchObject({
const result = await spawn({ agentId: "beta" });
expect(result).toMatchObject({
status: "accepted",
runId: "run-1",
childSessionKey: expect.stringMatching(/^agent:beta:subagent:/),
});
expect(getChildSessionKey()?.startsWith(`agent:${params.agentId}:subagent:`)).toBe(true);
}
});
async function expectInvalidAgentId(callId: string, agentId: string) {
setSessionsSpawnConfigOverride({
session: { mainKey: "main", scope: "per-sender" },
it("allows any agent when allowlist contains *", async () => {
setConfig({
agents: {
list: [{ id: "main", subagents: { allowAgents: ["*"] } }],
},
});
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
});
const result = await tool.execute(callId, { task: "do thing", agentId });
const details = result.details as { status?: string; error?: string };
expect(details.status).toBe("error");
expect(details.error).toContain("Invalid agentId");
expect(callGatewayMock).not.toHaveBeenCalled();
}
beforeEach(() => {
resetSessionsSpawnConfigOverride();
resetSubagentRegistryForTests();
callGatewayMock.mockClear();
const result = await spawn({ agentId: "beta" });
expect(result).toMatchObject({ status: "accepted" });
});
it("sessions_spawn only allows same-agent by default", async () => {
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
});
const result = await tool.execute("call6", {
task: "do thing",
agentId: "beta",
});
expect(result.details).toMatchObject({
status: "forbidden",
});
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("sessions_spawn forbids cross-agent spawning when not allowed", async () => {
setSessionsSpawnConfigOverride({
session: {
mainKey: "main",
scope: "per-sender",
},
it("normalizes allowlisted agent ids", async () => {
setConfig({
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["alpha"],
},
},
],
list: [{ id: "main", subagents: { allowAgents: ["Research"] } }],
},
});
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
});
const result = await tool.execute("call9", {
task: "do thing",
agentId: "beta",
});
expect(result.details).toMatchObject({
status: "forbidden",
});
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("sessions_spawn allows cross-agent spawning when configured", async () => {
await expectAllowedSpawn({
allowAgents: ["beta"],
agentId: "beta",
callId: "call7",
acceptedAt: 5000,
});
});
it("sessions_spawn falls back to default allowlist when agent config omits allowAgents", async () => {
setDefaultAllowAgents(["beta"]);
const getChildSessionKey = mockAcceptedSpawn(5050);
const result = await executeSpawn("call7b", "beta");
expect(result.details).toMatchObject({
status: "accepted",
runId: "run-1",
});
expect(getChildSessionKey()?.startsWith("agent:beta:subagent:")).toBe(true);
});
it("sessions_spawn allows any agent when allowlist is *", async () => {
await expectAllowedSpawn({
allowAgents: ["*"],
agentId: "beta",
callId: "call8",
acceptedAt: 5100,
});
});
it("sessions_spawn normalizes allowlisted agent ids", async () => {
await expectAllowedSpawn({
allowAgents: ["Research"],
agentId: "research",
callId: "call10",
acceptedAt: 5200,
});
const result = await spawn({ agentId: "research" });
expect(result).toMatchObject({ status: "accepted" });
});
it("forbids sandboxed cross-agent spawns that would unsandbox the child", async () => {
setResearchUnsandboxedConfig({ includeSandboxedDefault: true });
const result = await executeSpawn("call11", "research");
const details = result.details as { status?: string; error?: string };
expect(details.status).toBe("forbidden");
expect(details.error).toContain("Sandboxed sessions cannot spawn unsandboxed subagents.");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it('forbids sandbox="require" when target runtime is unsandboxed', async () => {
setResearchUnsandboxedConfig();
const result = await executeSpawn("call12", "research", "require");
const details = result.details as { status?: string; error?: string };
expect(details.status).toBe("forbidden");
expect(details.error).toContain('sandbox="require"');
expect(callGatewayMock).not.toHaveBeenCalled();
});
// ---------------------------------------------------------------------------
// agentId format validation (#31311)
// ---------------------------------------------------------------------------
it("sessions_spawn forbids omit agentId when requireAgentId is configured", async () => {
setSessionsSpawnConfigOverride({
session: {
mainKey: "main",
scope: "per-sender",
},
agents: {
defaults: {
subagents: { requireAgentId: true },
},
list: [{ id: "main" }],
},
});
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
});
const result = await tool.execute("call13", { task: "do thing" });
expect(result.details).toMatchObject({
status: "forbidden",
error: expect.stringContaining("sessions_spawn requires explicit agentId"),
});
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("sessions_spawn allows omit agentId when requireAgentId is false", async () => {
setSessionsSpawnConfigOverride({
session: {
mainKey: "main",
scope: "per-sender",
},
agents: {
defaults: {
subagents: { requireAgentId: false },
},
list: [{ id: "main" }],
},
});
const getChildSessionKey = mockAcceptedSpawn(5300);
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
});
const result = await tool.execute("call14", { task: "do thing" });
expect(result.details).toMatchObject({
status: "accepted",
runId: "run-1",
});
expect(getChildSessionKey()?.startsWith("agent:main:subagent:")).toBe(true);
});
it("sessions_spawn allows explicit agentId when requireAgentId is configured", async () => {
setSessionsSpawnConfigOverride({
session: {
mainKey: "main",
scope: "per-sender",
},
setConfig({
agents: {
defaults: { sandbox: { mode: "all" } },
list: [
{
id: "main",
subagents: {
allowAgents: ["worker"],
requireAgentId: true,
},
},
{ id: "main", subagents: { allowAgents: ["research"] } },
{ id: "research", sandbox: { mode: "off" } },
],
},
});
mockAcceptedSpawn(5400);
const result = await executeSpawn("call15", "worker");
expect(result.details).toMatchObject({
status: "accepted",
runId: "run-1",
});
expect(callGatewayMock).toHaveBeenCalled();
const result = await spawn({ agentId: "research" });
expect(result).toMatchObject({ status: "forbidden" });
expect(String(result.error ?? "")).toContain(
"Sandboxed sessions cannot spawn unsandboxed subagents.",
);
expect(hoisted.callGatewayMock).not.toHaveBeenCalled();
});
it("rejects error-message-like strings as agentId (#31311)", async () => {
setSessionsSpawnConfigOverride({
session: { mainKey: "main", scope: "per-sender" },
it('forbids sandbox="require" when target runtime is unsandboxed', async () => {
setConfig({
agents: {
list: [
{ id: "main", subagents: { allowAgents: ["research"] } },
{ id: "research", sandbox: { mode: "off" } },
],
},
});
const result = await spawn({ agentId: "research", sandbox: "require" });
expect(result).toMatchObject({ status: "forbidden" });
expect(String(result.error ?? "")).toContain('sandbox="require"');
expect(hoisted.callGatewayMock).not.toHaveBeenCalled();
});
it("forbids omitted agentId when requireAgentId is configured", async () => {
setConfig({
agents: {
defaults: { subagents: { requireAgentId: true } },
list: [{ id: "main" }],
},
});
const result = await spawn({});
expect(result).toMatchObject({ status: "forbidden" });
expect(String(result.error ?? "")).toContain("sessions_spawn requires explicit agentId");
expect(hoisted.callGatewayMock).not.toHaveBeenCalled();
});
it("allows omitted agentId when requireAgentId is false", async () => {
setConfig({
agents: {
defaults: { subagents: { requireAgentId: false } },
list: [{ id: "main" }],
},
});
const result = await spawn({});
expect(result).toMatchObject({
status: "accepted",
childSessionKey: expect.stringMatching(/^agent:main:subagent:/),
});
});
it("allows explicit agentId when requireAgentId is configured", async () => {
setConfig({
agents: {
list: [{ id: "main", subagents: { allowAgents: ["worker"], requireAgentId: true } }],
},
});
const result = await spawn({ agentId: "worker" });
expect(result).toMatchObject({ status: "accepted" });
});
it("rejects malformed agentId strings before any gateway work", async () => {
setConfig({
agents: {
list: [{ id: "main", subagents: { allowAgents: ["*"] } }, { id: "research" }],
},
});
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
const result = await spawn({ agentId: "Agent not found: xyz" });
expect(result).toMatchObject({ status: "error" });
expect(String(result.error ?? "")).toContain("Invalid agentId");
expect(String(result.error ?? "")).toContain("agents_list");
expect(hoisted.callGatewayMock).not.toHaveBeenCalled();
});
it("rejects agentId containing path separators", async () => {
setConfig({
agents: {
list: [{ id: "main", subagents: { allowAgents: ["*"] } }],
},
});
const result = await tool.execute("call-err-msg", {
task: "do thing",
agentId: "Agent not found: xyz",
const result = await spawn({ agentId: "../../../etc/passwd" });
expect(result).toMatchObject({ status: "error" });
expect(String(result.error ?? "")).toContain("Invalid agentId");
expect(hoisted.callGatewayMock).not.toHaveBeenCalled();
});
it("rejects agentId exceeding 64 characters", async () => {
setConfig({
agents: {
list: [{ id: "main", subagents: { allowAgents: ["*"] } }],
},
});
const details = result.details as { status?: string; error?: string };
expect(details.status).toBe("error");
expect(details.error).toContain("Invalid agentId");
expect(details.error).toContain("agents_list");
expect(callGatewayMock).not.toHaveBeenCalled();
const result = await spawn({ agentId: "a".repeat(65) });
expect(result).toMatchObject({ status: "error" });
expect(String(result.error ?? "")).toContain("Invalid agentId");
expect(hoisted.callGatewayMock).not.toHaveBeenCalled();
});
it("rejects agentId containing path separators (#31311)", async () => {
await expectInvalidAgentId("call-path", "../../../etc/passwd");
});
it("rejects agentId exceeding 64 characters (#31311)", async () => {
await expectInvalidAgentId("call-long", "a".repeat(65));
});
it("accepts well-formed agentId with hyphens and underscores (#31311)", async () => {
setSessionsSpawnConfigOverride({
session: { mainKey: "main", scope: "per-sender" },
it("accepts well-formed agentId with hyphens and underscores", async () => {
setConfig({
agents: {
list: [{ id: "main", subagents: { allowAgents: ["*"] } }, { id: "my-research_agent01" }],
},
});
mockAcceptedSpawn(1000);
const result = await executeSpawn("call-valid", "my-research_agent01");
const details = result.details as { status?: string };
expect(details.status).toBe("accepted");
const result = await spawn({ agentId: "my-research_agent01" });
expect(result).toMatchObject({ status: "accepted" });
});
it("allows allowlisted-but-unconfigured agentId (#31311)", async () => {
setSessionsSpawnConfigOverride({
session: { mainKey: "main", scope: "per-sender" },
it("allows allowlisted-but-unconfigured agentId", async () => {
setConfig({
agents: {
list: [
{ id: "main", subagents: { allowAgents: ["research"] } },
// "research" is NOT in agents.list — only in allowAgents
],
list: [{ id: "main", subagents: { allowAgents: ["research"] } }],
},
});
mockAcceptedSpawn(1000);
const result = await executeSpawn("call-unconfigured", "research");
const details = result.details as { status?: string };
// Must pass: "research" is in allowAgents even though not in agents.list
expect(details.status).toBe("accepted");
const result = await spawn({ agentId: "research" });
expect(result).toMatchObject({ status: "accepted" });
});
});

View File

@@ -1,306 +1,154 @@
import { beforeEach, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import "./test-helpers/fast-core-tools.js";
import {
getCallGatewayMock,
getSessionsSpawnTool,
resetSessionsSpawnConfigOverride,
setSessionsSpawnConfigOverride,
} from "./openclaw-tools.subagents.sessions-spawn.test-harness.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
import { SUBAGENT_SPAWN_ACCEPTED_NOTE } from "./subagent-spawn.js";
resolveConfiguredSubagentRunTimeoutSeconds,
resolveSubagentModelAndThinkingPlan,
} from "./subagent-spawn-plan.js";
const callGatewayMock = getCallGatewayMock();
type GatewayCall = { method?: string; params?: unknown };
type SessionsSpawnConfigOverride = Parameters<typeof setSessionsSpawnConfigOverride>[0];
function mockLongRunningSpawnFlow(params: {
calls: GatewayCall[];
acceptedAtBase: number;
patch?: (request: GatewayCall) => Promise<unknown>;
}) {
let agentCallCount = 0;
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as GatewayCall;
params.calls.push(request);
if (request.method === "sessions.patch") {
if (params.patch) {
return await params.patch(request);
}
return { ok: true };
}
if (request.method === "agent") {
agentCallCount += 1;
return {
runId: `run-${agentCallCount}`,
status: "accepted",
acceptedAt: params.acceptedAtBase + agentCallCount,
};
}
if (request.method === "agent.wait") {
return { status: "timeout" };
}
if (request.method === "sessions.delete") {
return { ok: true };
}
return {};
});
function createConfig(overrides?: Record<string, unknown>): OpenClawConfig {
return {
session: { mainKey: "main", scope: "per-sender" },
...overrides,
} as OpenClawConfig;
}
function mockPatchAndSingleAgentRun(params: { calls: GatewayCall[]; runId: string }) {
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as GatewayCall;
params.calls.push(request);
if (request.method === "sessions.patch") {
return { ok: true };
}
if (request.method === "agent") {
return { runId: params.runId, status: "accepted" };
}
return {};
});
}
async function expectSpawnUsesConfiguredModel(params: {
config?: SessionsSpawnConfigOverride;
runId: string;
callId: string;
expectedModel: string;
}) {
if (params.config) {
setSessionsSpawnConfigOverride(params.config);
} else {
resetSessionsSpawnConfigOverride();
}
const calls: GatewayCall[] = [];
mockPatchAndSingleAgentRun({ calls, runId: params.runId });
const tool = await getSessionsSpawnTool({
agentSessionKey: "agent:research:main",
agentChannel: "discord",
});
const result = await tool.execute(params.callId, {
task: "do thing",
});
expect(result.details).toMatchObject({
status: "accepted",
modelApplied: true,
});
const patchCall = calls.find(
(call) => call.method === "sessions.patch" && (call.params as { model?: string })?.model,
);
expect(patchCall?.params).toMatchObject({
model: params.expectedModel,
});
}
describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => {
beforeEach(() => {
resetSessionsSpawnConfigOverride();
resetSubagentRegistryForTests();
callGatewayMock.mockClear();
});
it("sessions_spawn applies a model to the child session", async () => {
const calls: GatewayCall[] = [];
mockLongRunningSpawnFlow({ calls, acceptedAtBase: 3000 });
const tool = await getSessionsSpawnTool({
agentSessionKey: "discord:group:req",
agentChannel: "discord",
describe("subagent spawn model + thinking plan", () => {
it("includes explicit model overrides in the initial patch", () => {
const plan = resolveSubagentModelAndThinkingPlan({
cfg: createConfig(),
targetAgentId: "research",
modelOverride: "claude-haiku-4-5",
});
const result = await tool.execute("call3", {
task: "do thing",
runTimeoutSeconds: 1,
model: "claude-haiku-4-5",
cleanup: "keep",
});
expect(result.details).toMatchObject({
status: "accepted",
note: SUBAGENT_SPAWN_ACCEPTED_NOTE,
expect(plan).toMatchObject({
status: "ok",
resolvedModel: "claude-haiku-4-5",
modelApplied: true,
});
const patchIndex = calls.findIndex((call) => call.method === "sessions.patch");
const agentIndex = calls.findIndex((call) => call.method === "agent");
expect(patchIndex).toBeGreaterThan(-1);
expect(agentIndex).toBeGreaterThan(-1);
expect(patchIndex).toBeLessThan(agentIndex);
const patchCalls = calls.filter((call) => call.method === "sessions.patch");
expect(patchCalls[0]?.params).toMatchObject({
key: expect.stringContaining("subagent:"),
model: "claude-haiku-4-5",
spawnDepth: 1,
initialSessionPatch: {
model: "claude-haiku-4-5",
},
});
});
it("sessions_spawn forwards thinking overrides to the agent run", async () => {
const calls: Array<{ method?: string; params?: unknown }> = [];
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
return { runId: "run-thinking", status: "accepted" };
}
return {};
it("normalizes thinking overrides into the initial patch", () => {
const plan = resolveSubagentModelAndThinkingPlan({
cfg: createConfig(),
targetAgentId: "research",
thinkingOverrideRaw: "high",
});
const tool = await getSessionsSpawnTool({
agentSessionKey: "discord:group:req",
agentChannel: "discord",
});
const result = await tool.execute("call-thinking", {
task: "do thing",
thinking: "high",
});
expect(result.details).toMatchObject({
status: "accepted",
});
const agentCall = calls.find((call) => call.method === "agent");
expect(agentCall?.params).toMatchObject({
thinking: "high",
expect(plan).toMatchObject({
status: "ok",
thinkingOverride: "high",
initialSessionPatch: {
thinkingLevel: "high",
},
});
});
it("sessions_spawn rejects invalid thinking levels", async () => {
const calls: Array<{ method?: string }> = [];
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string };
calls.push(request);
return {};
it("rejects invalid thinking levels before any runtime work", () => {
const plan = resolveSubagentModelAndThinkingPlan({
cfg: createConfig(),
targetAgentId: "research",
thinkingOverrideRaw: "banana",
});
const tool = await getSessionsSpawnTool({
agentSessionKey: "discord:group:req",
agentChannel: "discord",
});
const result = await tool.execute("call-thinking-invalid", {
task: "do thing",
thinking: "banana",
});
expect(result.details).toMatchObject({
expect(plan).toMatchObject({
status: "error",
});
const errorDetails = result.details as { error?: unknown };
expect(String(errorDetails.error)).toMatch(/Invalid thinking level/i);
expect(calls).toHaveLength(0);
if (plan.status === "error") {
expect(plan.error).toMatch(/Invalid thinking level/i);
}
});
it("sessions_spawn applies default subagent model from defaults config", async () => {
await expectSpawnUsesConfiguredModel({
config: {
session: { mainKey: "main", scope: "per-sender" },
it("applies default subagent model from defaults config", () => {
const plan = resolveSubagentModelAndThinkingPlan({
cfg: createConfig({
agents: { defaults: { subagents: { model: "minimax/MiniMax-M2.7" } } },
},
runId: "run-default-model",
callId: "call-default-model",
expectedModel: "minimax/MiniMax-M2.7",
}),
targetAgentId: "research",
});
expect(plan).toMatchObject({
status: "ok",
resolvedModel: "minimax/MiniMax-M2.7",
initialSessionPatch: { model: "minimax/MiniMax-M2.7" },
});
});
it("sessions_spawn falls back to runtime default model when no model config is set", async () => {
await expectSpawnUsesConfiguredModel({
runId: "run-runtime-default-model",
callId: "call-runtime-default-model",
expectedModel: `${DEFAULT_PROVIDER}/${DEFAULT_MODEL}`,
it("falls back to runtime default model when no model config is set", () => {
const plan = resolveSubagentModelAndThinkingPlan({
cfg: createConfig(),
targetAgentId: "research",
});
expect(plan).toMatchObject({
status: "ok",
resolvedModel: `${DEFAULT_PROVIDER}/${DEFAULT_MODEL}`,
initialSessionPatch: { model: `${DEFAULT_PROVIDER}/${DEFAULT_MODEL}` },
});
});
it("sessions_spawn prefers per-agent subagent model over defaults", async () => {
await expectSpawnUsesConfiguredModel({
config: {
session: { mainKey: "main", scope: "per-sender" },
agents: {
defaults: { subagents: { model: "minimax/MiniMax-M2.7" } },
list: [{ id: "research", subagents: { model: "opencode/claude" } }],
},
},
runId: "run-agent-model",
callId: "call-agent-model",
expectedModel: "opencode/claude",
});
});
it("sessions_spawn prefers target agent primary model over global default", async () => {
await expectSpawnUsesConfiguredModel({
config: {
session: { mainKey: "main", scope: "per-sender" },
agents: {
defaults: { model: { primary: "minimax/MiniMax-M2.7" } },
list: [{ id: "research", model: { primary: "opencode/claude" } }],
},
},
runId: "run-agent-primary-model",
callId: "call-agent-primary-model",
expectedModel: "opencode/claude",
});
});
it("sessions_spawn fails when model patch is rejected", async () => {
const calls: GatewayCall[] = [];
mockLongRunningSpawnFlow({
calls,
acceptedAtBase: 4000,
patch: async (request) => {
const model = (request.params as { model?: unknown } | undefined)?.model;
if (model === "bad-model") {
throw new Error("invalid model: bad-model");
}
return { ok: true };
it("prefers per-agent subagent model over defaults", () => {
const cfg = createConfig({
agents: {
defaults: { subagents: { model: "minimax/MiniMax-M2.7" } },
list: [{ id: "research", subagents: { model: "opencode/claude" } }],
},
});
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
const targetAgentConfig = {
id: "research",
subagents: { model: "opencode/claude" },
};
const plan = resolveSubagentModelAndThinkingPlan({
cfg,
targetAgentId: "research",
targetAgentConfig,
});
const result = await tool.execute("call4", {
task: "do thing",
runTimeoutSeconds: 1,
model: "bad-model",
expect(plan).toMatchObject({
status: "ok",
resolvedModel: "opencode/claude",
initialSessionPatch: { model: "opencode/claude" },
});
expect(result.details).toMatchObject({
status: "error",
});
expect(String((result.details as { error?: string }).error ?? "")).toContain("invalid model");
expect(calls.some((call) => call.method === "agent")).toBe(false);
});
it("sessions_spawn supports legacy timeoutSeconds alias", async () => {
let spawnedTimeout: number | undefined;
it("prefers target agent primary model over global default", () => {
const cfg = createConfig({
agents: {
defaults: { model: { primary: "minimax/MiniMax-M2.7" } },
list: [{ id: "research", model: { primary: "opencode/claude" } }],
},
});
const targetAgentConfig = {
id: "research",
model: { primary: "opencode/claude" },
};
const plan = resolveSubagentModelAndThinkingPlan({
cfg,
targetAgentId: "research",
targetAgentConfig,
});
expect(plan).toMatchObject({
status: "ok",
resolvedModel: "opencode/claude",
initialSessionPatch: { model: "opencode/claude" },
});
});
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
if (request.method === "agent") {
const params = request.params as { timeout?: number } | undefined;
spawnedTimeout = params?.timeout;
return { runId: "run-1", status: "accepted", acceptedAt: 1000 };
}
return {};
});
it("uses config default timeout when agent omits runTimeoutSeconds", () => {
expect(
resolveConfiguredSubagentRunTimeoutSeconds({
cfg: createConfig({
agents: { defaults: { subagents: { runTimeoutSeconds: 120 } } },
}),
}),
).toBe(120);
});
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
});
const result = await tool.execute("call5", {
task: "do thing",
timeoutSeconds: 2,
});
expect(result.details).toMatchObject({
status: "accepted",
runId: "run-1",
});
expect(spawnedTimeout).toBe(2);
it("explicit runTimeoutSeconds wins over config default", () => {
expect(
resolveConfiguredSubagentRunTimeoutSeconds({
cfg: createConfig({
agents: { defaults: { subagents: { runTimeoutSeconds: 120 } } },
}),
runTimeoutSeconds: 2,
}),
).toBe(2);
});
});

View File

@@ -93,6 +93,8 @@ const hoisted = vi.hoisted(() => {
return { callGatewayMock, defaultConfigOverride, state };
});
let cachedCreateSessionsSpawnTool: CreateSessionsSpawnTool | null = null;
export function getCallGatewayMock(): Mock {
return hoisted.callGatewayMock;
}
@@ -158,8 +160,11 @@ export async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) {
captureSubagentCompletionReply,
runSubagentAnnounceFlow: (params) => hoisted.state.runSubagentAnnounceFlowOverride(params),
});
const { createSessionsSpawnTool } = await import("./tools/sessions-spawn-tool.js");
return createSessionsSpawnTool(opts);
if (!cachedCreateSessionsSpawnTool) {
({ createSessionsSpawnTool: cachedCreateSessionsSpawnTool } =
await import("./tools/sessions-spawn-tool.js"));
}
return cachedCreateSessionsSpawnTool(opts);
}
export function setupSessionsSpawnGatewayMock(setupOpts: SessionsSpawnGatewayMockOptions): {

View File

@@ -0,0 +1,90 @@
import { formatThinkingLevels, normalizeThinkLevel } from "../auto-reply/thinking.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveSubagentSpawnModelSelection } from "./model-selection.js";
import { readStringParam } from "./tools/common.js";
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
}
export function splitModelRef(ref?: string) {
if (!ref) {
return { provider: undefined, model: undefined };
}
const trimmed = ref.trim();
if (!trimmed) {
return { provider: undefined, model: undefined };
}
const [provider, model] = trimmed.split("/", 2);
if (model) {
return { provider, model };
}
return { provider: undefined, model: trimmed };
}
export function resolveConfiguredSubagentRunTimeoutSeconds(params: {
cfg: OpenClawConfig;
runTimeoutSeconds?: number;
}) {
const cfgSubagentTimeout =
typeof params.cfg?.agents?.defaults?.subagents?.runTimeoutSeconds === "number" &&
Number.isFinite(params.cfg.agents.defaults.subagents.runTimeoutSeconds)
? Math.max(0, Math.floor(params.cfg.agents.defaults.subagents.runTimeoutSeconds))
: 0;
return typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds)
? Math.max(0, Math.floor(params.runTimeoutSeconds))
: cfgSubagentTimeout;
}
export function resolveSubagentModelAndThinkingPlan(params: {
cfg: OpenClawConfig;
targetAgentId: string;
targetAgentConfig?: unknown;
modelOverride?: string;
thinkingOverrideRaw?: string;
}) {
const resolvedModel = resolveSubagentSpawnModelSelection({
cfg: params.cfg,
agentId: params.targetAgentId,
modelOverride: params.modelOverride,
});
const targetSubagents = asRecord(asRecord(params.targetAgentConfig)?.subagents);
const defaultSubagents = asRecord(params.cfg.agents?.defaults?.subagents);
const resolvedThinkingDefaultRaw =
readStringParam(targetSubagents ?? {}, "thinking") ??
readStringParam(defaultSubagents ?? {}, "thinking");
const thinkingCandidateRaw = params.thinkingOverrideRaw || resolvedThinkingDefaultRaw;
if (!thinkingCandidateRaw) {
return {
status: "ok" as const,
resolvedModel,
modelApplied: Boolean(resolvedModel),
initialSessionPatch: resolvedModel ? { model: resolvedModel } : {},
thinkingOverride: undefined,
};
}
const normalizedThinking = normalizeThinkLevel(thinkingCandidateRaw);
if (!normalizedThinking) {
const { provider, model } = splitModelRef(resolvedModel);
const hint = formatThinkingLevels(provider, model);
return {
status: "error" as const,
resolvedModel,
error: `Invalid thinking level "${thinkingCandidateRaw}". Use one of: ${hint}.`,
};
}
return {
status: "ok" as const,
resolvedModel,
modelApplied: Boolean(resolvedModel),
thinkingOverride: normalizedThinking,
initialSessionPatch: {
...(resolvedModel ? { model: resolvedModel } : {}),
thinkingLevel: normalizedThinking === "off" ? null : normalizedThinking,
},
};
}

View File

@@ -119,7 +119,10 @@ export async function loadSubagentSpawnModuleForTest(params: {
resolveAgentConfig?: (cfg: Record<string, unknown>, agentId: string) => unknown;
resolveAgentWorkspaceDir?: (cfg: Record<string, unknown>, agentId: string) => string;
resolveSubagentSpawnModelSelection?: () => string | undefined;
resolveSandboxRuntimeStatus?: () => { sandboxed: boolean };
resolveSandboxRuntimeStatus?: (params: {
cfg?: Record<string, unknown>;
sessionKey?: string;
}) => { sandboxed: boolean };
workspaceDir?: string;
sessionStorePath?: string;
resetModules?: boolean;

View File

@@ -207,4 +207,83 @@ describe("spawnSubagentDirect seam flow", () => {
}
}
});
it("forwards normalized thinking to the agent run", async () => {
const calls: Array<{ method?: string; params?: unknown }> = [];
hoisted.callGatewayMock.mockImplementation(
async (request: { method?: string; params?: unknown }) => {
calls.push(request);
if (request.method === "agent") {
return { runId: "run-thinking", status: "accepted", acceptedAt: 1000 };
}
if (request.method?.startsWith("sessions.")) {
return { ok: true };
}
return {};
},
);
installSessionStoreCaptureMock(hoisted.updateSessionStoreMock);
const result = await spawnSubagentDirect(
{
task: "verify thinking forwarding",
thinking: "high",
},
{
agentSessionKey: "agent:main:main",
agentChannel: "discord",
},
);
expect(result).toMatchObject({
status: "accepted",
});
const agentCall = calls.find((call) => call.method === "agent");
expect(agentCall?.params).toMatchObject({
thinking: "high",
});
});
it("returns an error when the initial model patch is rejected", async () => {
hoisted.callGatewayMock.mockImplementation(
async (request: { method?: string; params?: unknown }) => {
if (request.method === "sessions.patch") {
const model = (request.params as { model?: unknown } | undefined)?.model;
if (model === "bad-model") {
throw new Error("invalid model: bad-model");
}
return { ok: true };
}
if (request.method === "agent") {
return { runId: "run-1", status: "accepted", acceptedAt: 1000 };
}
if (request.method === "sessions.delete") {
return { ok: true };
}
return {};
},
);
const result = await spawnSubagentDirect(
{
task: "verify patch rejection",
model: "bad-model",
},
{
agentSessionKey: "agent:main:main",
agentChannel: "discord",
},
);
expect(result).toMatchObject({
status: "error",
childSessionKey: expect.stringMatching(/^agent:main:subagent:/),
});
expect(String(result.error ?? "")).toContain("invalid model");
expect(
hoisted.callGatewayMock.mock.calls.some(
(call) => (call[0] as { method?: string }).method === "agent",
),
).toBe(false);
});
});

View File

@@ -21,6 +21,11 @@ import {
import { resolveSubagentCapabilities } from "./subagent-capabilities.js";
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
import { countActiveRunsForSession, registerSubagentRun } from "./subagent-registry.js";
import {
resolveConfiguredSubagentRunTimeoutSeconds,
resolveSubagentModelAndThinkingPlan,
splitModelRef,
} from "./subagent-spawn-plan.js";
import {
ADMIN_SCOPE,
AGENT_LANE_SUBAGENT,
@@ -28,12 +33,10 @@ import {
buildSubagentSystemPrompt,
callGateway,
emitSessionLifecycleEvent,
formatThinkingLevels,
getGlobalHookRunner,
loadConfig,
mergeSessionEntry,
normalizeDeliveryContext,
normalizeThinkLevel,
pruneLegacyStoreKeys,
resolveAgentConfig,
resolveDisplaySessionKey,
@@ -41,11 +44,9 @@ import {
resolveInternalSessionKey,
resolveMainSessionAlias,
resolveSandboxRuntimeStatus,
resolveSubagentSpawnModelSelection,
updateSessionStore,
isAdminOnlyMethod,
} from "./subagent-spawn.runtime.js";
import { readStringParam } from "./tools/common.js";
export const SUBAGENT_SPAWN_MODES = ["run", "session"] as const;
export type SpawnSubagentMode = (typeof SUBAGENT_SPAWN_MODES)[number];
@@ -127,20 +128,7 @@ export type SpawnSubagentResult = {
};
};
export function splitModelRef(ref?: string) {
if (!ref) {
return { provider: undefined, model: undefined };
}
const trimmed = ref.trim();
if (!trimmed) {
return { provider: undefined, model: undefined };
}
const [provider, model] = trimmed.split("/", 2);
if (model) {
return { provider, model };
}
return { provider: undefined, model: trimmed };
}
export { splitModelRef } from "./subagent-spawn-plan.js";
async function updateSubagentSessionStore(
storePath: string,
@@ -402,15 +390,10 @@ export async function spawnSubagentDirect(
// When agent omits runTimeoutSeconds, use the config default.
// Falls back to 0 (no timeout) if config key is also unset,
// preserving current behavior for existing deployments.
const cfgSubagentTimeout =
typeof cfg?.agents?.defaults?.subagents?.runTimeoutSeconds === "number" &&
Number.isFinite(cfg.agents.defaults.subagents.runTimeoutSeconds)
? Math.max(0, Math.floor(cfg.agents.defaults.subagents.runTimeoutSeconds))
: 0;
const runTimeoutSeconds =
typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds)
? Math.max(0, Math.floor(params.runTimeoutSeconds))
: cfgSubagentTimeout;
const runTimeoutSeconds = resolveConfiguredSubagentRunTimeoutSeconds({
cfg,
runTimeoutSeconds: params.runTimeoutSeconds,
});
let modelApplied = false;
let threadBindingReady = false;
const { mainKey, alias } = resolveMainSessionAlias(cfg);
@@ -512,30 +495,20 @@ export async function spawnSubagentDirect(
maxSpawnDepth,
});
const targetAgentConfig = resolveAgentConfig(cfg, targetAgentId);
const resolvedModel = resolveSubagentSpawnModelSelection({
const plan = resolveSubagentModelAndThinkingPlan({
cfg,
agentId: targetAgentId,
targetAgentId,
targetAgentConfig,
modelOverride,
thinkingOverrideRaw,
});
const resolvedThinkingDefaultRaw =
readStringParam(targetAgentConfig?.subagents ?? {}, "thinking") ??
readStringParam(cfg.agents?.defaults?.subagents ?? {}, "thinking");
let thinkingOverride: string | undefined;
const thinkingCandidateRaw = thinkingOverrideRaw || resolvedThinkingDefaultRaw;
if (thinkingCandidateRaw) {
const normalized = normalizeThinkLevel(thinkingCandidateRaw);
if (!normalized) {
const { provider, model } = splitModelRef(resolvedModel);
const hint = formatThinkingLevels(provider, model);
return {
status: "error",
error: `Invalid thinking level "${thinkingCandidateRaw}". Use one of: ${hint}.`,
};
}
thinkingOverride = normalized;
if (plan.status === "error") {
return {
status: "error",
error: plan.error,
};
}
const { resolvedModel, thinkingOverride } = plan;
const patchChildSession = async (patch: Record<string, unknown>): Promise<string | undefined> => {
try {
await callSubagentGateway({
@@ -553,13 +526,8 @@ export async function spawnSubagentDirect(
spawnDepth: childDepth,
subagentRole: childCapabilities.role === "main" ? null : childCapabilities.role,
subagentControlScope: childCapabilities.controlScope,
...plan.initialSessionPatch,
};
if (resolvedModel) {
initialChildSessionPatch.model = resolvedModel;
}
if (thinkingOverride !== undefined) {
initialChildSessionPatch.thinkingLevel = thinkingOverride === "off" ? null : thinkingOverride;
}
const initialPatchError = await patchChildSession(initialChildSessionPatch);
if (initialPatchError) {

View File

@@ -84,6 +84,25 @@ describe("sessions_spawn tool", () => {
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
});
it("supports legacy timeoutSeconds alias", async () => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
});
await tool.execute("call-timeout-alias", {
task: "do thing",
timeoutSeconds: 2,
});
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith(
expect.objectContaining({
task: "do thing",
runTimeoutSeconds: 2,
}),
expect.any(Object),
);
});
it("passes inherited workspaceDir from tool context, not from tool args", async () => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",