Files
openclaw/src/agents/subagent-spawn.ts
2026-05-02 06:58:07 +01:00

1327 lines
42 KiB
TypeScript

import crypto from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { isAcpRuntimeSpawnAvailable } from "../acp/runtime/availability.js";
import { resolveThreadBindingSpawnPolicy } from "../channels/thread-bindings-policy.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { SubagentSpawnPreparation } from "../context-engine/types.js";
import { stringifyRouteThreadId } from "../plugin-sdk/channel-route.js";
import { listRegisteredPluginAgentPromptGuidance } from "../plugins/command-registry-state.js";
import type { SubagentLifecycleHookRunner } from "../plugins/hooks.js";
import { isValidAgentId, normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import type { DeliveryContext } from "../utils/delivery-context.types.js";
import { resolveAgentDir } from "./agent-scope-config.js";
import type { BootstrapContextMode } from "./bootstrap-files.js";
import {
mapToolContextToSpawnedRunMetadata,
normalizeSpawnedRunMetadata,
resolveSpawnedWorkspaceInheritance,
} from "./spawned-context.js";
import {
decodeStrictBase64,
materializeSubagentAttachments,
type SubagentAttachmentReceiptFile,
} from "./subagent-attachments.js";
import { resolveSubagentCapabilities } from "./subagent-capabilities.js";
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
import { buildSubagentInitialUserMessage } from "./subagent-initial-user-message.js";
import { countActiveRunsForSession, registerSubagentRun } from "./subagent-registry.js";
import { resolveSubagentSpawnAcceptedNote } from "./subagent-spawn-accepted-note.js";
import { resolveSubagentTargetPolicy } from "./subagent-target-policy.js";
export {
SUBAGENT_SPAWN_ACCEPTED_NOTE,
SUBAGENT_SPAWN_SESSION_ACCEPTED_NOTE,
} from "./subagent-spawn-accepted-note.js";
import { resolveRequesterOriginForChild } from "./spawn-requester-origin.js";
import {
resolveConfiguredSubagentRunTimeoutSeconds,
resolveSubagentModelAndThinkingPlan,
splitModelRef,
} from "./subagent-spawn-plan.js";
import {
ADMIN_SCOPE,
AGENT_LANE_SUBAGENT,
DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT,
DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH,
buildSubagentSystemPrompt,
callGateway,
emitSessionLifecycleEvent,
forkSessionFromParent,
getGlobalHookRunner,
getRuntimeConfig,
mergeSessionEntry,
mergeDeliveryContext,
normalizeDeliveryContext,
pruneLegacyStoreKeys,
ensureContextEnginesInitialized,
resolveParentForkDecision,
resolveAgentConfig,
resolveContextEngine,
resolveDisplaySessionKey,
resolveGatewaySessionStoreTarget,
resolveInternalSessionKey,
resolveMainSessionAlias,
resolveSandboxRuntimeStatus,
updateSessionStore,
isAdminOnlyMethod,
} from "./subagent-spawn.runtime.js";
import {
SUBAGENT_SPAWN_CONTEXT_MODES,
SUBAGENT_SPAWN_MODES,
SUBAGENT_SPAWN_SANDBOX_MODES,
type SpawnSubagentContextMode,
type SpawnSubagentMode,
type SpawnSubagentSandboxMode,
} from "./subagent-spawn.types.js";
export {
SUBAGENT_SPAWN_CONTEXT_MODES,
SUBAGENT_SPAWN_MODES,
SUBAGENT_SPAWN_SANDBOX_MODES,
} from "./subagent-spawn.types.js";
export type {
SpawnSubagentContextMode,
SpawnSubagentMode,
SpawnSubagentSandboxMode,
} from "./subagent-spawn.types.js";
export { decodeStrictBase64 };
type SubagentSpawnDeps = {
callGateway: typeof callGateway;
forkSessionFromParent: typeof forkSessionFromParent;
getGlobalHookRunner: () => SubagentLifecycleHookRunner | null;
getRuntimeConfig: typeof getRuntimeConfig;
ensureContextEnginesInitialized: typeof ensureContextEnginesInitialized;
resolveContextEngine: typeof resolveContextEngine;
resolveParentForkDecision: typeof resolveParentForkDecision;
updateSessionStore: typeof updateSessionStore;
};
const defaultSubagentSpawnDeps: SubagentSpawnDeps = {
callGateway,
forkSessionFromParent,
getGlobalHookRunner,
getRuntimeConfig,
ensureContextEnginesInitialized,
resolveContextEngine,
resolveParentForkDecision,
updateSessionStore,
};
let subagentSpawnDeps: SubagentSpawnDeps = defaultSubagentSpawnDeps;
const SUBAGENT_CONTROL_GATEWAY_TIMEOUT_MS = 60_000;
const DEFAULT_SUBAGENT_AGENT_GATEWAY_TIMEOUT_MS = 60_000;
const MAX_SUBAGENT_AGENT_GATEWAY_TIMEOUT_MS = 300_000;
export type SpawnSubagentParams = {
task: string;
label?: string;
agentId?: string;
model?: string;
thinking?: string;
runTimeoutSeconds?: number;
thread?: boolean;
mode?: SpawnSubagentMode;
cleanup?: "delete" | "keep";
sandbox?: SpawnSubagentSandboxMode;
context?: SpawnSubagentContextMode;
lightContext?: boolean;
expectsCompletionMessage?: boolean;
attachments?: Array<{
name: string;
content: string;
encoding?: "utf8" | "base64";
mimeType?: string;
}>;
attachMountPath?: string;
};
export type SpawnSubagentContext = {
agentSessionKey?: string;
agentChannel?: string;
agentAccountId?: string;
agentTo?: string;
agentThreadId?: string | number;
agentGroupId?: string | null;
agentGroupChannel?: string | null;
agentGroupSpace?: string | null;
agentMemberRoleIds?: string[];
requesterAgentIdOverride?: string;
/** Explicit workspace directory for subagent to inherit (optional). */
workspaceDir?: string;
};
export type SpawnSubagentResult = {
status: "accepted" | "forbidden" | "error";
childSessionKey?: string;
runId?: string;
mode?: SpawnSubagentMode;
note?: string;
modelApplied?: boolean;
error?: string;
attachments?: {
count: number;
totalBytes: number;
files: Array<{ name: string; bytes: number; sha256: string }>;
relDir: string;
};
};
export { splitModelRef } from "./subagent-spawn-plan.js";
async function updateSubagentSessionStore(
storePath: string,
mutator: Parameters<typeof updateSessionStore>[1],
) {
return await subagentSpawnDeps.updateSessionStore(storePath, mutator);
}
async function callSubagentGateway(
params: Parameters<typeof callGateway>[0],
): Promise<Awaited<ReturnType<typeof callGateway>>> {
// Subagent lifecycle requires methods spanning multiple scope tiers
// (sessions.patch / sessions.delete → admin, agent → write). When each call
// independently negotiates least-privilege scopes the first connection pairs
// at a lower tier and every subsequent higher-tier call triggers a
// scope-upgrade handshake that headless gateway-client connections cannot
// complete interactively, causing close(1008) "pairing required" (#59428).
//
// Only admin-only methods are pinned to ADMIN_SCOPE; other methods (e.g.
// "agent" → write) keep their least-privilege scope so that the gateway does
// not treat the caller as owner (senderIsOwner) and expose owner-only tools.
const scopes = params.scopes ?? (isAdminOnlyMethod(params.method) ? [ADMIN_SCOPE] : undefined);
return await subagentSpawnDeps.callGateway({
...params,
...(scopes != null ? { scopes } : {}),
});
}
function readGatewayRunId(response: Awaited<ReturnType<typeof callGateway>>): string | undefined {
if (!response || typeof response !== "object") {
return undefined;
}
const { runId } = response as { runId?: unknown };
return typeof runId === "string" && runId ? runId : undefined;
}
function resolveSubagentAgentGatewayTimeoutMs(runTimeoutSeconds: number): number {
const runTimeoutMs =
Number.isFinite(runTimeoutSeconds) && runTimeoutSeconds > 0
? Math.floor(runTimeoutSeconds * 1000)
: 0;
if (runTimeoutMs <= 0) {
return DEFAULT_SUBAGENT_AGENT_GATEWAY_TIMEOUT_MS;
}
return Math.min(
MAX_SUBAGENT_AGENT_GATEWAY_TIMEOUT_MS,
Math.max(DEFAULT_SUBAGENT_AGENT_GATEWAY_TIMEOUT_MS, runTimeoutMs + 5_000),
);
}
function buildDirectChildSessionPatch(patch: Record<string, unknown>): Partial<SessionEntry> {
const entry: Partial<SessionEntry> = {};
const spawnDepth = patch.spawnDepth;
if (typeof spawnDepth === "number" && Number.isFinite(spawnDepth) && spawnDepth >= 0) {
entry.spawnDepth = Math.floor(spawnDepth);
}
if (patch.subagentRole === "orchestrator" || patch.subagentRole === "leaf") {
entry.subagentRole = patch.subagentRole;
}
if (patch.subagentControlScope === "children" || patch.subagentControlScope === "none") {
entry.subagentControlScope = patch.subagentControlScope;
}
if (typeof patch.spawnedBy === "string" && patch.spawnedBy.trim()) {
entry.spawnedBy = patch.spawnedBy.trim();
}
if (typeof patch.spawnedWorkspaceDir === "string" && patch.spawnedWorkspaceDir.trim()) {
entry.spawnedWorkspaceDir = patch.spawnedWorkspaceDir.trim();
}
if (typeof patch.thinkingLevel === "string" && patch.thinkingLevel.trim()) {
entry.thinkingLevel = patch.thinkingLevel.trim();
}
if (typeof patch.model === "string" && patch.model.trim()) {
const { provider, model } = splitModelRef(patch.model.trim());
if (model) {
entry.model = model;
entry.modelOverride = model;
entry.modelOverrideSource = patch.modelOverrideSource === "auto" ? "auto" : "user";
if (provider) {
entry.modelProvider = provider;
entry.providerOverride = provider;
}
}
}
return entry;
}
function loadSubagentConfig() {
return subagentSpawnDeps.getRuntimeConfig();
}
async function persistInitialChildSessionRuntimeModel(params: {
cfg: OpenClawConfig;
childSessionKey: string;
resolvedModel?: string;
}): Promise<string | undefined> {
const { provider, model } = splitModelRef(params.resolvedModel);
if (!model) {
return undefined;
}
try {
const target = resolveGatewaySessionStoreTarget({
cfg: params.cfg,
key: params.childSessionKey,
});
await updateSubagentSessionStore(target.storePath, (store) => {
pruneLegacyStoreKeys({
store,
canonicalKey: target.canonicalKey,
candidates: target.storeKeys,
});
store[target.canonicalKey] = mergeSessionEntry(store[target.canonicalKey], {
model,
...(provider ? { modelProvider: provider } : {}),
});
});
return undefined;
} catch (err) {
return err instanceof Error ? err.message : typeof err === "string" ? err : "error";
}
}
function resolveStoreEntryByKeys(
store: Record<string, SessionEntry>,
keys: readonly string[],
): SessionEntry | undefined {
for (const key of keys) {
const entry = store[key];
if (entry) {
return entry;
}
}
return undefined;
}
type PreparedSpawnContext =
| {
status: "ok";
mode: "isolated";
parentEntry?: SessionEntry;
childEntry?: SessionEntry;
forkFallbackNote?: string;
}
| {
status: "ok";
mode: "fork";
parentEntry: SessionEntry;
childEntry?: SessionEntry;
forked: { sessionId: string; sessionFile: string };
forkFallbackNote?: never;
}
| { status: "error"; error: string };
async function prepareSubagentSessionContext(params: {
cfg: OpenClawConfig;
contextMode: SpawnSubagentContextMode;
requesterAgentId: string;
targetAgentId: string;
requesterInternalKey: string;
childSessionKey: string;
}): Promise<PreparedSpawnContext> {
if (params.contextMode === "isolated") {
return { status: "ok", mode: "isolated" };
}
const childTarget = resolveGatewaySessionStoreTarget({
cfg: params.cfg,
key: params.childSessionKey,
});
const parentTarget = resolveGatewaySessionStoreTarget({
cfg: params.cfg,
key: params.requesterInternalKey,
});
let parentEntry: SessionEntry | undefined;
let childEntry: SessionEntry | undefined;
let forkFallbackNote: string | undefined;
const sessionsDir = path.dirname(parentTarget.storePath);
try {
const forked = (await updateSubagentSessionStore(childTarget.storePath, async (store) => {
parentEntry = resolveStoreEntryByKeys(store, parentTarget.storeKeys);
childEntry = resolveStoreEntryByKeys(store, childTarget.storeKeys);
if (params.targetAgentId !== params.requesterAgentId) {
throw new Error(
'context="fork" currently requires the same target agent as the requester; use context="isolated" for cross-agent spawns.',
);
}
if (!parentEntry?.sessionId) {
throw new Error(
'context="fork" requested but the requester session transcript is not available.',
);
}
const forkDecision = await subagentSpawnDeps.resolveParentForkDecision({
parentEntry,
storePath: parentTarget.storePath,
});
if (forkDecision.status === "skip") {
forkFallbackNote = forkDecision.message;
return null;
}
const fork = await subagentSpawnDeps.forkSessionFromParent({
parentEntry,
agentId: params.requesterAgentId,
sessionsDir,
});
if (!fork) {
throw new Error(
'context="fork" requested but OpenClaw could not fork the requester transcript.',
);
}
pruneLegacyStoreKeys({
store,
canonicalKey: childTarget.canonicalKey,
candidates: childTarget.storeKeys,
});
store[childTarget.canonicalKey] = mergeSessionEntry(store[childTarget.canonicalKey], {
sessionId: fork.sessionId,
sessionFile: fork.sessionFile,
forkedFromParent: true,
});
childEntry = store[childTarget.canonicalKey];
return fork;
})) as { sessionId: string; sessionFile: string } | null;
if (params.contextMode === "fork") {
if (!parentEntry || !forked) {
if (forkFallbackNote) {
return {
status: "ok",
mode: "isolated",
parentEntry,
childEntry,
forkFallbackNote,
};
}
return {
status: "error",
error: 'context="fork" requested but OpenClaw could not prepare forked context.',
};
}
return {
status: "ok",
mode: "fork",
parentEntry,
childEntry,
forked,
};
}
return {
status: "ok",
mode: "isolated",
parentEntry,
childEntry,
...(forkFallbackNote ? { forkFallbackNote } : {}),
};
} catch (err) {
return { status: "error", error: summarizeError(err) };
}
}
async function prepareContextEngineSubagentSpawn(params: {
cfg: OpenClawConfig;
context: PreparedSpawnContext & { status: "ok" };
requesterInternalKey: string;
childSessionKey: string;
runTimeoutSeconds: number;
}): Promise<
{ status: "ok"; preparation?: SubagentSpawnPreparation } | { status: "error"; error: string }
> {
try {
subagentSpawnDeps.ensureContextEnginesInitialized();
const engine = await subagentSpawnDeps.resolveContextEngine(params.cfg);
const preparation = await engine.prepareSubagentSpawn?.({
parentSessionKey: params.requesterInternalKey,
childSessionKey: params.childSessionKey,
contextMode: params.context.mode,
parentSessionId: params.context.parentEntry?.sessionId,
parentSessionFile: params.context.parentEntry?.sessionFile,
childSessionId:
params.context.mode === "fork"
? params.context.forked.sessionId
: params.context.childEntry?.sessionId,
childSessionFile:
params.context.mode === "fork"
? params.context.forked.sessionFile
: params.context.childEntry?.sessionFile,
ttlMs: params.runTimeoutSeconds > 0 ? params.runTimeoutSeconds * 1000 : undefined,
});
return { status: "ok", preparation };
} catch (err) {
return {
status: "error",
error: `Context engine subagent preparation failed: ${summarizeError(err)}`,
};
}
}
async function rollbackPreparedContextEngine(
preparation?: SubagentSpawnPreparation,
): Promise<void> {
try {
await preparation?.rollback();
} catch {
// Best-effort cleanup only.
}
}
function sanitizeMountPathHint(value?: string): string | undefined {
const trimmed = normalizeOptionalString(value);
if (!trimmed) {
return undefined;
}
// Prevent prompt injection via control/newline characters in system prompt hints.
// eslint-disable-next-line no-control-regex
if (/[\r\n\u0000-\u001F\u007F\u0085\u2028\u2029]/.test(trimmed)) {
return undefined;
}
if (!/^[A-Za-z0-9._\-/:]+$/.test(trimmed)) {
return undefined;
}
return trimmed;
}
async function cleanupProvisionalSession(
childSessionKey: string,
options?: {
emitLifecycleHooks?: boolean;
deleteTranscript?: boolean;
},
): Promise<void> {
try {
await callSubagentGateway({
method: "sessions.delete",
params: {
key: childSessionKey,
emitLifecycleHooks: options?.emitLifecycleHooks === true,
deleteTranscript: options?.deleteTranscript === true,
},
timeoutMs: SUBAGENT_CONTROL_GATEWAY_TIMEOUT_MS,
});
} catch {
// Best-effort cleanup only.
}
}
async function cleanupFailedSpawnBeforeAgentStart(params: {
childSessionKey: string;
attachmentAbsDir?: string;
emitLifecycleHooks?: boolean;
deleteTranscript?: boolean;
}): Promise<void> {
if (params.attachmentAbsDir) {
try {
await fs.rm(params.attachmentAbsDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup only.
}
}
await cleanupProvisionalSession(params.childSessionKey, {
emitLifecycleHooks: params.emitLifecycleHooks,
deleteTranscript: params.deleteTranscript,
});
}
function resolveSpawnMode(params: {
requestedMode?: SpawnSubagentMode;
threadRequested: boolean;
}): SpawnSubagentMode {
if (params.requestedMode === "run" || params.requestedMode === "session") {
return params.requestedMode;
}
// Thread-bound spawns should default to persistent sessions.
return params.threadRequested ? "session" : "run";
}
function resolveSubagentContextMode(params: {
requestedContext?: SpawnSubagentContextMode;
threadRequested: boolean;
cfg: OpenClawConfig;
requester: {
channel?: string;
accountId?: string;
};
}): SpawnSubagentContextMode {
if (params.requestedContext === "fork" || params.requestedContext === "isolated") {
return params.requestedContext;
}
if (!params.threadRequested || !params.requester.channel) {
return "isolated";
}
return resolveThreadBindingSpawnPolicy({
cfg: params.cfg,
channel: params.requester.channel,
accountId: params.requester.accountId,
kind: "subagent",
}).defaultSpawnContext;
}
function summarizeError(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
if (typeof err === "string") {
return err;
}
return "error";
}
function buildThreadBindingUnavailableError(mode: SpawnSubagentMode): string {
if (mode === "session") {
return (
'sessions_spawn(mode="session") is only available on channels that expose thread bindings (e.g. Discord threads, Slack threads, Telegram forum topics). ' +
"This request is not running on a channel that can bind a subagent thread. " +
'Use mode="run" for one-shot subagent work, or sessions_send(sessionKey=...) to keep talking to a persistent session without thread binding.'
);
}
return (
"thread=true is only available on channels that expose thread bindings (e.g. Discord threads, Slack threads, Telegram forum topics). " +
"This request is not running on a channel that can bind a subagent thread. " +
"Retry without thread=true, or re-run sessions_spawn from a channel that supports threads."
);
}
async function ensureThreadBindingForSubagentSpawn(params: {
hookRunner: SubagentLifecycleHookRunner | null;
childSessionKey: string;
agentId: string;
label?: string;
mode: SpawnSubagentMode;
requesterSessionKey?: string;
requester: {
channel?: string;
accountId?: string;
to?: string;
threadId?: string | number;
};
}): Promise<
{ status: "ok"; deliveryOrigin?: DeliveryContext } | { status: "error"; error: string }
> {
if (!params.hookRunner?.hasHooks("subagent_spawning")) {
return {
status: "error",
error: buildThreadBindingUnavailableError(params.mode),
};
}
try {
const result = await params.hookRunner.runSubagentSpawning(
{
childSessionKey: params.childSessionKey,
agentId: params.agentId,
label: params.label,
mode: params.mode,
requester: params.requester,
threadRequested: true,
},
{
childSessionKey: params.childSessionKey,
requesterSessionKey: params.requesterSessionKey,
},
);
if (result?.status === "error") {
const error = result.error.trim();
return {
status: "error",
error: error || "Failed to prepare thread binding for this subagent session.",
};
}
if (!result) {
return {
status: "error",
error: buildThreadBindingUnavailableError(params.mode),
};
}
if (result?.status !== "ok" || !result.threadBindingReady) {
return {
status: "error",
error:
"Unable to create or bind a thread for this subagent session. Session mode is unavailable for this target.",
};
}
const deliveryOrigin = normalizeDeliveryContext(result.deliveryOrigin);
return {
status: "ok",
...(deliveryOrigin ? { deliveryOrigin } : {}),
};
} catch (err) {
return {
status: "error",
error: `Thread bind failed: ${summarizeError(err)}`,
};
}
}
function hasRoutableDeliveryOrigin(
origin?: DeliveryContext,
): origin is DeliveryContext & { channel: string; to: string } {
return Boolean(origin?.channel && origin.to);
}
export async function spawnSubagentDirect(
params: SpawnSubagentParams,
ctx: SpawnSubagentContext,
): Promise<SpawnSubagentResult> {
const task = params.task;
const label = params.label?.trim() || "";
const requestedAgentId = params.agentId?.trim();
// Reject malformed agentId before normalizeAgentId can mangle it.
// Without this gate, error-message strings like "Agent not found: xyz" pass
// through normalizeAgentId and become "agent-not-found--xyz", which later
// creates ghost workspace directories and triggers cascading cron loops (#31311).
if (requestedAgentId && !isValidAgentId(requestedAgentId)) {
return {
status: "error",
error: `Invalid agentId "${requestedAgentId}". Agent IDs must match [a-z0-9][a-z0-9_-]{0,63}. Use agents_list to discover valid targets.`,
};
}
const modelOverride = params.model;
const thinkingOverrideRaw = params.thinking;
const requestThreadBinding = params.thread === true;
const sandboxMode = params.sandbox === "require" ? "require" : "inherit";
const spawnMode = resolveSpawnMode({
requestedMode: params.mode,
threadRequested: requestThreadBinding,
});
if (spawnMode === "session" && !requestThreadBinding) {
return {
status: "error",
error:
'sessions_spawn(mode="session") requires thread=true so the subagent can stay bound to a channel thread. ' +
'Retry with { mode: "session", thread: true } on a channel that supports threads, use mode="run" for one-shot work, or use sessions_send(sessionKey=...) to keep talking to a persistent session without thread binding.',
};
}
const cleanup =
spawnMode === "session"
? "keep"
: params.cleanup === "keep" || params.cleanup === "delete"
? params.cleanup
: "keep";
const expectsCompletionMessage = params.expectsCompletionMessage !== false;
const hookRunner = subagentSpawnDeps.getGlobalHookRunner();
const cfg = loadSubagentConfig();
// 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 runTimeoutSeconds = resolveConfiguredSubagentRunTimeoutSeconds({
cfg,
runTimeoutSeconds: params.runTimeoutSeconds,
});
let modelApplied = false;
let threadBindingReady = false;
let hasBoundThreadDeliveryOrigin = false;
const contextMode = resolveSubagentContextMode({
requestedContext: params.context,
threadRequested: requestThreadBinding,
cfg,
requester: {
channel: ctx.agentChannel,
accountId: ctx.agentAccountId,
},
});
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 ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH;
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 ?? DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT;
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 requireAgentId =
resolveAgentConfig(cfg, requesterAgentId)?.subagents?.requireAgentId ??
cfg.agents?.defaults?.subagents?.requireAgentId ??
false;
if (requireAgentId && !requestedAgentId?.trim()) {
return {
status: "forbidden",
error:
"sessions_spawn requires explicit agentId when requireAgentId is configured. Use agents_list to see allowed agent ids.",
};
}
const targetAgentId = requestedAgentId ? normalizeAgentId(requestedAgentId) : requesterAgentId;
const requesterOrigin = normalizeDeliveryContext({
channel: ctx.agentChannel,
accountId: ctx.agentAccountId,
to: ctx.agentTo,
...(ctx.agentThreadId != null && ctx.agentThreadId !== ""
? { threadId: ctx.agentThreadId }
: {}),
});
let childSessionOrigin = resolveRequesterOriginForChild({
cfg,
targetAgentId,
requesterAgentId,
requesterChannel: ctx.agentChannel,
requesterAccountId: ctx.agentAccountId,
requesterTo: ctx.agentTo,
requesterThreadId: ctx.agentThreadId,
requesterGroupSpace: ctx.agentGroupSpace,
requesterMemberRoleIds: ctx.agentMemberRoleIds,
});
const targetPolicy = resolveSubagentTargetPolicy({
requesterAgentId,
targetAgentId,
requestedAgentId,
allowAgents:
resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ??
cfg?.agents?.defaults?.subagents?.allowAgents,
});
if (!targetPolicy.ok) {
return {
status: "forbidden",
error: targetPolicy.error,
};
}
const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`;
const requesterRuntime = resolveSandboxRuntimeStatus({
cfg,
sessionKey: requesterInternalKey,
});
const childRuntime = resolveSandboxRuntimeStatus({
cfg,
sessionKey: childSessionKey,
});
if (!childRuntime.sandboxed && (requesterRuntime.sandboxed || sandboxMode === "require")) {
if (requesterRuntime.sandboxed) {
return {
status: "forbidden",
error:
"Sandboxed sessions cannot spawn unsandboxed subagents. Set a sandboxed target agent or use the same agent runtime.",
};
}
return {
status: "forbidden",
error:
'sessions_spawn sandbox="require" needs a sandboxed target runtime. Pick a sandboxed agentId or use sandbox="inherit".',
};
}
const childDepth = callerDepth + 1;
const spawnedByKey = requesterInternalKey;
const childCapabilities = resolveSubagentCapabilities({
depth: childDepth,
maxSpawnDepth,
});
const targetAgentDir = resolveAgentDir(cfg, targetAgentId);
const targetAgentConfig = resolveAgentConfig(cfg, targetAgentId);
const plan = resolveSubagentModelAndThinkingPlan({
cfg,
targetAgentId,
targetAgentConfig,
modelOverride,
thinkingOverrideRaw,
});
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 {
const target = resolveGatewaySessionStoreTarget({
cfg,
key: childSessionKey,
});
await updateSubagentSessionStore(target.storePath, (store) => {
pruneLegacyStoreKeys({
store,
canonicalKey: target.canonicalKey,
candidates: target.storeKeys,
});
store[target.canonicalKey] = mergeSessionEntry(
store[target.canonicalKey],
buildDirectChildSessionPatch(patch),
);
});
return undefined;
} catch (err) {
const message = err instanceof Error ? err.message : typeof err === "string" ? err : "error";
return `child session patch failed: ${message}`;
}
};
const initialChildSessionPatch: Record<string, unknown> = {
spawnDepth: childDepth,
subagentRole: childCapabilities.role === "main" ? null : childCapabilities.role,
subagentControlScope: childCapabilities.controlScope,
...plan.initialSessionPatch,
};
const initialPatchError = await patchChildSession(initialChildSessionPatch);
if (initialPatchError) {
return {
status: "error",
error: initialPatchError,
childSessionKey,
};
}
const preparedSpawnContext = await prepareSubagentSessionContext({
cfg,
contextMode,
requesterAgentId,
targetAgentId,
requesterInternalKey,
childSessionKey,
});
if (preparedSpawnContext.status === "error") {
await cleanupProvisionalSession(childSessionKey, {
emitLifecycleHooks: false,
deleteTranscript: true,
});
return {
status: "error",
error: preparedSpawnContext.error,
childSessionKey,
};
}
if (resolvedModel) {
const runtimeModelPersistError = await persistInitialChildSessionRuntimeModel({
cfg,
childSessionKey,
resolvedModel,
});
if (runtimeModelPersistError) {
try {
await callSubagentGateway({
method: "sessions.delete",
params: { key: childSessionKey, emitLifecycleHooks: false },
timeoutMs: SUBAGENT_CONTROL_GATEWAY_TIMEOUT_MS,
});
} catch {
// Best-effort cleanup only.
}
return {
status: "error",
error: runtimeModelPersistError,
childSessionKey,
};
}
modelApplied = true;
}
if (requestThreadBinding) {
const bindResult = await ensureThreadBindingForSubagentSpawn({
hookRunner,
childSessionKey,
agentId: targetAgentId,
label: label || undefined,
mode: spawnMode,
requesterSessionKey: requesterInternalKey,
requester: {
channel: childSessionOrigin?.channel,
accountId: childSessionOrigin?.accountId,
to: childSessionOrigin?.to,
threadId: childSessionOrigin?.threadId,
},
});
if (bindResult.status === "error") {
try {
await callSubagentGateway({
method: "sessions.delete",
params: { key: childSessionKey, deleteTranscript: true, emitLifecycleHooks: false },
timeoutMs: SUBAGENT_CONTROL_GATEWAY_TIMEOUT_MS,
});
} catch {
// Best-effort cleanup only.
}
return {
status: "error",
error: bindResult.error,
childSessionKey,
};
}
threadBindingReady = true;
hasBoundThreadDeliveryOrigin = hasRoutableDeliveryOrigin(bindResult.deliveryOrigin);
childSessionOrigin =
mergeDeliveryContext(bindResult.deliveryOrigin, childSessionOrigin) ?? childSessionOrigin;
}
const mountPathHint = sanitizeMountPathHint(params.attachMountPath);
let childSystemPrompt = buildSubagentSystemPrompt({
requesterSessionKey,
requesterOrigin: childSessionOrigin,
childSessionKey,
label: label || undefined,
task,
acpEnabled: isAcpRuntimeSpawnAvailable({
config: cfg,
sandboxed: childRuntime.sandboxed,
}),
nativeCommandGuidanceLines: listRegisteredPluginAgentPromptGuidance(),
childDepth,
maxSpawnDepth,
});
let retainOnSessionKeep = false;
let attachmentsReceipt:
| {
count: number;
totalBytes: number;
files: SubagentAttachmentReceiptFile[];
relDir: string;
}
| undefined;
let attachmentAbsDir: string | undefined;
let attachmentRootDir: string | undefined;
const materializedAttachments = await materializeSubagentAttachments({
config: cfg,
targetAgentId,
attachments: params.attachments,
mountPathHint,
});
if (materializedAttachments && materializedAttachments.status !== "ok") {
await cleanupProvisionalSession(childSessionKey, {
emitLifecycleHooks: threadBindingReady,
deleteTranscript: true,
});
return {
status: materializedAttachments.status,
error: materializedAttachments.error,
};
}
if (materializedAttachments?.status === "ok") {
retainOnSessionKeep = materializedAttachments.retainOnSessionKeep;
attachmentsReceipt = materializedAttachments.receipt;
attachmentAbsDir = materializedAttachments.absDir;
attachmentRootDir = materializedAttachments.rootDir;
childSystemPrompt = `${childSystemPrompt}\n\n${materializedAttachments.systemPromptSuffix}`;
}
const bootstrapContextMode: BootstrapContextMode | undefined = params.lightContext
? "lightweight"
: undefined;
const childTaskMessage = buildSubagentInitialUserMessage({
childDepth,
maxSpawnDepth,
persistentSession: spawnMode === "session",
});
const toolSpawnMetadata = mapToolContextToSpawnedRunMetadata({
agentGroupId: ctx.agentGroupId,
agentGroupChannel: ctx.agentGroupChannel,
agentGroupSpace: ctx.agentGroupSpace,
workspaceDir: ctx.workspaceDir,
});
const spawnedMetadata = normalizeSpawnedRunMetadata({
spawnedBy: spawnedByKey,
...toolSpawnMetadata,
workspaceDir: resolveSpawnedWorkspaceInheritance({
config: cfg,
targetAgentId,
// For cross-agent spawns, ignore the caller's inherited workspace;
// let targetAgentId resolve the correct workspace instead.
explicitWorkspaceDir:
targetAgentId !== requesterAgentId ? undefined : toolSpawnMetadata.workspaceDir,
}),
});
const spawnLineagePatchError = await patchChildSession({
spawnedBy: spawnedByKey,
...(spawnedMetadata.workspaceDir ? { spawnedWorkspaceDir: spawnedMetadata.workspaceDir } : {}),
});
if (spawnLineagePatchError) {
await cleanupFailedSpawnBeforeAgentStart({
childSessionKey,
attachmentAbsDir,
emitLifecycleHooks: threadBindingReady,
deleteTranscript: true,
});
return {
status: "error",
error: spawnLineagePatchError,
childSessionKey,
};
}
const contextEnginePrepareResult = await prepareContextEngineSubagentSpawn({
cfg,
context: preparedSpawnContext,
requesterInternalKey,
childSessionKey,
runTimeoutSeconds,
});
if (contextEnginePrepareResult.status === "error") {
await cleanupFailedSpawnBeforeAgentStart({
childSessionKey,
attachmentAbsDir,
emitLifecycleHooks: threadBindingReady,
deleteTranscript: true,
});
return {
status: "error",
error: contextEnginePrepareResult.error,
childSessionKey,
};
}
const contextEnginePreparation = contextEnginePrepareResult.preparation;
const childIdem = crypto.randomUUID();
let childRunId: string = childIdem;
const deliverInitialChildRunDirectly =
requestThreadBinding && spawnMode === "session" && hasBoundThreadDeliveryOrigin;
const shouldAnnounceCompletion = deliverInitialChildRunDirectly
? false
: expectsCompletionMessage;
try {
const {
spawnedBy: _spawnedBy,
workspaceDir: _workspaceDir,
...publicSpawnedMetadata
} = spawnedMetadata;
const response = await callSubagentGateway({
method: "agent",
params: {
message: childTaskMessage,
sessionKey: childSessionKey,
channel: childSessionOrigin?.channel,
to: childSessionOrigin?.to ?? undefined,
accountId: childSessionOrigin?.accountId ?? undefined,
threadId:
childSessionOrigin?.threadId != null
? stringifyRouteThreadId(childSessionOrigin.threadId)
: undefined,
idempotencyKey: childIdem,
deliver: deliverInitialChildRunDirectly,
lane: AGENT_LANE_SUBAGENT,
cleanupBundleMcpOnRunEnd: spawnMode !== "session",
extraSystemPrompt: childSystemPrompt,
thinking: thinkingOverride,
timeout: runTimeoutSeconds,
label: label || undefined,
...(bootstrapContextMode
? {
bootstrapContextMode,
bootstrapContextRunKind: "default" as const,
}
: {}),
...publicSpawnedMetadata,
},
timeoutMs: resolveSubagentAgentGatewayTimeoutMs(runTimeoutSeconds),
});
const runId = readGatewayRunId(response);
if (runId) {
childRunId = runId;
}
} catch (err) {
await rollbackPreparedContextEngine(contextEnginePreparation);
if (attachmentAbsDir) {
try {
await fs.rm(attachmentAbsDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup only.
}
}
let emitLifecycleHooks = false;
if (threadBindingReady) {
const hasEndedHook = hookRunner?.hasHooks("subagent_ended") === true;
let endedHookEmitted = false;
if (hasEndedHook) {
try {
await hookRunner?.runSubagentEnded(
{
targetSessionKey: childSessionKey,
targetKind: "subagent",
reason: "spawn-failed",
sendFarewell: true,
accountId: childSessionOrigin?.accountId,
runId: childRunId,
outcome: "error",
error: "Session failed to start",
},
{
runId: childRunId,
childSessionKey,
requesterSessionKey: requesterInternalKey,
},
);
endedHookEmitted = true;
} catch {
// Spawn should still return an actionable error even if cleanup hooks fail.
}
}
emitLifecycleHooks = !endedHookEmitted;
}
// Always delete the provisional child session after a failed spawn attempt.
// If we already emitted subagent_ended above, suppress a duplicate lifecycle hook.
try {
await callSubagentGateway({
method: "sessions.delete",
params: {
key: childSessionKey,
deleteTranscript: true,
emitLifecycleHooks,
},
timeoutMs: SUBAGENT_CONTROL_GATEWAY_TIMEOUT_MS,
});
} catch {
// Best-effort only.
}
const messageText = summarizeError(err);
return {
status: "error",
error: messageText,
childSessionKey,
runId: childRunId,
};
}
try {
registerSubagentRun({
runId: childRunId,
childSessionKey,
controllerSessionKey: requesterInternalKey,
requesterSessionKey: requesterInternalKey,
requesterOrigin,
requesterDisplayKey,
task,
cleanup,
label: label || undefined,
model: resolvedModel,
agentDir: targetAgentDir,
workspaceDir: spawnedMetadata.workspaceDir,
runTimeoutSeconds,
expectsCompletionMessage: shouldAnnounceCompletion,
spawnMode,
attachmentsDir: attachmentAbsDir,
attachmentsRootDir: attachmentRootDir,
retainAttachmentsOnKeep: retainOnSessionKeep,
});
} catch (err) {
await rollbackPreparedContextEngine(contextEnginePreparation);
if (attachmentAbsDir) {
try {
await fs.rm(attachmentAbsDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup only.
}
}
try {
await callSubagentGateway({
method: "sessions.delete",
params: {
key: childSessionKey,
deleteTranscript: true,
emitLifecycleHooks: threadBindingReady,
},
timeoutMs: SUBAGENT_CONTROL_GATEWAY_TIMEOUT_MS,
});
} catch {
// Best-effort cleanup only.
}
return {
status: "error",
error: `Failed to register subagent run: ${summarizeError(err)}`,
childSessionKey,
runId: childRunId,
};
}
if (hookRunner?.hasHooks("subagent_spawned")) {
try {
await hookRunner.runSubagentSpawned(
{
runId: childRunId,
childSessionKey,
agentId: targetAgentId,
label: label || undefined,
requester: {
channel: requesterOrigin?.channel,
accountId: requesterOrigin?.accountId,
to: requesterOrigin?.to,
threadId: requesterOrigin?.threadId,
},
threadRequested: requestThreadBinding,
mode: spawnMode,
},
{
runId: childRunId,
childSessionKey,
requesterSessionKey: requesterInternalKey,
},
);
} catch {
// Spawn should still return accepted if spawn lifecycle hooks fail.
}
}
// Emit lifecycle event so the gateway can broadcast sessions.changed to SSE subscribers.
emitSessionLifecycleEvent({
sessionKey: childSessionKey,
reason: "create",
parentSessionKey: requesterInternalKey,
label: label || undefined,
});
const acceptedNote = resolveSubagentSpawnAcceptedNote({
spawnMode,
agentSessionKey: ctx.agentSessionKey,
});
return {
status: "accepted",
childSessionKey,
runId: childRunId,
mode: spawnMode,
note: preparedSpawnContext.forkFallbackNote
? `${acceptedNote} ${preparedSpawnContext.forkFallbackNote}`
: acceptedNote,
modelApplied: resolvedModel ? modelApplied : undefined,
attachments: attachmentsReceipt,
};
}
export const __testing = {
setDepsForTest(overrides?: Partial<SubagentSpawnDeps>) {
subagentSpawnDeps = overrides
? {
...defaultSubagentSpawnDeps,
...overrides,
}
: defaultSubagentSpawnDeps;
},
};