mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-21 05:32:53 +00:00
perf(agents): extract subagent spawn planning seams
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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): {
|
||||
|
||||
90
src/agents/subagent-spawn-plan.ts
Normal file
90
src/agents/subagent-spawn-plan.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user