/** * Shared L3 subagent context + result rendering (saga u4uo9m.5). * * The bundled subagent tools are thin L3 clients of the daemon. They share a captured * {@link SubagentToolContext} (the managed daemon context, the trusted definition snapshot, * and an injectable daemon-client connector) and a small set of rendering helpers that turn * daemon responses into BOUNDED structured JSON + recognizable render descriptors. Task and * prompt bodies never appear in results, logs, or renders (saga u4uo9m non-disclosure). * * Layer L3: imports only L1 (`@/protocol/*`, `@/contracts/*`) + `node:crypto`. The eslint * boundary guard (plan item 9) keeps this honest. */ import { randomUUID } from 'node:crypto'; import type { ManagedContext } from '@/contracts/managed-context'; import type { AgentDefinitionSnapshot, AgentDefinitionRecord } from '@/contracts/agent-definition'; import type { ToolResult, ToolContext } from '@/contracts/tool'; import type { RenderDescriptor } from '@/contracts/render-descriptor'; import { connectSubagentClient, classifyClientFailure, type SubagentClient, type SubagentClientResult, type SubagentCommand, type DaemonRequest, } from '@/subagent/daemon-client'; /** * The daemon-client connector the tools use. Production wires * {@link connectSubagentClient}; tests inject a fake that resolves scripted responses * without touching a socket. Returns a client the tool MUST `close()` when done (the * extension wraps each tool execution in an open/close pair; `subagent_wait` keeps its * client open for the duration of the wait). */ export type SubagentConnector = (socketPath: string) => Promise; /** Options for {@link createSubagentToolContext}. */ export interface SubagentToolContextOptions { /** Pinned managed daemon context (socket + durable parent id + depth + launch policy). */ readonly managed: ManagedContext; /** Trusted agent-definition snapshot (for launch prevalidation + inheritance). */ readonly definitions: AgentDefinitionSnapshot; /** Injectable daemon-client connector (default: real Unix-socket client). */ readonly connect?: SubagentConnector; /** Injectable id factory for command correlation (default `randomUUID`). */ readonly generateId?: () => string; } /** * The captured L3 context every subagent tool closes over. Built by the extension factory * (saga u4uo9m.5 plan item 1) from a {@link SubagentToolContextOptions}; the L4 CLI injects * the factory and the bootstrap hands it the trusted snapshots + managed context. */ export interface SubagentToolContext { readonly managed: ManagedContext; readonly definitions: AgentDefinitionSnapshot; /** Connect a short-lived daemon client. */ connect(): Promise; /** Allocate a fresh command correlation id. */ readonly generateId: () => string; } /** Build the captured L3 context. Pure except for the connector default closure. */ export function createSubagentToolContext(opts: SubagentToolContextOptions): SubagentToolContext { const connect: SubagentConnector = opts.connect ?? ((socketPath) => connectSubagentClient({ socketPath })); return { managed: opts.managed, definitions: opts.definitions, connect: () => connect(opts.managed.daemon_socket), generateId: opts.generateId ?? randomUUID, }; } /** * Resolve a trusted definition by name (case-sensitive; definitions are a parsed-name * last-wins map). Returns `undefined` when no trusted definition matches — `subagent_launch` * surfaces a recognizable `NOT_FOUND` immediately (prevalidation before submission) so the * model gets fast feedback rather than a daemon round-trip. The daemon ALSO resolves it * authoritatively (defense in depth — the daemon pins body/policy); this is the fast path. */ export function resolveTrustedDefinition( snapshot: AgentDefinitionSnapshot, name: string, ): AgentDefinitionRecord | undefined { return snapshot.agents.find((a) => a.name === name); } /** * Derive the inherited model/provider/mode for a `subagent.create` command * (capability policy: definition overrides parent current; omissions inherit at launch * execution time). The daemon's `createChildManifest` applies `cmd.model ?? def.model ?? * parent.spawn_options`; sending `def.{field} ?? ctx.current{Field}` makes definition * overrides win while falling back to the parent's CURRENT effective values (which reflect * a mid-session model/mode switch). Fields are omitted entirely when both are absent so the * daemon inherits from the parent manifest / definition. */ export function deriveInheritedModelProviderMode( def: AgentDefinitionRecord | undefined, ctx: ToolContext, ): { model?: string; provider?: string; mode?: string } { const out: { model?: string; provider?: string; mode?: string } = {}; const model = def?.model ?? ctx.currentModel?.model; const provider = def?.provider ?? ctx.currentModel?.provider; const mode = def?.mode ?? ctx.currentMode; if (model !== undefined && model.length > 0) out.model = model; if (provider !== undefined && provider.length > 0) out.provider = provider; if (mode !== undefined && mode.length > 0) out.mode = mode; return out; } /** * Whether this managed child is eligible to launch subagents at all (plan item 6: launch * requires policy/depth eligibility). Eligibility: launching produces a child at * `depth + 1`, which the daemon enforces must be `<= launch_policy.maxDepth`. A child whose * own policy forbids children errors before contacting the daemon. (The daemon is the * authoritative enforcer; this is the recognizable fast path.) */ export function launchEligible(managed: ManagedContext): boolean { return managed.depth + 1 <= managed.launch_policy.maxDepth; } // ── Result rendering (bounded; no task/prompt bodies) ───────────────────────────── /** Build a successful structured-JSON tool result (saga u4uo9m.5 plan item 8). */ export function jsonResult(value: unknown, render?: RenderDescriptor): ToolResult { const res: ToolResult = { content: { kind: 'json', value }, isError: false }; if (render !== undefined) res.render = render; return res; } /** Build an error tool result from a structured message (no private text). */ export function errorResult(message: string, render?: RenderDescriptor): ToolResult { const res: ToolResult = { content: { kind: 'error', message }, isError: true }; if (render !== undefined) res.render = render; return res; } /** Render a labeled status line (recognizable in the transcript). */ export function statusRender(label: string, tone: 'info' | 'warn' | 'error' = 'info'): RenderDescriptor { return { kind: 'status', label, tone }; } /** * Run a daemon command end-to-end: connect a short-lived client, send `cmd`, close, and map * the result. On a `success:false` reply, surface a recognizable error (the daemon's error * `message` is already private-text-free — it carries only identity/state, never task * bodies). On a thrown client failure (abort/disconnect), classify it into a recognizable * `SubagentClientResult` (never throws out of a tool) — the message always names the * command + that accepted jobs are not cancelled (abort) / recovery (disconnect). * * The socket stays open for the WHOLE duration of `send` (the daemon holds the response * open for `subagent.wait` until the wait condition is met or its own timeout sends it); * `client.close()` in the `finally` tears it down once `send` resolves/rejects. There is * no `keepOpen` flag: a tool never owns job execution or child event capture, so a client * is closed as soon as its one request/response round-trip finishes. */ export async function runDaemonCommand( sctx: SubagentToolContext, cmd: DaemonRequest, opts?: { signal?: AbortSignal; timeoutMs?: number }, ): Promise { const client = await sctx.connect(); try { return await client.send({ ...cmd, id: sctx.generateId() } as SubagentCommand, { ...(opts?.signal !== undefined ? { signal: opts.signal } : undefined), ...(opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : undefined), }); } catch (err) { // Abort/disconnect/etc. — classify into a recognizable structured error so the tool // returns an `isError` result rather than throwing (the turn loop's generic catch // would lose the "accepted jobs are not cancelled" / "recover via subagent_status" // guidance). `classifyClientFailure` names the command in every message. const { code, message } = classifyClientFailure(err, cmd.type); return { ok: false, command: cmd.type, error: { code, message } }; } finally { client.close(); } }