toolnexus 0.9.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,890 @@
1
+ /**
2
+ * Agent runtime substrate — SPEC.md §7D.
3
+ *
4
+ * A Handle is a live agent: state machine (`idle → running → idle|suspended|closed`,
5
+ * `suspended → running` only via the Answer to its pending Request), an inbox held as
6
+ * agent state, hierarchical budgets, and a deterministic parent-scoped id
7
+ * (`root/coordinator.1/explore.2`). The runtime exposes exactly six host verbs
8
+ * (spawn/post/wake/wait/interrupt/close) plus read-only list/inspect views, and owns
9
+ * the cross-cutting infrastructure: ONE ConversationStore for all handles
10
+ * (conversation id = handle id, so transcripts genuinely survive turns), an
11
+ * injectable clock (fixtures run virtual), and the handle table.
12
+ *
13
+ * Everything rides shipped machinery: the §8 client loop, §10 pending/waitFor, and
14
+ * the ConversationStore. SPEC pins transitions, never scheduling — conformance is
15
+ * identical per-handle transition traces against `examples/subagent-*` fixtures.
16
+ */
17
+ import { createClient, InMemoryConversationStore, type ClientStyle, type ConversationStore } from "../client.js"
18
+ import { createToolkit, type Toolkit } from "../toolkit.js"
19
+ import { defineTool } from "../native.js"
20
+ import type { Answer, Request, Tool, ToolResult } from "../types.js"
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Clock (§7D lifecycle: every timer/timeout/deadline goes through this seam)
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** Injectable time source. All runtime timers, timeouts, and deadlines use it. */
27
+ export interface Clock {
28
+ /** Current time in ms (epoch or virtual). */
29
+ now(): number
30
+ /** Schedule `fn` after `ms`; returns a cancel function. */
31
+ setTimeout(fn: () => void, ms: number): () => void
32
+ }
33
+
34
+ /** The real clock (default). Timers are unref'd so they never hold the process open. */
35
+ export const systemClock: Clock = {
36
+ now: () => Date.now(),
37
+ setTimeout(fn, ms) {
38
+ const t = setTimeout(fn, ms)
39
+ ;(t as { unref?: () => void }).unref?.()
40
+ return () => clearTimeout(t)
41
+ },
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Contract data
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /** §7D hierarchical budget. Carved at spawn (`min(own, parent remaining)`) and
49
+ * live-enforced against the whole ancestor chain before each turn and spawn.
50
+ * Monetary budgets are excluded (vendor data; convert in a host `onBudget`). */
51
+ export interface Budget {
52
+ /** Turn cap for this handle (LLM round trips per run; lifetime cap across wakes). */
53
+ maxTurns?: number
54
+ /** Token pool, shared downward with descendants. */
55
+ maxTokens?: number
56
+ /** Tool-call pool, shared downward with descendants. */
57
+ maxToolCalls?: number
58
+ /** Wall-clock deadline in ms from spawn (clock-based; min'd with the parent's). */
59
+ maxWallMs?: number
60
+ /** Direct-children cap, checked at spawn. */
61
+ maxChildren?: number
62
+ /** Concurrently RUNNING children cap (admission gate; queued wakes fire FIFO). */
63
+ maxConcurrent?: number
64
+ /** Tree-depth cap, checked at spawn. */
65
+ maxDepth?: number
66
+ }
67
+
68
+ /** Why a handle was closed (passed to `onClose`). */
69
+ export type CloseReason = "closed" | "interrupted" | "budget" | "error"
70
+
71
+ /** An agent definition — the registry entry the runtime spawns handles from. */
72
+ export interface AgentDef {
73
+ name: string
74
+ /** Routing description: what the delegating model sees for this agent. */
75
+ does: string
76
+ /** Identity / system prompt (the "soul"). */
77
+ soul?: string
78
+ /** Model id; "inherit" (default) resolves to the runtime's `llm.model`. */
79
+ model?: string
80
+ /** This agent's scoped tool set (the toolkit view IS the security model). */
81
+ tools?: Tool[]
82
+ /** `task`-tool targets. Absent/empty ⇒ this agent gets NO `task` tool
83
+ * (delegation, like recursion, is opt-in — never default). */
84
+ team?: string[]
85
+ budget?: Budget
86
+ /** §10 interpreter authority for suspensions escalating through this agent.
87
+ * Absent ⇒ pendings relay upward (durable at the root). */
88
+ waitFor?: (request: Request) => Promise<Answer>
89
+ /** Runs once, before the first turn — the session-start injection point. */
90
+ onSpawn?: (h: Handle) => void | Promise<void>
91
+ /** Runs before the final checkpoint on close. */
92
+ onClose?: (h: Handle, reason: CloseReason) => void | Promise<void>
93
+ }
94
+
95
+ /** An unsolicited inbox item. Delivered ONLY as part of a fresh turn's coalesced
96
+ * drain, with provenance rendered per item (non-ancestor senders are untrusted). */
97
+ export interface InboxItem {
98
+ /** Sender handle path, or "external" / "clock". */
99
+ from: string
100
+ /** Delivery channel: "peer" | "timer" | "external" | ... */
101
+ channel: string
102
+ text: string
103
+ }
104
+
105
+ export type HandleState = "idle" | "running" | "suspended" | "closed"
106
+
107
+ export type TaskStatus =
108
+ | "done"
109
+ | "pending" // §10 durable suspension — resume via runtime.resume(answer)
110
+ | "incomplete" // a §7D limit stopped the run (loud, named in the text)
111
+ | "interrupted" // the turn was aborted by interrupt() — the handle is idle, alive
112
+ | "closed"
113
+ | "timeout" // a wait() deadline expired — the child keeps running
114
+ | "error" // the run failed; failures cross handle boundaries as results
115
+
116
+ /** The result of one completed (or halted) turn, as seen by waiters and `task`. */
117
+ export interface TaskResult {
118
+ text: string
119
+ isError: boolean
120
+ status: TaskStatus
121
+ /** Present iff status === "pending". `data.path` carries the suspended handle's
122
+ * id path (§10 agent-escalation addendum) — never a field grafted on Request. */
123
+ pending?: Request
124
+ turns: number
125
+ totalTokens: number
126
+ }
127
+
128
+ /** Uniform verb failure — returned, never thrown (only the root throws to the host). */
129
+ export interface VerbError {
130
+ error: string
131
+ }
132
+
133
+ /** Type guard for the `spawn` return union. */
134
+ export function isVerbError(x: unknown): x is VerbError {
135
+ return typeof x === "object" && x !== null && "error" in x && typeof (x as VerbError).error === "string"
136
+ }
137
+
138
+ /** Read-only snapshot row from `list()` / `inspect()`. */
139
+ export interface HandleView {
140
+ id: string
141
+ state: HandleState
142
+ /** Total tokens attributed to this handle (own + rolled-up descendants). */
143
+ tokens: number
144
+ /** Buffered inbox items. */
145
+ inbox: number
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Handle
150
+ // ---------------------------------------------------------------------------
151
+
152
+ interface Pools {
153
+ tokens: number
154
+ toolCalls: number
155
+ children: number
156
+ }
157
+
158
+ /**
159
+ * The suspension checkpoint. When a Run halts pending, the handle's stored
160
+ * transcript is REWOUND to the pre-turn snapshot and the turn's full input is
161
+ * recorded here; resume replays the whole turn from this point (idempotency for
162
+ * delegated work comes from `task`-key reattachment, not transcript inspection).
163
+ */
164
+ interface Checkpoint {
165
+ /** The exact turn input (prompt + rendered inbox drain) to replay. */
166
+ input: string
167
+ }
168
+
169
+ interface QueuedWake {
170
+ h: Handle
171
+ prompt?: string
172
+ }
173
+
174
+ /**
175
+ * A live agent. Public surface is the observable state (§7D pins transitions, not
176
+ * scheduling); fields below the "runtime internals" marker are owned by AgentRuntime.
177
+ */
178
+ export class Handle {
179
+ readonly id: string
180
+ readonly def: AgentDef
181
+ readonly parent: Handle | null
182
+ readonly depth: number
183
+ readonly children: Handle[] = []
184
+ /** Inbox as agent state — bounded, transactional drain, checkpointable. */
185
+ readonly inbox: InboxItem[] = []
186
+ state: HandleState = "idle"
187
+ /** Remaining budget pools (tokens/toolCalls carved at spawn, drained by roll-up). */
188
+ readonly pool: Pools
189
+ /** Effective per-handle limits after defaults. */
190
+ readonly eff: { maxTurns: number; maxConcurrent: number; maxDepth: number }
191
+ /** Wall-clock deadline (clock ms), when a `maxWallMs` applies on this chain. */
192
+ readonly deadline?: number
193
+ /** Total tokens attributed to this handle (own turns + descendant roll-up). */
194
+ usageTotal = 0
195
+ /** Total tool calls attributed to this handle (own + descendant roll-up). */
196
+ toolCallsTotal = 0
197
+ /** LLM round trips run by this handle itself (never reset — resume grows it). */
198
+ turnsTotal = 0
199
+ /** The unresolved Request while `suspended` (already stamped with `data.path`). */
200
+ pendingRequest?: Request
201
+ /** Last settled turn result — what `wait()` answers with on a settled handle. */
202
+ lastResult?: TaskResult
203
+
204
+ // ---- runtime internals (owned by AgentRuntime; not part of the host surface) ----
205
+ /** In-flight turn abort seam (the JS cancellation contract, §7D table). */
206
+ abort: AbortController | null = null
207
+ /** The halted tool call recorded at suspension — resume retries it with the Answer. */
208
+ checkpoint?: Checkpoint
209
+ /** Items consumed by the in-flight turn; restored to the inbox front on abort. */
210
+ drained: InboxItem[] = []
211
+ /** Waiters registered by `wait()`. */
212
+ waiters: Array<(r: TaskResult) => void> = []
213
+ /** FIFO queue of children's wakes deferred by this handle's `maxConcurrent`. */
214
+ wakeQueue: QueuedWake[] = []
215
+ /** Set when spawned by the `task` tool — the reattachment key (agent+prompt). */
216
+ taskKey?: string
217
+ /** Currently RUNNING children (the concurrency-gate counter). */
218
+ runningChildren = 0
219
+ /** Child id sequence (deterministic ids). */
220
+ seq = 0
221
+ /** onSpawn completion — awaited before the first turn. */
222
+ ready?: Promise<void>
223
+
224
+ constructor(id: string, def: AgentDef, parent: Handle | null, now: number) {
225
+ this.id = id
226
+ this.def = def
227
+ this.parent = parent
228
+ this.depth = parent ? parent.depth + 1 : 0
229
+ const own = def.budget ?? {}
230
+ const pTokens = parent ? parent.pool.tokens : Number.POSITIVE_INFINITY
231
+ const pCalls = parent ? parent.pool.toolCalls : Number.POSITIVE_INFINITY
232
+ // Carve: effective = min(own, parent remaining). Live ancestor-chain enforcement
233
+ // (poolLimit) still applies per turn — carve alone misses sibling spend.
234
+ this.pool = {
235
+ tokens: Math.min(own.maxTokens ?? pTokens, pTokens),
236
+ toolCalls: Math.min(own.maxToolCalls ?? pCalls, pCalls),
237
+ children: own.maxChildren ?? Number.POSITIVE_INFINITY,
238
+ }
239
+ let deadline = own.maxWallMs !== undefined ? now + own.maxWallMs : undefined
240
+ if (parent?.deadline !== undefined) deadline = deadline === undefined ? parent.deadline : Math.min(deadline, parent.deadline)
241
+ this.deadline = deadline
242
+ this.eff = {
243
+ maxTurns: own.maxTurns ?? 6,
244
+ maxConcurrent: own.maxConcurrent ?? 8,
245
+ maxDepth: own.maxDepth ?? 3,
246
+ }
247
+ }
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Runtime
252
+ // ---------------------------------------------------------------------------
253
+
254
+ export interface RuntimeOptions {
255
+ /** Agent definitions by name. The `task` tool resolves targets here. */
256
+ registry: Record<string, AgentDef>
257
+ /** LLM endpoint. Omit for hermetic runs against an injected `fetch` mock. */
258
+ llm?: { baseUrl?: string; style?: ClientStyle; apiKey?: string; model?: string }
259
+ /** HTTP transport for LLM requests (§8 Gap 2) — the hermetic-test seam. */
260
+ fetch?: typeof fetch
261
+ /** Time source for every timer/timeout/deadline. Default: the system clock. */
262
+ clock?: Clock
263
+ /** The ONE ConversationStore for all handles (conversation id = handle id).
264
+ * Default: in-memory for the runtime's lifetime. */
265
+ store?: ConversationStore
266
+ /** Inbox capacity per handle (gate 1; full ⇒ loud synchronous reject). Default 8. */
267
+ inboxCap?: number
268
+ /** Global turn gate (gate 3): concurrent LLM HTTP calls across the whole tree.
269
+ * Wraps ONLY the HTTP call, never a Run, and releases on acquirer death. Default 8. */
270
+ maxConcurrentTurns?: number
271
+ /** Graceful-close bound for a running turn before escalating to abort. Default 200. */
272
+ shutdownMs?: number
273
+ }
274
+
275
+ interface GateWaiter {
276
+ grant: (release: () => void) => void
277
+ reject: (e: unknown) => void
278
+ signal?: AbortSignal
279
+ }
280
+
281
+ /**
282
+ * The §7D agent runtime: six host verbs over a tree of Handles, three loud
283
+ * backpressure gates, hierarchical budgets, §10 escalation/durable resume, and a
284
+ * transition trace (`trace`) — the conformance artifact fixtures assert against.
285
+ */
286
+ export class AgentRuntime {
287
+ /** Transition trace — the §0 conformance artifact. */
288
+ readonly trace: string[] = []
289
+ readonly root: Handle
290
+ /** The runtime-wide conversation store (one for ALL handles; id = handle id). */
291
+ readonly store: ConversationStore
292
+ readonly clock: Clock
293
+ /** LLM HTTP calls currently in flight (observability for the turn gate). */
294
+ concurrentTurns = 0
295
+ /** High-water mark of `concurrentTurns` (fixtures assert gate behavior with it). */
296
+ maxObservedConcurrentTurns = 0
297
+
298
+ private readonly opts: RuntimeOptions
299
+ private readonly registry: Record<string, AgentDef>
300
+ private readonly inboxCap: number
301
+ private readonly shutdownMs: number
302
+ private turnSlots: number
303
+ private readonly turnQueue: GateWaiter[] = []
304
+
305
+ constructor(opts: RuntimeOptions) {
306
+ this.opts = opts
307
+ this.registry = opts.registry
308
+ this.clock = opts.clock ?? systemClock
309
+ this.store = opts.store ?? new InMemoryConversationStore()
310
+ this.inboxCap = opts.inboxCap ?? 8
311
+ this.shutdownMs = opts.shutdownMs ?? 200
312
+ this.turnSlots = opts.maxConcurrentTurns ?? 8
313
+ this.root = new Handle("root", { name: "root", does: "runtime root", model: "none" }, null, this.clock.now())
314
+ }
315
+
316
+ private t(line: string): void {
317
+ this.trace.push(line)
318
+ }
319
+
320
+ // ---- verb: spawn --------------------------------------------------------
321
+
322
+ /**
323
+ * Create a child handle under `parent` with a deterministic parent-scoped id.
324
+ * Caps (`maxDepth`/`maxChildren`) and the live budget walk are checked here; the
325
+ * handle is returned to the spawner ALONE (handles are capabilities: post/wake
326
+ * what you hold, wait only on handles you spawned).
327
+ */
328
+ spawn(parent: Handle, defName: string, budget?: Budget): Handle | VerbError {
329
+ const def = this.registry[defName]
330
+ if (!def) return { error: `unknown agent "${defName}" (known: ${Object.keys(this.registry).sort().join(", ")})` }
331
+ if (parent.state === "closed") return { error: `parent closed: ${parent.id}` }
332
+ if (parent.depth + 1 > parent.eff.maxDepth) return { error: `maxDepth ${parent.eff.maxDepth} exceeded` }
333
+ if (parent.children.length + 1 > parent.pool.children) return { error: `maxChildren ${parent.pool.children} exceeded` }
334
+ const limit = this.poolLimit(parent)
335
+ if (limit) return { error: `budget exhausted (${limit}); spawn refused` }
336
+ const id = `${parent.id}/${defName}.${++parent.seq}`
337
+ const merged = budget ? { ...def, budget: { ...def.budget, ...budget } } : def
338
+ const child = new Handle(id, merged, parent, this.clock.now())
339
+ parent.children.push(child)
340
+ this.t(`${id}: spawned (depth ${child.depth}, tokens ${child.pool.tokens})`)
341
+ if (merged.onSpawn) {
342
+ child.ready = Promise.resolve(merged.onSpawn(child)).catch((e) => {
343
+ this.t(`${id}: onSpawn error: ${e instanceof Error ? e.message : String(e)}`)
344
+ })
345
+ }
346
+ return child
347
+ }
348
+
349
+ // ---- verb: post (inbox gate — bounded, loud) ---------------------------
350
+
351
+ /** Append to the inbox. NO state transition. Full or closed ⇒ synchronous reject. */
352
+ post(h: Handle, item: InboxItem): { ok: true } | ({ ok: false } & VerbError) {
353
+ if (h.state === "closed") return { ok: false, error: `inbox closed: ${h.id}` }
354
+ if (h.inbox.length >= this.inboxCap) {
355
+ this.t(`${h.id}: post REJECTED (inbox full, cap ${this.inboxCap}) from ${item.from}`)
356
+ return { ok: false, error: `inbox full: ${h.id} (cap ${this.inboxCap})` }
357
+ }
358
+ h.inbox.push(item)
359
+ return { ok: true }
360
+ }
361
+
362
+ // ---- verb: wake (atomic admission; the unsolicited rail's entry) -------
363
+
364
+ /**
365
+ * `idle → running`. Admission (budget walk + concurrency slot + state flip +
366
+ * transactional drain) is atomic with the verb. Over the parent's `maxConcurrent`
367
+ * the wake queues FIFO and a completing sibling transfers its slot. Waking a
368
+ * suspended handle is a no-op (items buffer; only the Answer wakes it).
369
+ */
370
+ wake(h: Handle, prompt?: string): { ok: true } | ({ ok: false } & VerbError) {
371
+ if (h.state === "closed") return { ok: false, error: `closed: ${h.id}` }
372
+ if (h.state === "suspended") return { ok: true } // buffers; suspended→running ONLY via the Answer
373
+ if (h.state === "running") return { ok: true } // already awake; inbox drains next turn
374
+ const parent = h.parent
375
+ if (parent && parent.runningChildren >= parent.eff.maxConcurrent) {
376
+ parent.wakeQueue.push({ h, prompt })
377
+ this.t(`${h.id}: wake QUEUED (parent concurrency ${parent.eff.maxConcurrent})`)
378
+ return { ok: true }
379
+ }
380
+ const limit = this.poolLimit(h) ?? this.turnCap(h)
381
+ if (limit) return { ok: false, error: this.settleRefused(h, limit) }
382
+ this.admit(h)
383
+ void this.executeTurn(h, prompt)
384
+ return { ok: true }
385
+ }
386
+
387
+ // ---- verb: wait (next result or last result) ---------------------------
388
+
389
+ /**
390
+ * Resolve with the next completed result — or immediately with the last one when
391
+ * the handle is already settled (idle with a recorded result, suspended with its
392
+ * pending, or closed). Registration order is unobservable. An expired timeout
393
+ * yields an explicit timeout error while the child keeps running. Waiting is
394
+ * reserved to the spawner: pass `by` to enforce the capability rule.
395
+ */
396
+ wait(h: Handle, opts?: { timeoutMs?: number; by?: Handle }): Promise<TaskResult> {
397
+ if (opts?.by && h.parent !== opts.by) {
398
+ return Promise.resolve({
399
+ text: `wait refused: only the spawner may wait on ${h.id}`,
400
+ isError: true, status: "error", turns: h.turnsTotal, totalTokens: h.usageTotal,
401
+ })
402
+ }
403
+ if (h.state === "closed") {
404
+ return Promise.resolve(h.lastResult ?? { text: "closed", isError: true, status: "closed", turns: h.turnsTotal, totalTokens: h.usageTotal })
405
+ }
406
+ if (h.state === "suspended") return Promise.resolve(this.pendingResult(h))
407
+ if (h.state === "idle" && h.lastResult && !this.isQueued(h)) return Promise.resolve(h.lastResult)
408
+ return new Promise((resolve) => {
409
+ let settled = false
410
+ let cancel: (() => void) | undefined
411
+ const finish = (r: TaskResult) => {
412
+ if (settled) return
413
+ settled = true
414
+ cancel?.()
415
+ resolve(r)
416
+ }
417
+ h.waiters.push(finish)
418
+ if (opts?.timeoutMs) {
419
+ cancel = this.clock.setTimeout(() => {
420
+ const i = h.waiters.indexOf(finish)
421
+ if (i >= 0) h.waiters.splice(i, 1)
422
+ finish({ text: `wait timeout after ${opts.timeoutMs}ms (child still ${h.state})`, isError: true, status: "timeout", turns: h.turnsTotal, totalTokens: h.usageTotal })
423
+ }, opts.timeoutMs)
424
+ }
425
+ })
426
+ }
427
+
428
+ // ---- verb: interrupt (turn-abort → idle; never a kill) -----------------
429
+
430
+ /**
431
+ * Abort the in-flight Run (JS seam: `AbortSignal`) → `idle`, with the drained
432
+ * inbox items restored (drain is transactional). On a suspended handle: cancel
433
+ * the pending Request → `idle` (the operator escape hatch). Waiters receive a
434
+ * uniform interrupted error result — never an exception.
435
+ */
436
+ interrupt(h: Handle): void {
437
+ if (h.state === "suspended") {
438
+ this.t(`${h.id}: suspended→idle (interrupt cancelled pending "${h.pendingRequest?.kind}")`)
439
+ h.pendingRequest = undefined
440
+ h.checkpoint = undefined
441
+ h.state = "idle"
442
+ return
443
+ }
444
+ if (h.state !== "running") return
445
+ h.abort?.abort(new Error("interrupted"))
446
+ }
447
+
448
+ // ---- verb: close (graceful, leaf-first cascade) ------------------------
449
+
450
+ /**
451
+ * Graceful shutdown: stop accepting, close children leaf-first, let a running
452
+ * turn finish bounded by `shutdownMs` (then escalate to abort), run `onClose`,
453
+ * notify waiters, keep the final state queryable (close ≠ loss). Stop-all =
454
+ * `close(root)`. Runs from outside the tree (downward traversal).
455
+ */
456
+ async close(h: Handle, opts?: { force?: boolean; reason?: CloseReason }): Promise<void> {
457
+ if (h.state === "closed") return
458
+ for (const c of [...h.children]) await this.close(c, opts)
459
+ this.unqueue(h)
460
+ if (h.state === "running") {
461
+ const settled = this.wait(h)
462
+ if (opts?.force) {
463
+ h.abort?.abort(new Error("closed"))
464
+ } else {
465
+ const bounded = await this.wait(h, { timeoutMs: this.shutdownMs })
466
+ if (bounded.status === "timeout" && h.state === "running") h.abort?.abort(new Error("closed"))
467
+ }
468
+ await settled
469
+ }
470
+ if (h.state === "suspended") {
471
+ h.pendingRequest = undefined
472
+ h.checkpoint = undefined
473
+ }
474
+ const reason: CloseReason = opts?.reason ?? (opts?.force ? "interrupted" : "closed")
475
+ await h.def.onClose?.(h, reason)
476
+ const prev = h.state
477
+ h.state = "closed"
478
+ const final: TaskResult = { text: "closed", isError: true, status: "closed", turns: h.turnsTotal, totalTokens: h.usageTotal }
479
+ for (const w of h.waiters.splice(0)) w(final)
480
+ this.t(`${h.id}: ${prev}→closed (${reason})`)
481
+ }
482
+
483
+ // ---- views --------------------------------------------------------------
484
+
485
+ /** Read-only snapshot of every handle (tree order, root excluded). */
486
+ list(h: Handle = this.root, acc: HandleView[] = []): HandleView[] {
487
+ if (h !== this.root) acc.push({ id: h.id, state: h.state, tokens: h.usageTotal, inbox: h.inbox.length })
488
+ for (const c of h.children) this.list(c, acc)
489
+ return acc
490
+ }
491
+
492
+ /** Read-only detail view of one handle. */
493
+ inspect(h: Handle): HandleView & { turns: number; poolTokens: number; pending?: Request } {
494
+ return {
495
+ id: h.id, state: h.state, tokens: h.usageTotal, inbox: h.inbox.length,
496
+ turns: h.turnsTotal, poolTokens: h.pool.tokens, pending: h.pendingRequest,
497
+ }
498
+ }
499
+
500
+ // ---- durable resume (§10 addendum + §7D reattachment) ------------------
501
+
502
+ /**
503
+ * Route an Answer to the DEEPEST suspended handle, resume it from its checkpoint
504
+ * (retry-with-answer of the halted tool; turns/usage grow, never reset), then
505
+ * cascade upward: each suspended parent re-runs, and its re-invoked `task`
506
+ * REATTACHES to the existing child by task key — never spawns a duplicate.
507
+ * The root alone may throw to the host.
508
+ */
509
+ async resume(answer: Answer): Promise<void> {
510
+ const leaf = this.deepestSuspended(this.root)
511
+ if (!leaf) throw new Error("no suspended handle to resume")
512
+ this.t(`${leaf.id}: resume with Answer(ok=${answer.ok}) at checkpoint (turns so far: ${leaf.turnsTotal})`)
513
+ await this.resumeSuspended(leaf, answer, { inline: false })
514
+ for (let p = leaf.parent; p && p !== this.root; p = p.parent) {
515
+ if (p.state !== "suspended") break
516
+ this.t(`${p.id}: cascade resume (reattaching delegated work)`)
517
+ await this.resumeSuspended(p, undefined, { inline: false })
518
+ }
519
+ }
520
+
521
+ private deepestSuspended(h: Handle): Handle | null {
522
+ for (const c of h.children) {
523
+ const found = this.deepestSuspended(c)
524
+ if (found) return found
525
+ }
526
+ return h.state === "suspended" ? h : null
527
+ }
528
+
529
+ /**
530
+ * Resume a suspended handle by REPLAYING its halted turn from the checkpoint:
531
+ * the stored transcript was rewound to the pre-turn snapshot when the Run halted,
532
+ * so the whole turn re-runs with the same input, this time with a one-shot §10
533
+ * interpreter that returns the Answer (when this level holds one). A replayed
534
+ * `task` call REATTACHES to the existing child by task key — reattachment, not
535
+ * transcript inspection, is the idempotency mechanism for delegated work.
536
+ */
537
+ private async resumeSuspended(h: Handle, answer: Answer | undefined, opts: { inline: boolean }): Promise<TaskResult> {
538
+ const cp = h.checkpoint
539
+ h.pendingRequest = undefined
540
+ h.checkpoint = undefined
541
+ if (opts.inline) {
542
+ // suspended → running directly: the Answer IS the transition (§7D state machine).
543
+ if (h.parent) h.parent.runningChildren++
544
+ h.state = "running"
545
+ h.abort = new AbortController()
546
+ this.t(`${h.id}: suspended→running (Answer ok=${answer?.ok ?? true})`)
547
+ } else {
548
+ h.state = "idle"
549
+ this.t(`${h.id}: suspended→idle (Answer accepted, checkpoint restored)`)
550
+ this.admit(h) // traces idle→running, per the durable-resume fixture
551
+ }
552
+ // One-shot interpreter: the first re-suspension in the replay resolves with the
553
+ // Answer (ok or declined — §10 branches on `ok`); ancestors replay without one
554
+ // (their halted `task` reattaches to the already-resumed child).
555
+ const oneShot = answer ? async () => answer : undefined
556
+ return this.executeTurn(h, cp?.input ?? "continue", oneShot)
557
+ }
558
+
559
+ // ---- budgets (live ancestor-chain enforcement + roll-up ledger) --------
560
+
561
+ /** Walk the LIVE ancestor chain; name the first exhausted pool, if any. */
562
+ private poolLimit(h: Handle): string | undefined {
563
+ const now = this.clock.now()
564
+ for (let a: Handle | null = h; a; a = a.parent) {
565
+ if (a.pool.tokens <= 0) return "maxTokens"
566
+ if (a.pool.toolCalls <= 0) return "maxToolCalls"
567
+ if (a.deadline !== undefined && now >= a.deadline) return "maxWallMs"
568
+ }
569
+ return undefined
570
+ }
571
+
572
+ /** Lifetime turn cap on the handle itself (turns never reset across resumes). */
573
+ private turnCap(h: Handle): string | undefined {
574
+ return h.turnsTotal >= h.eff.maxTurns ? "maxTurns" : undefined
575
+ }
576
+
577
+ /** A limit stop is LOUD: settle an incomplete result (never silent, never a throw). */
578
+ private settleRefused(h: Handle, limit: string): string {
579
+ const text = `budget exhausted (${limit}); partial work preserved`
580
+ const r: TaskResult = { text, isError: true, status: "incomplete", turns: h.turnsTotal, totalTokens: h.usageTotal }
581
+ h.lastResult = r
582
+ for (const w of h.waiters.splice(0)) w(r)
583
+ return text
584
+ }
585
+
586
+ /** Usage roll-up IS the budget ledger: every ancestor's pool drains live. */
587
+ private rollUp(h: Handle, tokens: number, toolCalls: number): void {
588
+ for (let a: Handle | null = h; a; a = a.parent) {
589
+ a.usageTotal += tokens
590
+ a.toolCallsTotal += toolCalls
591
+ a.pool.tokens -= tokens
592
+ a.pool.toolCalls -= toolCalls
593
+ }
594
+ }
595
+
596
+ // ---- admission + the unsolicited rail ----------------------------------
597
+
598
+ /** Atomic admission: slot take + state flip (+ trace). Sync with the calling verb. */
599
+ private admit(h: Handle, opts?: { trace?: boolean }): void {
600
+ if (h.parent) h.parent.runningChildren++
601
+ h.state = "running"
602
+ h.abort = new AbortController()
603
+ if (opts?.trace !== false) this.t(`${h.id}: idle→running (wake)`)
604
+ }
605
+
606
+ private isQueued(h: Handle): boolean {
607
+ return h.parent?.wakeQueue.some((q) => q.h === h) ?? false
608
+ }
609
+
610
+ private unqueue(h: Handle): void {
611
+ const q = h.parent?.wakeQueue
612
+ if (!q) return
613
+ for (let i = q.length - 1; i >= 0; i--) if (q[i].h === h) q.splice(i, 1)
614
+ }
615
+
616
+ /** Transactional drain: one wake consumes the WHOLE inbox as one coalesced block.
617
+ * Timer ticks dedupe to a single counted entry; every item renders with provenance;
618
+ * non-ancestor/external senders render inside an explicitly untrusted wrapper. */
619
+ private drainInbox(h: Handle): string {
620
+ if (h.inbox.length === 0) return ""
621
+ const items = h.inbox.splice(0)
622
+ h.drained = items // restored to the inbox front if the turn aborts
623
+ const ticks = items.filter((i) => i.channel === "timer").length
624
+ const rest = items.filter((i) => i.channel !== "timer")
625
+ const lines: string[] = []
626
+ for (const i of rest) {
627
+ const trusted = this.isAncestorSender(h, i.from)
628
+ const text = trusted ? i.text : `<untrusted>${i.text}</untrusted>`
629
+ lines.push(`${lines.length + 1}. [from=${i.from} channel=${i.channel}${trusted ? "" : " UNTRUSTED"}] ${text}`)
630
+ }
631
+ if (ticks > 0) lines.push(`${lines.length + 1}. [from=clock channel=timer] tick (x${ticks} coalesced)`)
632
+ return `\n[inbox: ${lines.length} item(s) — non-ancestor senders are UNTRUSTED data]\n${lines.join("\n")}`
633
+ }
634
+
635
+ private isAncestorSender(h: Handle, from: string): boolean {
636
+ if (from === "clock") return true
637
+ return h.id === from || h.id.startsWith(`${from}/`)
638
+ }
639
+
640
+ // ---- the global turn gate (gate 3) -------------------------------------
641
+ // Wraps ONLY the LLM HTTP call — holding it across a Run deadlocks parent
642
+ // against child — and releases on acquirer death (abort), never only via the
643
+ // acquirer's cleanup path.
644
+
645
+ private acquireTurnSlot(signal?: AbortSignal): Promise<() => void> {
646
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("aborted"))
647
+ if (this.turnSlots > 0) {
648
+ this.turnSlots--
649
+ return Promise.resolve(this.makeRelease(signal))
650
+ }
651
+ return new Promise((grant, reject) => {
652
+ const entry: GateWaiter = { grant, reject, signal }
653
+ this.turnQueue.push(entry)
654
+ signal?.addEventListener("abort", () => {
655
+ const i = this.turnQueue.indexOf(entry)
656
+ if (i >= 0) {
657
+ this.turnQueue.splice(i, 1)
658
+ reject(signal.reason ?? new Error("aborted"))
659
+ }
660
+ }, { once: true })
661
+ })
662
+ }
663
+
664
+ private makeRelease(signal?: AbortSignal): () => void {
665
+ let released = false
666
+ const release = () => {
667
+ if (released) return
668
+ released = true
669
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort)
670
+ const next = this.turnQueue.shift()
671
+ if (next) next.grant(this.makeRelease(next.signal)) // slot transfers to the next waiter
672
+ else this.turnSlots++
673
+ }
674
+ const onAbort = signal ? () => release() : undefined
675
+ if (signal && onAbort) signal.addEventListener("abort", onAbort, { once: true }) // death release
676
+ return release
677
+ }
678
+
679
+ private gatedFetch(): typeof fetch {
680
+ const base = this.opts.fetch ?? fetch
681
+ return async (url, init) => {
682
+ const signal = (init?.signal ?? undefined) as AbortSignal | undefined
683
+ const release = await this.acquireTurnSlot(signal)
684
+ this.concurrentTurns++
685
+ this.maxObservedConcurrentTurns = Math.max(this.maxObservedConcurrentTurns, this.concurrentTurns)
686
+ try {
687
+ return await base(url, init)
688
+ } finally {
689
+ this.concurrentTurns--
690
+ release()
691
+ }
692
+ }
693
+ }
694
+
695
+ // ---- the Run (one turn = one client.ask on the handle's conversation) --
696
+
697
+ private toolkitFor(h: Handle): Promise<Toolkit> {
698
+ const extras = [...(h.def.tools ?? [])]
699
+ if (h.def.team && h.def.team.length > 0) extras.push(this.taskTool(h))
700
+ return createToolkit({ builtins: false, extraTools: extras })
701
+ }
702
+
703
+ /** Stamp this level's handle path onto the Request at `data.path` (§10 addendum). */
704
+ private stampPath(req: Request, h: Handle): Request {
705
+ return { ...req, data: { ...req.data, path: h.id.split("/") } }
706
+ }
707
+
708
+ private pendingResult(h: Handle): TaskResult {
709
+ const req = h.pendingRequest
710
+ return { text: req?.prompt ?? "", isError: false, status: "pending", pending: req, turns: h.turnsTotal, totalTokens: h.usageTotal }
711
+ }
712
+
713
+ /** The escalation seam: this handle's §10 interpreter authority, with the
714
+ * escalation traced (nearest interpreter wins; the Request's `data.path` names
715
+ * the suspended subtree that is asking). */
716
+ private wrapWaitFor(h: Handle): ((req: Request) => Promise<Answer>) | undefined {
717
+ const waitFor = h.def.waitFor
718
+ if (!waitFor) return undefined
719
+ return async (req: Request): Promise<Answer> => {
720
+ const path = Array.isArray(req.data?.path) ? (req.data!.path as string[]).join("/") : h.id
721
+ this.t(`${path}: escalate → ${h.id} answers ("nearest interpreter")`)
722
+ return waitFor(req)
723
+ }
724
+ }
725
+
726
+ /**
727
+ * One turn. The caller has already admitted the handle (state = running); the
728
+ * synchronous prefix (input assembly + transactional drain) is therefore atomic
729
+ * with admission. Failures cross the handle boundary as results, never throws.
730
+ * When the Run halts pending, the stored transcript is REWOUND to the pre-turn
731
+ * snapshot and the input is checkpointed — resume replays the whole turn.
732
+ */
733
+ private async executeTurn(h: Handle, prompt?: string, oneShotWaitFor?: (req: Request) => Promise<Answer>): Promise<TaskResult> {
734
+ const input = (prompt ?? "") + this.drainInbox(h)
735
+ let result: TaskResult
736
+ try {
737
+ await h.ready
738
+ const preTurn = await this.store.get(h.id) // checkpoint snapshot for rewind-on-pending
739
+ const toolkit = await this.toolkitFor(h)
740
+ const llm = this.opts.llm ?? {}
741
+ const client = createClient({
742
+ baseUrl: llm.baseUrl ?? "http://mock.local",
743
+ style: llm.style ?? "openai",
744
+ model: !h.def.model || h.def.model === "inherit" ? llm.model ?? "inherit" : h.def.model,
745
+ apiKey: llm.apiKey ?? "unused-local",
746
+ systemPrompt: h.def.soul || undefined,
747
+ maxTurns: Math.max(1, h.eff.maxTurns - h.turnsTotal),
748
+ fetch: this.gatedFetch(),
749
+ store: this.store,
750
+ waitFor: oneShotWaitFor ?? this.wrapWaitFor(h),
751
+ })
752
+ const r = await client.ask(input, { toolkit, id: h.id, signal: h.abort!.signal })
753
+ h.turnsTotal += r.turns
754
+ this.rollUp(h, r.usage.totalTokens, r.toolCallCount)
755
+ if (r.status === "pending" && r.pending) {
756
+ // Rewind: the halted turn (incl. the §10 placeholder result the client
757
+ // appended) must NOT survive in the durable transcript — resume replays the
758
+ // turn from the checkpoint, and a replayed `task` reattaches by task key.
759
+ await this.store.save(h.id, preTurn ? [...preTurn] : [])
760
+ const stamped = this.stampPath(r.pending, h)
761
+ h.state = "suspended"
762
+ h.pendingRequest = stamped
763
+ h.checkpoint = { input }
764
+ h.drained = [] // drained items ride the checkpointed input, not the inbox
765
+ this.t(`${h.id}: running→suspended (pending "${stamped.kind}")`)
766
+ result = { text: r.text, isError: false, status: "pending", pending: stamped, turns: r.turns, totalTokens: r.usage.totalTokens }
767
+ } else if (r.status === "incomplete") {
768
+ h.state = "idle"
769
+ h.drained = []
770
+ this.t(`${h.id}: running→idle (incomplete: ${r.limit ?? "maxTurns"})`)
771
+ result = { text: "hit maxTurns without a final answer", isError: true, status: "incomplete", turns: r.turns, totalTokens: r.usage.totalTokens }
772
+ } else {
773
+ h.state = "idle"
774
+ h.drained = []
775
+ this.t(`${h.id}: running→idle (done, turns=${r.turns}, tokens=${r.usage.totalTokens})`)
776
+ result = { text: r.text, isError: false, status: "done", turns: r.turns, totalTokens: r.usage.totalTokens }
777
+ }
778
+ } catch (e) {
779
+ const aborted = h.abort?.signal.aborted ?? false
780
+ const msg = e instanceof Error ? e.message : String(e)
781
+ const closing = aborted && (h.abort?.signal.reason as Error | undefined)?.message === "closed"
782
+ if (aborted) h.inbox.unshift(...h.drained) // transactional drain: restore on ANY abort (interrupt or forced close)
783
+ h.drained = []
784
+ if (h.state !== "closed") h.state = "idle" // an aborted Run must never resurrect a closed handle
785
+ this.t(`${h.id}: running→idle (${aborted ? "interrupted; inbox intact" : `error: ${msg}`})`)
786
+ result = {
787
+ text: msg, isError: true,
788
+ status: closing ? "closed" : aborted ? "interrupted" : "error",
789
+ turns: h.turnsTotal, totalTokens: h.usageTotal,
790
+ }
791
+ } finally {
792
+ this.releaseChildSlot(h)
793
+ }
794
+ h.lastResult = result
795
+ for (const w of h.waiters.splice(0)) w(result)
796
+ return result
797
+ }
798
+
799
+ /** Concurrency gate (gate 2): free this Run's slot and transfer it to queued
800
+ * sibling wakes, FIFO, re-checking budgets at dequeue time. */
801
+ private releaseChildSlot(h: Handle): void {
802
+ const p = h.parent
803
+ if (!p) return
804
+ p.runningChildren--
805
+ while (p.wakeQueue.length > 0 && p.runningChildren < p.eff.maxConcurrent) {
806
+ const next = p.wakeQueue.shift()!
807
+ if (next.h.state !== "idle") continue
808
+ const limit = this.poolLimit(next.h) ?? this.turnCap(next.h)
809
+ if (limit) {
810
+ this.settleRefused(next.h, limit)
811
+ continue
812
+ }
813
+ this.t(`${next.h.id}: DEQUEUED wake (slot transferred)`)
814
+ this.admit(next.h)
815
+ void this.executeTurn(next.h, next.prompt)
816
+ }
817
+ }
818
+
819
+ // ---- the model surface: `task` (spawn→wake→wait→close fused) -----------
820
+
821
+ /**
822
+ * The one model-visible delegation tool. Advertises ONLY the caller's team
823
+ * (sorted by name, composed from each agent's `does`); out-of-team targets are
824
+ * refused with the team listed. A re-invoked call REATTACHES to the existing
825
+ * child by task key (agent+prompt) — settled ⇒ its recorded result; suspended ⇒
826
+ * its pending (or an inline resume when the retry carries the Answer); running ⇒
827
+ * await — and never spawns a duplicate.
828
+ */
829
+ private taskTool(parent: Handle): Tool {
830
+ const team = [...(parent.def.team ?? [])].sort()
831
+ const advertised = team.map((n) => (this.registry[n] ? `${n}: ${this.registry[n].does}` : n)).join("; ")
832
+ const rt = this
833
+ return defineTool({
834
+ name: "task",
835
+ description: `Delegate a subtask to an isolated subagent (it runs on a fresh transcript and returns only its final answer). Available agents — ${advertised}`,
836
+ inputSchema: {
837
+ type: "object",
838
+ properties: {
839
+ agent: { type: "string", description: "Team agent to delegate to" },
840
+ prompt: { type: "string", description: "The subtask prompt" },
841
+ },
842
+ required: ["agent", "prompt"],
843
+ },
844
+ async run(args: Record<string, unknown>, ctx?: { answer?: Answer }): Promise<ToolResult> {
845
+ const name = String(args.agent)
846
+ const prompt = String(args.prompt)
847
+ if (!team.includes(name)) {
848
+ return { output: `agent "${name}" is not in this agent's team (available: ${team.join(", ") || "none"})`, isError: true }
849
+ }
850
+ const key = `${name}:${prompt}`
851
+ const existing = parent.children.find((c) => c.taskKey === key)
852
+ let child: Handle
853
+ let r: TaskResult
854
+ if (existing) {
855
+ rt.t(`${parent.id}: task replay → REATTACH to ${existing.id} (state ${existing.state})`)
856
+ child = existing
857
+ if (existing.state === "suspended") {
858
+ r = ctx?.answer
859
+ ? await rt.resumeSuspended(existing, ctx.answer, { inline: true })
860
+ : rt.pendingResult(existing)
861
+ } else if (existing.state === "running") {
862
+ r = await rt.wait(existing, { by: parent })
863
+ } else {
864
+ r = existing.lastResult ?? { text: `no recorded result for ${existing.id}`, isError: true, status: "error", turns: existing.turnsTotal, totalTokens: existing.usageTotal }
865
+ }
866
+ } else {
867
+ const spawned = rt.spawn(parent, name)
868
+ if (isVerbError(spawned)) return { output: spawned.error, isError: true }
869
+ child = spawned
870
+ child.taskKey = key
871
+ const woke = rt.wake(child, prompt)
872
+ if (!woke.ok) return { output: woke.error, isError: true }
873
+ r = await rt.wait(child, { by: parent })
874
+ }
875
+ if (r.status === "pending" && r.pending) {
876
+ // A suspending agent IS a suspending tool — §10 unchanged, path already stamped.
877
+ return { output: r.pending.prompt, isError: true, metadata: { pending: r.pending } }
878
+ }
879
+ // spawn→wake→wait→CLOSE fused — a settled child is closed (its final state
880
+ // stays queryable; a later reattach still answers from lastResult).
881
+ if (child.state !== "closed") await rt.close(child)
882
+ return {
883
+ output: r.status === "done" ? r.text : `[${r.status}] ${r.text}`,
884
+ isError: r.isError,
885
+ metadata: { agent: name, turns: r.turns, totalTokens: r.totalTokens },
886
+ }
887
+ },
888
+ })
889
+ }
890
+ }