2026-02-16 10:07:22 -06:00
import crypto from "node:crypto" ;
import { formatThinkingLevels , normalizeThinkLevel } from "../auto-reply/thinking.js" ;
2026-02-21 16:14:55 +01:00
import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js" ;
2026-02-16 10:07:22 -06:00
import { loadConfig } from "../config/config.js" ;
import { callGateway } from "../gateway/call.js" ;
2026-02-21 16:14:55 +01:00
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js" ;
2026-02-16 10:07:22 -06:00
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" ;
2026-02-18 05:59:20 +01:00
import { resolveSubagentSpawnModelSelection } from "./model-selection.js" ;
2026-02-16 10:07:22 -06:00
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" ;
2026-02-21 16:14:55 +01:00
export const SUBAGENT_SPAWN_MODES = [ "run" , "session" ] as const ;
export type SpawnSubagentMode = ( typeof SUBAGENT_SPAWN_MODES ) [ number ] ;
2026-02-16 10:07:22 -06:00
export type SpawnSubagentParams = {
task : string ;
label? : string ;
agentId? : string ;
model? : string ;
thinking? : string ;
runTimeoutSeconds? : number ;
2026-02-21 16:14:55 +01:00
thread? : boolean ;
mode? : SpawnSubagentMode ;
2026-02-16 10:07:22 -06:00
cleanup ? : "delete" | "keep" ;
2026-02-18 02:45:05 +01:00
expectsCompletionMessage? : boolean ;
2026-02-16 10:07:22 -06:00
} ;
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 ;
} ;
2026-02-17 15:49:22 -08:00
export const SUBAGENT_SPAWN_ACCEPTED_NOTE =
2026-02-18 16:57:13 -08:00
"auto-announces on completion, do not poll/sleep. The response will be sent back as an user message." ;
2026-02-21 16:14:55 +01:00
export const SUBAGENT_SPAWN_SESSION_ACCEPTED_NOTE =
"thread-bound session stays active after this task; continue in-thread for follow-ups." ;
2026-02-17 11:05:37 -08:00
2026-02-16 10:07:22 -06:00
export type SpawnSubagentResult = {
status : "accepted" | "forbidden" | "error" ;
childSessionKey? : string ;
runId? : string ;
2026-02-21 16:14:55 +01:00
mode? : SpawnSubagentMode ;
2026-02-17 11:05:37 -08:00
note? : string ;
2026-02-16 10:07:22 -06:00
modelApplied? : boolean ;
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 } ;
}
2026-02-21 16:14:55 +01:00
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 summarizeError ( err : unknown ) : string {
if ( err instanceof Error ) {
return err . message ;
}
if ( typeof err === "string" ) {
return err ;
}
return "error" ;
}
async function ensureThreadBindingForSubagentSpawn ( params : {
hookRunner : ReturnType < typeof getGlobalHookRunner > ;
childSessionKey : string ;
agentId : string ;
label? : string ;
mode : SpawnSubagentMode ;
requesterSessionKey? : string ;
requester : {
channel? : string ;
accountId? : string ;
to? : string ;
threadId? : string | number ;
} ;
} ) : Promise < { status : "ok" } | { status : "error" ; error : string } > {
const hookRunner = params . hookRunner ;
if ( ! hookRunner ? . hasHooks ( "subagent_spawning" ) ) {
return {
status : "error" ,
error :
"thread=true is unavailable because no channel plugin registered subagent_spawning hooks." ,
} ;
}
try {
const result = await 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 ? . 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." ,
} ;
}
return { status : "ok" } ;
} catch ( err ) {
return {
status : "error" ,
error : ` Thread bind failed: ${ summarizeError ( err ) } ` ,
} ;
}
}
2026-02-16 10:07:22 -06:00
export async function spawnSubagentDirect (
params : SpawnSubagentParams ,
ctx : SpawnSubagentContext ,
) : Promise < SpawnSubagentResult > {
const task = params . task ;
const label = params . label ? . trim ( ) || "" ;
const requestedAgentId = params . agentId ;
const modelOverride = params . model ;
const thinkingOverrideRaw = params . thinking ;
2026-02-21 16:14:55 +01:00
const requestThreadBinding = params . thread === true ;
const spawnMode = resolveSpawnMode ( {
requestedMode : params.mode ,
threadRequested : requestThreadBinding ,
} ) ;
if ( spawnMode === "session" && ! requestThreadBinding ) {
return {
status : "error" ,
error : 'mode="session" requires thread=true so the subagent can stay bound to a thread.' ,
} ;
}
2026-02-16 10:07:22 -06:00
const cleanup =
2026-02-21 16:14:55 +01:00
spawnMode === "session"
? "keep"
: params . cleanup === "keep" || params . cleanup === "delete"
? params . cleanup
: "keep" ;
const expectsCompletionMessage = params . expectsCompletionMessage !== false ;
2026-02-16 10:07:22 -06:00
const requesterOrigin = normalizeDeliveryContext ( {
channel : ctx.agentChannel ,
accountId : ctx.agentAccountId ,
to : ctx.agentTo ,
threadId : ctx.agentThreadId ,
} ) ;
2026-02-21 16:14:55 +01:00
const hookRunner = getGlobalHookRunner ( ) ;
2026-02-16 10:07:22 -06:00
const runTimeoutSeconds =
typeof params . runTimeoutSeconds === "number" && Number . isFinite ( params . runTimeoutSeconds )
? Math . max ( 0 , Math . floor ( params . runTimeoutSeconds ) )
: 0 ;
let modelApplied = false ;
2026-02-21 16:14:55 +01:00
let threadBindingReady = false ;
2026-02-16 10:07:22 -06:00
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 } ) ;
2026-02-21 16:14:55 +01:00
const maxSpawnDepth =
cfg . agents ? . defaults ? . subagents ? . maxSpawnDepth ? ? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH ;
2026-02-16 10:07:22 -06:00
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 ) ;
2026-02-18 05:59:20 +01:00
const resolvedModel = resolveSubagentSpawnModelSelection ( {
2026-02-16 10:07:22 -06:00
cfg ,
agentId : targetAgentId ,
2026-02-18 05:59:20 +01:00
modelOverride ,
2026-02-16 10:07:22 -06:00
} ) ;
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" ;
2026-02-18 05:59:20 +01:00
return {
status : "error" ,
error : messageText ,
childSessionKey ,
} ;
2026-02-16 10:07:22 -06:00
}
}
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 ,
} ;
}
}
2026-02-21 16:14:55 +01:00
if ( requestThreadBinding ) {
const bindResult = await ensureThreadBindingForSubagentSpawn ( {
hookRunner ,
childSessionKey ,
agentId : targetAgentId ,
label : label || undefined ,
mode : spawnMode ,
requesterSessionKey : requesterInternalKey ,
requester : {
channel : requesterOrigin?.channel ,
accountId : requesterOrigin?.accountId ,
to : requesterOrigin?.to ,
threadId : requesterOrigin?.threadId ,
} ,
} ) ;
if ( bindResult . status === "error" ) {
try {
await callGateway ( {
method : "sessions.delete" ,
params : { key : childSessionKey , emitLifecycleHooks : false } ,
timeoutMs : 10_000 ,
} ) ;
} catch {
// Best-effort cleanup only.
}
return {
status : "error" ,
error : bindResult.error ,
childSessionKey ,
} ;
}
threadBindingReady = true ;
}
2026-02-16 10:07:22 -06:00
const childSystemPrompt = buildSubagentSystemPrompt ( {
requesterSessionKey ,
requesterOrigin ,
childSessionKey ,
label : label || undefined ,
task ,
childDepth ,
maxSpawnDepth ,
} ) ;
2026-02-17 11:05:37 -08:00
const childTaskMessage = [
` [Subagent Context] You are running as a subagent (depth ${ childDepth } / ${ maxSpawnDepth } ). Results auto-announce to your requester; do not busy-poll for status. ` ,
2026-02-21 16:14:55 +01:00
spawnMode === "session"
? "[Subagent Context] This subagent session is persistent and remains available for thread follow-up messages."
: undefined ,
2026-02-17 11:05:37 -08:00
` [Subagent Task]: ${ task } ` ,
2026-02-21 16:14:55 +01:00
]
. filter ( ( line ) : line is string = > Boolean ( line ) )
. join ( "\n\n" ) ;
2026-02-16 10:07:22 -06:00
const childIdem = crypto . randomUUID ( ) ;
let childRunId : string = childIdem ;
try {
const response = await callGateway < { runId : string } > ( {
method : "agent" ,
params : {
2026-02-17 11:05:37 -08:00
message : childTaskMessage ,
2026-02-16 10:07:22 -06:00
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 ) {
2026-02-21 16:14:55 +01:00
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 : requesterOrigin?.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.
}
}
// 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 callGateway ( {
method : "sessions.delete" ,
params : {
key : childSessionKey ,
deleteTranscript : true ,
emitLifecycleHooks : ! endedHookEmitted ,
} ,
timeoutMs : 10_000 ,
} ) ;
} catch {
// Best-effort only.
}
}
const messageText = summarizeError ( err ) ;
2026-02-16 10:07:22 -06:00
return {
status : "error" ,
error : messageText ,
childSessionKey ,
runId : childRunId ,
} ;
}
registerSubagentRun ( {
runId : childRunId ,
childSessionKey ,
requesterSessionKey : requesterInternalKey ,
requesterOrigin ,
requesterDisplayKey ,
task ,
cleanup ,
label : label || undefined ,
model : resolvedModel ,
runTimeoutSeconds ,
2026-02-21 16:14:55 +01:00
expectsCompletionMessage ,
spawnMode ,
2026-02-16 10:07:22 -06:00
} ) ;
2026-02-21 16:14:55 +01:00
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.
}
}
2026-02-16 10:07:22 -06:00
return {
status : "accepted" ,
childSessionKey ,
runId : childRunId ,
2026-02-21 16:14:55 +01:00
mode : spawnMode ,
note :
spawnMode === "session" ? SUBAGENT_SPAWN_SESSION_ACCEPTED_NOTE : SUBAGENT_SPAWN_ACCEPTED_NOTE ,
2026-02-16 10:07:22 -06:00
modelApplied : resolvedModel ? modelApplied : undefined ,
} ;
}