import { defined } from "#src/optional"; import type { PreparedSubagentsLaunch, Subagents, } from "#src/service/subagents"; import { renderSpawnNotes, textResult } from "#src/tools/helpers"; import type { ResolvedSpawnConfig, SpawnPresentation, } from "#src/tools/spawn-config"; import { type SubagentToolOutput, withStructured, } from "#src/tools/structured-output"; import type { AgentDetails } from "#src/ui/display"; /** Narrow manager interface for the background spawner. */ export type BackgroundManagerDeps = Pick; /** All values the background spawner needs beyond the resolved config. */ export type BackgroundParams = { config: ResolvedSpawnConfig; launch: PreparedSubagentsLaunch; settings: { readonly maxConcurrent: number }; }; /** What a background launch reports: one shape for every door that returns before the run ends. */ export type BackgroundLaunch = { /** The leading line, e.g. "Agent started in background." */ headline: string; id: string; displayName: string; description: string; detailBase: SpawnPresentation["detailBase"]; /** Advisories that lead the result; omitted when the door has none. */ notes?: ReadonlyArray; outputFile?: string; /** Present only when the launch is waiting for a concurrency slot. */ queuePosition?: { maxConcurrent: number }; }; /** * Spawn a background agent and return the tool result immediately. * Owns: mapping the spawned record onto its launch report. */ export function spawnBackground( manager: BackgroundManagerDeps, params: BackgroundParams, ) { const { identity, execution, presentation, notes } = params.config; let id: string; try { const outcome = manager.spawn(params.launch, { kind: "explicit", isBackground: true, }); // The shared Subagents seam carries the captured definition, owner, // snapshot, session identity, and resolved execution fields to acceptance. // Delivery remains background-specific here. // A refusal is an expected answer, not a crash: the session this launch // belonged to is gone, so it is reported in the door's own words — as a // structured value, so a caller reads the reason instead of parsing it. if (outcome.kind === "rejected") { return withStructured( { ...textResult( launchRefusedMessage(outcome.reason, identity.displayName), ), isError: true, }, { outcome: "rejected", reason: outcome.reason, agentType: identity.subagentType, }, ); } id = outcome.value; } catch (err) { const message = err instanceof Error ? err.message : String(err); // A spawn that throws is a defect in this runtime, not an answer about the // child: flagged as an error so it is not mistaken for a launch outcome. return withStructured( { ...textResult(message), isError: true }, { outcome: "failed", error: message, agentType: identity.subagentType }, ); } const record = manager.getRecord(id); const isQueued = record?.status === "queued"; const output: SubagentToolOutput = { outcome: "accepted", agentId: id, agentType: identity.subagentType, background: true, queued: isQueued, ...defined({ outputFile: record?.outputFile }), }; return withStructured( renderBackgroundLaunch( defined({ headline: `Agent ${isQueued ? "queued" : "started"} in background.`, id, displayName: identity.displayName, description: execution.description, detailBase: presentation.detailBase, notes, outputFile: record?.outputFile, queuePosition: isQueued ? { maxConcurrent: params.settings.maxConcurrent } : undefined, }), ), output, ); } /** * The operator-facing sentence for a launch refused before a child was created. * * Exhausts the launch reasons: agent type, configuration, or session ownership. * Adding a new reason fails to compile here rather than * falling through to an unhelpful answer. */ function launchRefusedMessage( reason: | "no-active-session" | "session-ended" | "unknown-agent" | "disabled-agent" | "configuration", displayName: string, ): string { switch (reason) { case "no-active-session": return "No active session — cannot launch agents outside a session."; case "session-ended": return "Session ended before the launch was accepted."; case "unknown-agent": return `Unknown agent type "${displayName}".`; case "disabled-agent": return `Agent "${displayName}" is disabled.`; case "configuration": return `Agent "${displayName}" has invalid definition configuration.`; } } /** * Render a background launch as the tool result: the launch message and the * `background` details the result renderer shows while the run continues. * Owns: launch message formatting, for every door that launches in the background. */ export function renderBackgroundLaunch(launch: BackgroundLaunch) { // Annotated rather than inlined into the call: `textResult` is generic over its // details, so an inline literal would define the type instead of being checked // against it. const details: AgentDetails = { ...launch.detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background", agentId: launch.id, }; return textResult( renderSpawnNotes(launch.notes ?? []) + `${launch.headline}\n` + `Agent ID: ${launch.id}\n` + `Type: ${launch.displayName}\n` + `Description: ${launch.description}\n` + (launch.outputFile ? `Output file: ${launch.outputFile}\n` : "") + (launch.queuePosition ? `Position: queued (max ${launch.queuePosition.maxConcurrent} concurrent)\n` : "") + `\nYou will be notified when this agent completes.\n` + `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` + `Do not duplicate this agent's work.`, details, ); }