diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts new file mode 100644 index 0000000000..189347de9d --- /dev/null +++ b/src/agents/subagent-spawn.ts @@ -0,0 +1,322 @@ +import crypto from "node:crypto"; +import { formatThinkingLevels, normalizeThinkLevel } from "../auto-reply/thinking.js"; +import { loadConfig } from "../config/config.js"; +import { callGateway } from "../gateway/call.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; +import { normalizeDeliveryContext } from "../utils/delivery-context.js"; +import { resolveAgentConfig } from "./agent-scope.js"; +import { AGENT_LANE_SUBAGENT } from "./lanes.js"; +import { resolveDefaultModelForAgent } from "./model-selection.js"; +import { buildSubagentSystemPrompt } from "./subagent-announce.js"; +import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; +import { countActiveRunsForSession, registerSubagentRun } from "./subagent-registry.js"; +import { readStringParam } from "./tools/common.js"; +import { + resolveDisplaySessionKey, + resolveInternalSessionKey, + resolveMainSessionAlias, +} from "./tools/sessions-helpers.js"; + +export type SpawnSubagentParams = { + task: string; + label?: string; + agentId?: string; + model?: string; + thinking?: string; + runTimeoutSeconds?: number; + cleanup?: "delete" | "keep"; +}; + +export type SpawnSubagentContext = { + agentSessionKey?: string; + agentChannel?: string; + agentAccountId?: string; + agentTo?: string; + agentThreadId?: string | number; + agentGroupId?: string | null; + agentGroupChannel?: string | null; + agentGroupSpace?: string | null; + requesterAgentIdOverride?: string; +}; + +export type SpawnSubagentResult = { + status: "accepted" | "forbidden" | "error"; + childSessionKey?: string; + runId?: string; + modelApplied?: boolean; + warning?: string; + error?: string; +}; + +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 normalizeModelSelection(value: unknown): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed || undefined; + } + if (!value || typeof value !== "object") { + return undefined; + } + const primary = (value as { primary?: unknown }).primary; + if (typeof primary === "string" && primary.trim()) { + return primary.trim(); + } + return undefined; +} + +export async function spawnSubagentDirect( + params: SpawnSubagentParams, + ctx: SpawnSubagentContext, +): Promise { + const task = params.task; + const label = params.label?.trim() || ""; + const requestedAgentId = params.agentId; + const modelOverride = params.model; + const thinkingOverrideRaw = params.thinking; + const cleanup = + params.cleanup === "keep" || params.cleanup === "delete" ? params.cleanup : "keep"; + const requesterOrigin = normalizeDeliveryContext({ + channel: ctx.agentChannel, + accountId: ctx.agentAccountId, + to: ctx.agentTo, + threadId: ctx.agentThreadId, + }); + const runTimeoutSeconds = + typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds) + ? Math.max(0, Math.floor(params.runTimeoutSeconds)) + : 0; + let modelWarning: string | undefined; + let modelApplied = false; + + const cfg = loadConfig(); + const { mainKey, alias } = resolveMainSessionAlias(cfg); + const requesterSessionKey = ctx.agentSessionKey; + const requesterInternalKey = requesterSessionKey + ? resolveInternalSessionKey({ + key: requesterSessionKey, + alias, + mainKey, + }) + : alias; + const requesterDisplayKey = resolveDisplaySessionKey({ + key: requesterInternalKey, + alias, + mainKey, + }); + + const callerDepth = getSubagentDepthFromSessionStore(requesterInternalKey, { cfg }); + const maxSpawnDepth = cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? 1; + if (callerDepth >= maxSpawnDepth) { + return { + status: "forbidden", + error: `sessions_spawn is not allowed at this depth (current depth: ${callerDepth}, max: ${maxSpawnDepth})`, + }; + } + + const maxChildren = cfg.agents?.defaults?.subagents?.maxChildrenPerAgent ?? 5; + const activeChildren = countActiveRunsForSession(requesterInternalKey); + if (activeChildren >= maxChildren) { + return { + status: "forbidden", + error: `sessions_spawn has reached max active children for this session (${activeChildren}/${maxChildren})`, + }; + } + + const requesterAgentId = normalizeAgentId( + ctx.requesterAgentIdOverride ?? parseAgentSessionKey(requesterInternalKey)?.agentId, + ); + const targetAgentId = requestedAgentId ? normalizeAgentId(requestedAgentId) : requesterAgentId; + if (targetAgentId !== requesterAgentId) { + const allowAgents = resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ?? []; + const allowAny = allowAgents.some((value) => value.trim() === "*"); + const normalizedTargetId = targetAgentId.toLowerCase(); + const allowSet = new Set( + allowAgents + .filter((value) => value.trim() && value.trim() !== "*") + .map((value) => normalizeAgentId(value).toLowerCase()), + ); + if (!allowAny && !allowSet.has(normalizedTargetId)) { + const allowedText = allowSet.size > 0 ? Array.from(allowSet).join(", ") : "none"; + return { + status: "forbidden", + error: `agentId is not allowed for sessions_spawn (allowed: ${allowedText})`, + }; + } + } + const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`; + const childDepth = callerDepth + 1; + const spawnedByKey = requesterInternalKey; + const targetAgentConfig = resolveAgentConfig(cfg, targetAgentId); + const runtimeDefaultModel = resolveDefaultModelForAgent({ + cfg, + agentId: targetAgentId, + }); + const resolvedModel = + normalizeModelSelection(modelOverride) ?? + normalizeModelSelection(targetAgentConfig?.subagents?.model) ?? + normalizeModelSelection(cfg.agents?.defaults?.subagents?.model) ?? + normalizeModelSelection(cfg.agents?.defaults?.model?.primary) ?? + normalizeModelSelection(`${runtimeDefaultModel.provider}/${runtimeDefaultModel.model}`); + + 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; + } + try { + await callGateway({ + method: "sessions.patch", + params: { key: childSessionKey, spawnDepth: childDepth }, + timeoutMs: 10_000, + }); + } catch (err) { + const messageText = + err instanceof Error ? err.message : typeof err === "string" ? err : "error"; + return { + status: "error", + error: messageText, + childSessionKey, + }; + } + + if (resolvedModel) { + try { + await callGateway({ + method: "sessions.patch", + params: { key: childSessionKey, model: resolvedModel }, + timeoutMs: 10_000, + }); + modelApplied = true; + } catch (err) { + const messageText = + err instanceof Error ? err.message : typeof err === "string" ? err : "error"; + const recoverable = + messageText.includes("invalid model") || messageText.includes("model not allowed"); + if (!recoverable) { + return { + status: "error", + error: messageText, + childSessionKey, + }; + } + modelWarning = messageText; + } + } + if (thinkingOverride !== undefined) { + try { + await callGateway({ + method: "sessions.patch", + params: { + key: childSessionKey, + thinkingLevel: thinkingOverride === "off" ? null : thinkingOverride, + }, + timeoutMs: 10_000, + }); + } catch (err) { + const messageText = + err instanceof Error ? err.message : typeof err === "string" ? err : "error"; + return { + status: "error", + error: messageText, + childSessionKey, + }; + } + } + const childSystemPrompt = buildSubagentSystemPrompt({ + requesterSessionKey, + requesterOrigin, + childSessionKey, + label: label || undefined, + task, + childDepth, + maxSpawnDepth, + }); + + const childIdem = crypto.randomUUID(); + let childRunId: string = childIdem; + try { + const response = await callGateway<{ runId: string }>({ + method: "agent", + params: { + message: task, + sessionKey: childSessionKey, + channel: requesterOrigin?.channel, + to: requesterOrigin?.to ?? undefined, + accountId: requesterOrigin?.accountId ?? undefined, + threadId: requesterOrigin?.threadId != null ? String(requesterOrigin.threadId) : undefined, + idempotencyKey: childIdem, + deliver: false, + lane: AGENT_LANE_SUBAGENT, + extraSystemPrompt: childSystemPrompt, + thinking: thinkingOverride, + timeout: runTimeoutSeconds, + label: label || undefined, + spawnedBy: spawnedByKey, + groupId: ctx.agentGroupId ?? undefined, + groupChannel: ctx.agentGroupChannel ?? undefined, + groupSpace: ctx.agentGroupSpace ?? undefined, + }, + timeoutMs: 10_000, + }); + if (typeof response?.runId === "string" && response.runId) { + childRunId = response.runId; + } + } catch (err) { + const messageText = + err instanceof Error ? err.message : typeof err === "string" ? err : "error"; + return { + status: "error", + error: messageText, + childSessionKey, + runId: childRunId, + }; + } + + registerSubagentRun({ + runId: childRunId, + childSessionKey, + requesterSessionKey: requesterInternalKey, + requesterOrigin, + requesterDisplayKey, + task, + cleanup, + label: label || undefined, + model: resolvedModel, + runTimeoutSeconds, + }); + + return { + status: "accepted", + childSessionKey, + runId: childRunId, + modelApplied: resolvedModel ? modelApplied : undefined, + warning: modelWarning, + }; +} diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 867aa85c9d..cb4c6ac87c 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -1,25 +1,9 @@ import { Type } from "@sinclair/typebox"; -import crypto from "node:crypto"; import type { GatewayMessageChannel } from "../../utils/message-channel.js"; import type { AnyAgentTool } from "./common.js"; -import { formatThinkingLevels, normalizeThinkLevel } from "../../auto-reply/thinking.js"; -import { loadConfig } from "../../config/config.js"; -import { callGateway } from "../../gateway/call.js"; -import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; -import { normalizeDeliveryContext } from "../../utils/delivery-context.js"; -import { resolveAgentConfig } from "../agent-scope.js"; -import { AGENT_LANE_SUBAGENT } from "../lanes.js"; -import { resolveDefaultModelForAgent } from "../model-selection.js"; import { optionalStringEnum } from "../schema/typebox.js"; -import { buildSubagentSystemPrompt } from "../subagent-announce.js"; -import { getSubagentDepthFromSessionStore } from "../subagent-depth.js"; -import { countActiveRunsForSession, registerSubagentRun } from "../subagent-registry.js"; +import { spawnSubagentDirect } from "../subagent-spawn.js"; import { jsonResult, readStringParam } from "./common.js"; -import { - resolveDisplaySessionKey, - resolveInternalSessionKey, - resolveMainSessionAlias, -} from "./sessions-helpers.js"; const SessionsSpawnToolSchema = Type.Object({ task: Type.String(), @@ -33,36 +17,6 @@ const SessionsSpawnToolSchema = Type.Object({ cleanup: optionalStringEnum(["delete", "keep"] as const), }); -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 }; -} - -function normalizeModelSelection(value: unknown): string | undefined { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed || undefined; - } - if (!value || typeof value !== "object") { - return undefined; - } - const primary = (value as { primary?: unknown }).primary; - if (typeof primary === "string" && primary.trim()) { - return primary.trim(); - } - return undefined; -} - export function createSessionsSpawnTool(opts?: { agentSessionKey?: string; agentChannel?: GatewayMessageChannel; @@ -91,14 +45,7 @@ export function createSessionsSpawnTool(opts?: { const thinkingOverrideRaw = readStringParam(params, "thinking"); const cleanup = params.cleanup === "keep" || params.cleanup === "delete" ? params.cleanup : "keep"; - const requesterOrigin = normalizeDeliveryContext({ - channel: opts?.agentChannel, - accountId: opts?.agentAccountId, - to: opts?.agentTo, - threadId: opts?.agentThreadId, - }); - // Default to 0 (no timeout) when omitted. Sub-agent runs are long-lived - // by default and should not inherit the main agent 600s timeout. + // Back-compat: older callers used timeoutSeconds for this tool. const timeoutSecondsCandidate = typeof params.runTimeoutSeconds === "number" ? params.runTimeoutSeconds @@ -108,234 +55,32 @@ export function createSessionsSpawnTool(opts?: { const runTimeoutSeconds = typeof timeoutSecondsCandidate === "number" && Number.isFinite(timeoutSecondsCandidate) ? Math.max(0, Math.floor(timeoutSecondsCandidate)) - : 0; - let modelWarning: string | undefined; - let modelApplied = false; + : undefined; - const cfg = loadConfig(); - const { mainKey, alias } = resolveMainSessionAlias(cfg); - const requesterSessionKey = opts?.agentSessionKey; - const requesterInternalKey = requesterSessionKey - ? resolveInternalSessionKey({ - key: requesterSessionKey, - alias, - mainKey, - }) - : alias; - const requesterDisplayKey = resolveDisplaySessionKey({ - key: requesterInternalKey, - alias, - mainKey, - }); - - const callerDepth = getSubagentDepthFromSessionStore(requesterInternalKey, { cfg }); - const maxSpawnDepth = cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? 1; - if (callerDepth >= maxSpawnDepth) { - return jsonResult({ - status: "forbidden", - error: `sessions_spawn is not allowed at this depth (current depth: ${callerDepth}, max: ${maxSpawnDepth})`, - }); - } - - const maxChildren = cfg.agents?.defaults?.subagents?.maxChildrenPerAgent ?? 5; - const activeChildren = countActiveRunsForSession(requesterInternalKey); - if (activeChildren >= maxChildren) { - return jsonResult({ - status: "forbidden", - error: `sessions_spawn has reached max active children for this session (${activeChildren}/${maxChildren})`, - }); - } - - const requesterAgentId = normalizeAgentId( - opts?.requesterAgentIdOverride ?? parseAgentSessionKey(requesterInternalKey)?.agentId, + const result = await spawnSubagentDirect( + { + task, + label: label || undefined, + agentId: requestedAgentId, + model: modelOverride, + thinking: thinkingOverrideRaw, + runTimeoutSeconds, + cleanup, + }, + { + agentSessionKey: opts?.agentSessionKey, + agentChannel: opts?.agentChannel, + agentAccountId: opts?.agentAccountId, + agentTo: opts?.agentTo, + agentThreadId: opts?.agentThreadId, + agentGroupId: opts?.agentGroupId, + agentGroupChannel: opts?.agentGroupChannel, + agentGroupSpace: opts?.agentGroupSpace, + requesterAgentIdOverride: opts?.requesterAgentIdOverride, + }, ); - const targetAgentId = requestedAgentId - ? normalizeAgentId(requestedAgentId) - : requesterAgentId; - if (targetAgentId !== requesterAgentId) { - const allowAgents = resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ?? []; - const allowAny = allowAgents.some((value) => value.trim() === "*"); - const normalizedTargetId = targetAgentId.toLowerCase(); - const allowSet = new Set( - allowAgents - .filter((value) => value.trim() && value.trim() !== "*") - .map((value) => normalizeAgentId(value).toLowerCase()), - ); - if (!allowAny && !allowSet.has(normalizedTargetId)) { - const allowedText = allowAny - ? "*" - : allowSet.size > 0 - ? Array.from(allowSet).join(", ") - : "none"; - return jsonResult({ - status: "forbidden", - error: `agentId is not allowed for sessions_spawn (allowed: ${allowedText})`, - }); - } - } - const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`; - const childDepth = callerDepth + 1; - const spawnedByKey = requesterInternalKey; - const targetAgentConfig = resolveAgentConfig(cfg, targetAgentId); - const runtimeDefaultModel = resolveDefaultModelForAgent({ - cfg, - agentId: targetAgentId, - }); - const resolvedModel = - normalizeModelSelection(modelOverride) ?? - normalizeModelSelection(targetAgentConfig?.subagents?.model) ?? - normalizeModelSelection(cfg.agents?.defaults?.subagents?.model) ?? - normalizeModelSelection(cfg.agents?.defaults?.model?.primary) ?? - normalizeModelSelection(`${runtimeDefaultModel.provider}/${runtimeDefaultModel.model}`); - 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 jsonResult({ - status: "error", - error: `Invalid thinking level "${thinkingCandidateRaw}". Use one of: ${hint}.`, - }); - } - thinkingOverride = normalized; - } - try { - await callGateway({ - method: "sessions.patch", - params: { key: childSessionKey, spawnDepth: childDepth }, - timeoutMs: 10_000, - }); - } catch (err) { - const messageText = - err instanceof Error ? err.message : typeof err === "string" ? err : "error"; - return jsonResult({ - status: "error", - error: messageText, - childSessionKey, - }); - } - - if (resolvedModel) { - try { - await callGateway({ - method: "sessions.patch", - params: { key: childSessionKey, model: resolvedModel }, - timeoutMs: 10_000, - }); - modelApplied = true; - } catch (err) { - const messageText = - err instanceof Error ? err.message : typeof err === "string" ? err : "error"; - const recoverable = - messageText.includes("invalid model") || messageText.includes("model not allowed"); - if (!recoverable) { - return jsonResult({ - status: "error", - error: messageText, - childSessionKey, - }); - } - modelWarning = messageText; - } - } - if (thinkingOverride !== undefined) { - try { - await callGateway({ - method: "sessions.patch", - params: { - key: childSessionKey, - thinkingLevel: thinkingOverride === "off" ? null : thinkingOverride, - }, - timeoutMs: 10_000, - }); - } catch (err) { - const messageText = - err instanceof Error ? err.message : typeof err === "string" ? err : "error"; - return jsonResult({ - status: "error", - error: messageText, - childSessionKey, - }); - } - } - const childSystemPrompt = buildSubagentSystemPrompt({ - requesterSessionKey, - requesterOrigin, - childSessionKey, - label: label || undefined, - task, - childDepth, - maxSpawnDepth, - }); - - const childIdem = crypto.randomUUID(); - let childRunId: string = childIdem; - try { - const response = await callGateway<{ runId: string }>({ - method: "agent", - params: { - message: task, - sessionKey: childSessionKey, - channel: requesterOrigin?.channel, - to: requesterOrigin?.to ?? undefined, - accountId: requesterOrigin?.accountId ?? undefined, - threadId: - requesterOrigin?.threadId != null ? String(requesterOrigin.threadId) : undefined, - idempotencyKey: childIdem, - deliver: false, - lane: AGENT_LANE_SUBAGENT, - extraSystemPrompt: childSystemPrompt, - thinking: thinkingOverride, - timeout: runTimeoutSeconds, - label: label || undefined, - spawnedBy: spawnedByKey, - groupId: opts?.agentGroupId ?? undefined, - groupChannel: opts?.agentGroupChannel ?? undefined, - groupSpace: opts?.agentGroupSpace ?? undefined, - }, - timeoutMs: 10_000, - }); - if (typeof response?.runId === "string" && response.runId) { - childRunId = response.runId; - } - } catch (err) { - const messageText = - err instanceof Error ? err.message : typeof err === "string" ? err : "error"; - return jsonResult({ - status: "error", - error: messageText, - childSessionKey, - runId: childRunId, - }); - } - - registerSubagentRun({ - runId: childRunId, - childSessionKey, - requesterSessionKey: requesterInternalKey, - requesterOrigin, - requesterDisplayKey, - task, - cleanup, - label: label || undefined, - model: resolvedModel, - runTimeoutSeconds, - }); - - return jsonResult({ - status: "accepted", - childSessionKey, - runId: childRunId, - modelApplied: resolvedModel ? modelApplied : undefined, - warning: modelWarning, - }); + return jsonResult(result); }, }; } diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 70b993f670..b1d2168121 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -249,15 +249,15 @@ function buildChatCommands(): ChatCommandDefinition[] { defineChatCommand({ key: "subagents", nativeName: "subagents", - description: "List, kill, log, or steer subagent runs for this session.", + description: "List, kill, log, spawn, or steer subagent runs for this session.", textAlias: "/subagents", category: "management", args: [ { name: "action", - description: "list | kill | log | info | send | steer", + description: "list | kill | log | info | send | steer | spawn", type: "string", - choices: ["list", "kill", "log", "info", "send", "steer"], + choices: ["list", "kill", "log", "info", "send", "steer", "spawn"], }, { name: "target", diff --git a/src/auto-reply/reply/commands-spawn.test-harness.ts b/src/auto-reply/reply/commands-spawn.test-harness.ts new file mode 100644 index 0000000000..dd9de3c286 --- /dev/null +++ b/src/auto-reply/reply/commands-spawn.test-harness.ts @@ -0,0 +1,46 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { MsgContext } from "../templating.js"; +import { buildCommandContext } from "./commands-context.js"; +import { parseInlineDirectives } from "./directive-handling.js"; + +export function buildCommandTestParams( + commandBody: string, + cfg: OpenClawConfig, + ctxOverrides?: Partial, +) { + const ctx = { + Body: commandBody, + CommandBody: commandBody, + CommandSource: "text", + CommandAuthorized: true, + Provider: "whatsapp", + Surface: "whatsapp", + ...ctxOverrides, + } as MsgContext; + + const command = buildCommandContext({ + ctx, + cfg, + isGroup: false, + triggerBodyNormalized: commandBody.trim().toLowerCase(), + commandAuthorized: true, + }); + + return { + ctx, + cfg, + command, + directives: parseInlineDirectives(commandBody), + elevated: { enabled: true, allowed: true, failures: [] }, + sessionKey: "agent:main:main", + workspaceDir: "/tmp", + defaultGroupActivation: () => "mention", + resolvedVerboseLevel: "off" as const, + resolvedReasoningLevel: "off" as const, + resolveDefaultThinkingLevel: async () => undefined, + provider: "whatsapp", + model: "test-model", + contextTokens: 0, + isGroup: false, + }; +} diff --git a/src/auto-reply/reply/commands-subagents-spawn.test.ts b/src/auto-reply/reply/commands-subagents-spawn.test.ts new file mode 100644 index 0000000000..0dbef9c492 --- /dev/null +++ b/src/auto-reply/reply/commands-subagents-spawn.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SpawnSubagentResult } from "../../agents/subagent-spawn.js"; +import { resetSubagentRegistryForTests } from "../../agents/subagent-registry.js"; + +const hoisted = vi.hoisted(() => { + const spawnSubagentDirectMock = vi.fn(); + const callGatewayMock = vi.fn(); + return { spawnSubagentDirectMock, callGatewayMock }; +}); + +vi.mock("../../agents/subagent-spawn.js", () => ({ + spawnSubagentDirect: (...args: unknown[]) => hoisted.spawnSubagentDirectMock(...args), +})); + +vi.mock("../../gateway/call.js", () => ({ + callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), +})); + +vi.mock("../../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => ({}), + }; +}); + +// Prevent transitive import chain from reaching discord/monitor which needs https-proxy-agent. +vi.mock("../../discord/monitor/gateway-plugin.js", () => ({ + createDiscordGatewayPlugin: () => ({}), +})); + +// Dynamic import to ensure mocks are installed first. +const { handleSubagentsCommand } = await import("./commands-subagents.js"); +const { buildCommandTestParams } = await import("./commands-spawn.test-harness.js"); + +const { spawnSubagentDirectMock } = hoisted; + +function acceptedResult(overrides?: Partial): SpawnSubagentResult { + return { + status: "accepted", + childSessionKey: "agent:beta:subagent:test-uuid", + runId: "run-spawn-1", + ...overrides, + }; +} + +function forbiddenResult(error: string): SpawnSubagentResult { + return { + status: "forbidden", + error, + }; +} + +const baseCfg = { + session: { mainKey: "main", scope: "per-sender" }, +}; + +describe("/subagents spawn command", () => { + beforeEach(() => { + resetSubagentRegistryForTests(); + spawnSubagentDirectMock.mockReset(); + hoisted.callGatewayMock.mockReset(); + }); + + it("shows usage when agentId is missing", async () => { + const params = buildCommandTestParams("/subagents spawn", baseCfg); + const result = await handleSubagentsCommand(params, true); + expect(result).not.toBeNull(); + expect(result?.reply?.text).toContain("Usage:"); + expect(result?.reply?.text).toContain("/subagents spawn"); + expect(spawnSubagentDirectMock).not.toHaveBeenCalled(); + }); + + it("shows usage when task is missing", async () => { + const params = buildCommandTestParams("/subagents spawn beta", baseCfg); + const result = await handleSubagentsCommand(params, true); + expect(result).not.toBeNull(); + expect(result?.reply?.text).toContain("Usage:"); + expect(spawnSubagentDirectMock).not.toHaveBeenCalled(); + }); + + it("spawns subagent and confirms reply text and child session key", async () => { + spawnSubagentDirectMock.mockResolvedValue(acceptedResult()); + const params = buildCommandTestParams("/subagents spawn beta do the thing", baseCfg); + const result = await handleSubagentsCommand(params, true); + expect(result).not.toBeNull(); + expect(result?.reply?.text).toContain("Spawned subagent beta"); + expect(result?.reply?.text).toContain("agent:beta:subagent:test-uuid"); + expect(result?.reply?.text).toContain("run-spaw"); + + expect(spawnSubagentDirectMock).toHaveBeenCalledOnce(); + const [spawnParams, spawnCtx] = spawnSubagentDirectMock.mock.calls[0]; + expect(spawnParams.task).toBe("do the thing"); + expect(spawnParams.agentId).toBe("beta"); + expect(spawnParams.cleanup).toBe("keep"); + expect(spawnCtx.agentSessionKey).toBeDefined(); + }); + + it("spawns with --model flag and passes model to spawnSubagentDirect", async () => { + spawnSubagentDirectMock.mockResolvedValue(acceptedResult({ modelApplied: true })); + const params = buildCommandTestParams( + "/subagents spawn beta do the thing --model openai/gpt-4o", + baseCfg, + ); + const result = await handleSubagentsCommand(params, true); + expect(result).not.toBeNull(); + expect(result?.reply?.text).toContain("Spawned subagent beta"); + + const [spawnParams] = spawnSubagentDirectMock.mock.calls[0]; + expect(spawnParams.model).toBe("openai/gpt-4o"); + expect(spawnParams.task).toBe("do the thing"); + }); + + it("spawns with --thinking flag and passes thinking to spawnSubagentDirect", async () => { + spawnSubagentDirectMock.mockResolvedValue(acceptedResult()); + const params = buildCommandTestParams( + "/subagents spawn beta do the thing --thinking high", + baseCfg, + ); + const result = await handleSubagentsCommand(params, true); + expect(result).not.toBeNull(); + expect(result?.reply?.text).toContain("Spawned subagent beta"); + + const [spawnParams] = spawnSubagentDirectMock.mock.calls[0]; + expect(spawnParams.thinking).toBe("high"); + expect(spawnParams.task).toBe("do the thing"); + }); + + it("returns forbidden for unauthorized cross-agent spawn", async () => { + spawnSubagentDirectMock.mockResolvedValue( + forbiddenResult("agentId is not allowed for sessions_spawn (allowed: alpha)"), + ); + const params = buildCommandTestParams("/subagents spawn beta do the thing", baseCfg); + const result = await handleSubagentsCommand(params, true); + expect(result).not.toBeNull(); + expect(result?.reply?.text).toContain("Spawn failed"); + expect(result?.reply?.text).toContain("not allowed"); + }); + + it("allows cross-agent spawn when in allowlist", async () => { + spawnSubagentDirectMock.mockResolvedValue(acceptedResult()); + const params = buildCommandTestParams("/subagents spawn beta do the thing", baseCfg); + const result = await handleSubagentsCommand(params, true); + expect(result).not.toBeNull(); + expect(result?.reply?.text).toContain("Spawned subagent beta"); + }); + + it("ignores unauthorized sender (silent, no reply)", async () => { + const params = buildCommandTestParams("/subagents spawn beta do the thing", baseCfg, { + CommandAuthorized: false, + }); + params.command.isAuthorizedSender = false; + const result = await handleSubagentsCommand(params, true); + expect(result).not.toBeNull(); + expect(result?.reply).toBeUndefined(); + expect(result?.shouldContinue).toBe(false); + expect(spawnSubagentDirectMock).not.toHaveBeenCalled(); + }); + + it("returns null when text commands disabled", async () => { + const params = buildCommandTestParams("/subagents spawn beta do the thing", baseCfg); + const result = await handleSubagentsCommand(params, false); + expect(result).toBeNull(); + expect(spawnSubagentDirectMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/auto-reply/reply/commands-subagents.ts b/src/auto-reply/reply/commands-subagents.ts index b4d201e947..a7397c6e67 100644 --- a/src/auto-reply/reply/commands-subagents.ts +++ b/src/auto-reply/reply/commands-subagents.ts @@ -10,6 +10,7 @@ import { markSubagentRunForSteerRestart, replaceSubagentRunAfterSteer, } from "../../agents/subagent-registry.js"; +import { spawnSubagentDirect } from "../../agents/subagent-spawn.js"; import { extractAssistantText, resolveInternalSessionKey, @@ -47,7 +48,7 @@ const COMMAND = "/subagents"; const COMMAND_KILL = "/kill"; const COMMAND_STEER = "/steer"; const COMMAND_TELL = "/tell"; -const ACTIONS = new Set(["list", "kill", "log", "send", "steer", "info", "help"]); +const ACTIONS = new Set(["list", "kill", "log", "send", "steer", "info", "spawn", "help"]); const RECENT_WINDOW_MINUTES = 30; const SUBAGENT_TASK_PREVIEW_MAX = 110; const STEER_ABORT_SETTLE_TIMEOUT_MS = 5_000; @@ -192,6 +193,7 @@ function buildSubagentsHelp() { "- /subagents info ", "- /subagents send ", "- /subagents steer ", + "- /subagents spawn [--model ] [--thinking ]", "- /kill ", "- /steer ", "- /tell ", @@ -644,5 +646,56 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo }; } + if (action === "spawn") { + const agentId = restTokens[0]; + // Parse remaining tokens: task text with optional --model and --thinking flags. + const taskParts: string[] = []; + let model: string | undefined; + let thinking: string | undefined; + for (let i = 1; i < restTokens.length; i++) { + if (restTokens[i] === "--model" && i + 1 < restTokens.length) { + i += 1; + model = restTokens[i]; + } else if (restTokens[i] === "--thinking" && i + 1 < restTokens.length) { + i += 1; + thinking = restTokens[i]; + } else { + taskParts.push(restTokens[i]); + } + } + const task = taskParts.join(" ").trim(); + if (!agentId || !task) { + return { + shouldContinue: false, + reply: { + text: "Usage: /subagents spawn [--model ] [--thinking ]", + }, + }; + } + + const result = await spawnSubagentDirect( + { task, agentId, model, thinking, cleanup: "keep" }, + { + agentSessionKey: requesterKey, + agentChannel: params.command.channel, + agentAccountId: params.ctx.AccountId, + agentTo: params.command.to, + agentThreadId: params.ctx.MessageThreadId, + }, + ); + if (result.status === "accepted") { + return { + shouldContinue: false, + reply: { + text: `Spawned subagent ${agentId} (session ${result.childSessionKey}, run ${result.runId?.slice(0, 8)}).${result.warning ? ` Warning: ${result.warning}` : ""}`, + }, + }; + } + return { + shouldContinue: false, + reply: { text: `Spawn failed: ${result.error ?? result.status}` }, + }; + } + return { shouldContinue: false, reply: { text: buildSubagentsHelp() } }; };