import crypto from "node:crypto"; import { Type } from "@sinclair/typebox"; import { loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; import { isSubagentSessionKey, normalizeAgentId, parseAgentSessionKey, } from "../../routing/session-key.js"; import { resolveAgentConfig } from "../agent-scope.js"; import { buildSubagentSystemPrompt } from "../subagent-announce.js"; import { registerSubagentRun } from "../subagent-registry.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readStringParam } from "./common.js"; import { resolveDisplaySessionKey, resolveInternalSessionKey, resolveMainSessionAlias, } from "./sessions-helpers.js"; const SessionsSpawnToolSchema = Type.Object({ task: Type.String(), label: Type.Optional(Type.String()), agentId: Type.Optional(Type.String()), model: Type.Optional(Type.String()), runTimeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })), // Back-compat alias. Prefer runTimeoutSeconds. timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })), cleanup: Type.Optional( Type.Union([Type.Literal("delete"), Type.Literal("keep")]), ), }); export function createSessionsSpawnTool(opts?: { agentSessionKey?: string; agentProvider?: string; sandboxed?: boolean; }): AnyAgentTool { return { label: "Sessions", name: "sessions_spawn", description: "Spawn a background sub-agent run in an isolated session and announce the result back to the requester chat.", parameters: SessionsSpawnToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; const task = readStringParam(params, "task", { required: true }); const label = typeof params.label === "string" ? params.label.trim() : ""; const requestedAgentId = readStringParam(params, "agentId"); const model = readStringParam(params, "model"); const cleanup = params.cleanup === "keep" || params.cleanup === "delete" ? (params.cleanup as "keep" | "delete") : "keep"; const runTimeoutSeconds = (() => { const explicit = typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds) ? Math.max(0, Math.floor(params.runTimeoutSeconds)) : undefined; if (explicit !== undefined) return explicit; const legacy = typeof params.timeoutSeconds === "number" && Number.isFinite(params.timeoutSeconds) ? Math.max(0, Math.floor(params.timeoutSeconds)) : undefined; return legacy ?? 0; })(); let modelWarning: string | undefined; let modelApplied = false; const cfg = loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); const requesterSessionKey = opts?.agentSessionKey; if ( typeof requesterSessionKey === "string" && isSubagentSessionKey(requesterSessionKey) ) { return jsonResult({ status: "forbidden", error: "sessions_spawn is not allowed from sub-agent sessions", }); } const requesterInternalKey = requesterSessionKey ? resolveInternalSessionKey({ key: requesterSessionKey, alias, mainKey, }) : alias; const requesterDisplayKey = resolveDisplaySessionKey({ key: requesterInternalKey, alias, mainKey, }); const requesterAgentId = normalizeAgentId( 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 = 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()}`; if (opts?.sandboxed === true) { try { await callGateway({ method: "sessions.patch", params: { key: childSessionKey, spawnedBy: requesterInternalKey }, timeoutMs: 10_000, }); } catch { // best-effort; scoping relies on this metadata but spawning still works without it } } if (model) { try { await callGateway({ method: "sessions.patch", params: { key: childSessionKey, model }, 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; } } const childSystemPrompt = buildSubagentSystemPrompt({ requesterSessionKey, requesterProvider: opts?.agentProvider, childSessionKey, label: label || undefined, }); const childIdem = crypto.randomUUID(); let childRunId: string = childIdem; try { const response = (await callGateway({ method: "agent", params: { message: task, sessionKey: childSessionKey, idempotencyKey: childIdem, deliver: false, lane: "subagent", extraSystemPrompt: childSystemPrompt, timeout: runTimeoutSeconds > 0 ? runTimeoutSeconds : undefined, }, timeoutMs: 10_000, })) as { runId?: string }; 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, requesterProvider: opts?.agentProvider, requesterDisplayKey, task, cleanup, }); return jsonResult({ status: "accepted", childSessionKey, runId: childRunId, modelApplied: model ? modelApplied : undefined, warning: modelWarning, }); }, }; }