toolnexus 0.18.1 → 0.20.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.
@@ -234,9 +234,11 @@ export function startAgent(a: Agent, run: AgentRunOptions, hb: HeartbeatOptions)
234
234
  })
235
235
  }
236
236
  }
237
- cancel = runtime.clock.setTimeout(beat, hb.everyMs) // self-reschedule → one live timer
237
+ // keepAlive:false — a heartbeat is housekeeping and must never be the reason
238
+ // a host process stays up.
239
+ cancel = runtime.clock.setTimeout(beat, hb.everyMs, { keepAlive: false }) // self-reschedule → one live timer
238
240
  }
239
- cancel = runtime.clock.setTimeout(beat, hb.everyMs)
241
+ cancel = runtime.clock.setTimeout(beat, hb.everyMs, { keepAlive: false })
240
242
 
241
243
  return {
242
244
  runtime,
@@ -177,6 +177,33 @@ export async function runGated(
177
177
  }
178
178
  }
179
179
 
180
+ /**
181
+ * The spec fields a Loop drive CANNOT honour, named rather than dropped silently.
182
+ *
183
+ * A `Loop` is one client run: it has no runtime, so it has no handle tree to spawn a `team`
184
+ * into, no §10 suspension ledger for `waitFor`, and no per-agent metric sink routing. Those
185
+ * fields are honoured by `Agent.run` / `AgentRuntime` only. `uses.tools` is not honoured either
186
+ * — a Loop is handed its toolkit by the caller.
187
+ *
188
+ * Returns a FIXED CANONICAL vocabulary — `"tools"`, `"team"`, `"waitFor"`, `"onMetric"` —
189
+ * identical in all seven ports, exactly as the `limit` strings are, and NOT this language's own
190
+ * spelling of the field. Empty ⇒ nothing is lost.
191
+ * Additive and advisory: a Loop never refuses to run because of it (that would be breaking).
192
+ */
193
+ export function loopUnsupported(spec: {
194
+ uses?: { tools?: unknown[] }
195
+ team?: unknown[]
196
+ waitFor?: unknown
197
+ onMetric?: unknown
198
+ }): string[] {
199
+ const out: string[] = []
200
+ if (spec.uses?.tools && spec.uses.tools.length > 0) out.push("tools")
201
+ if (spec.team && spec.team.length > 0) out.push("team")
202
+ if (spec.waitFor) out.push("waitFor")
203
+ if (spec.onMetric) out.push("onMetric")
204
+ return out
205
+ }
206
+
180
207
  /** A live execution of an Agent. Its only verbs are `run` and reading state. */
181
208
  export class Loop {
182
209
  #status: Outcome["status"] | "idle" | "running" = "idle"
@@ -234,10 +261,21 @@ export class Loop {
234
261
  /** Applies a per-call model override via `requestParams` (`model` is not in the
235
262
  * forbidden set — the client forbids only messages/tools/stream). */
236
263
  #clientOptions(opts: LoopRunOptions): ClientOptions {
264
+ const spec = this.agent.spec
237
265
  const base: ClientOptions = {
238
266
  ...this.options,
239
- systemPrompt: this.agent.spec.soul ?? this.options.systemPrompt,
240
- hooks: guardedHooks(this.agent.spec.guardrails, this.agent.spec.hooks ?? this.options.hooks),
267
+ // CALLER-WINS (ADR 0024 / issue #87). A `systemPrompt` the caller put on the client
268
+ // options is an explicit instruction for THIS drive; the soul is the agent's default.
269
+ // js used to let the soul override it, which python and csharp never did.
270
+ systemPrompt: this.options.systemPrompt ?? spec.soul,
271
+ // The spec's model and turn ceiling become Loop DEFAULTS — the same spec drives both
272
+ // doors. `ClientOptions.model` is REQUIRED in js, so "the caller did not choose" is
273
+ // spelled the way the runtime already spells it: absent, or the `"inherit"` sentinel.
274
+ model: !this.options.model || this.options.model === "inherit"
275
+ ? (spec.model && spec.model !== "inherit" ? spec.model : this.options.model)
276
+ : this.options.model,
277
+ maxTurns: this.options.maxTurns ?? spec.budget?.maxTurns,
278
+ hooks: guardedHooks(spec.guardrails, spec.hooks ?? this.options.hooks),
241
279
  }
242
280
  if (!opts.model) return base
243
281
  return { ...base, requestParams: { ...(base.requestParams ?? {}), model: opts.model } }
@@ -15,7 +15,17 @@
15
15
  * identical per-handle transition traces against `examples/subagent-*` fixtures.
16
16
  */
17
17
  import type { PromptInput } from "../content.js"
18
- import { createClient, InMemoryConversationStore, type ClientStyle, type ConversationStore, type Hooks, type MetricEvent } from "../client.js"
18
+ import {
19
+ createClient,
20
+ createInProcessFetch,
21
+ InMemoryConversationStore,
22
+ type ClientStyle,
23
+ type ConversationStore,
24
+ type Hooks,
25
+ type InProcessRequest,
26
+ type InProcessResponse,
27
+ type MetricEvent,
28
+ } from "../client.js"
19
29
  import { runGated, type Completion } from "./loop.js"
20
30
  import { createToolkit, type Toolkit } from "../toolkit.js"
21
31
  import { defineTool } from "../native.js"
@@ -29,16 +39,23 @@ import type { Answer, Request, Tool, ToolResult } from "../types.js"
29
39
  export interface Clock {
30
40
  /** Current time in ms (epoch or virtual). */
31
41
  now(): number
32
- /** Schedule `fn` after `ms`; returns a cancel function. */
33
- setTimeout(fn: () => void, ms: number): () => void
42
+ /** Schedule `fn` after `ms`; returns a cancel function.
43
+ *
44
+ * `keepAlive` defaults to TRUE, and the default is the important half: a timer
45
+ * that cannot hold the event loop open never fires when it is the only thing
46
+ * left to do — which is precisely a deadline whose promise nothing else will
47
+ * settle. Background housekeeping that should never hold a process open (a
48
+ * heartbeat) passes `false` explicitly. A host's own Clock may ignore the
49
+ * option; it then behaves as that host's timers always have. */
50
+ setTimeout(fn: () => void, ms: number, opts?: { keepAlive?: boolean }): () => void
34
51
  }
35
52
 
36
- /** The real clock (default). Timers are unref'd so they never hold the process open. */
53
+ /** The real clock (default). See `Clock.setTimeout` for why `keepAlive` defaults to true. */
37
54
  export const systemClock: Clock = {
38
55
  now: () => Date.now(),
39
- setTimeout(fn, ms) {
56
+ setTimeout(fn, ms, opts) {
40
57
  const t = setTimeout(fn, ms)
41
- ;(t as { unref?: () => void }).unref?.()
58
+ if (opts?.keepAlive === false) (t as { unref?: () => void }).unref?.()
42
59
  return () => clearTimeout(t)
43
60
  },
44
61
  }
@@ -118,6 +135,63 @@ export interface InboxItem {
118
135
 
119
136
  export type HandleState = "idle" | "running" | "suspended" | "closed"
120
137
 
138
+ /**
139
+ * The §7D AGENT status vocabulary, as a value. It is a DIFFERENT, larger closed set than the
140
+ * §8 CLIENT vocabulary (`RUN_STATUSES` = done/pending/incomplete): only this one contains
141
+ * `"timeout"`, and `"timeout"` here means a `wait()` DEADLINE expired while the child keeps
142
+ * running — never that an LLM call timed out. Two fields are called `status`; they are not
143
+ * interchangeable (ADR 0027 D2.1).
144
+ */
145
+ export const TASK_STATUSES = [
146
+ "done", "pending", "incomplete", "interrupted", "closed", "timeout", "error",
147
+ ] as const
148
+
149
+ /**
150
+ * The CLOSED `limit` vocabulary (addendum A14). A value NAMES THE BUDGET FIELD that stopped the
151
+ * run, spelled exactly as `Budget` spells it, plus the two non-budget stops `completion` and
152
+ * `timeout`. Identical in all seven ports, like the `loopUnsupported` strings — "which limit
153
+ * stopped me" is only answerable if the answer is the same word everywhere.
154
+ *
155
+ * A port's INTERNAL pool/dimension name is an implementation detail and must never leak into
156
+ * the field; `canonicalLimit` is the boundary that enforces that here.
157
+ */
158
+ export const TASK_LIMITS = [
159
+ "maxTurns", "maxTokens", "maxToolCalls", "maxWallMs", "maxChildren", "maxConcurrent", "maxDepth",
160
+ "completion", "timeout",
161
+ ] as const
162
+
163
+ export type TaskLimit = (typeof TASK_LIMITS)[number]
164
+
165
+ /** Map anything the internals produce onto the closed vocabulary; unknown ⇒ undefined, never a
166
+ * leaked internal name. */
167
+ export function canonicalLimit(limit: string | undefined): TaskLimit | undefined {
168
+ return limit && (TASK_LIMITS as readonly string[]).includes(limit) ? (limit as TaskLimit) : undefined
169
+ }
170
+
171
+ /**
172
+ * NAMED CONSTANTS for the two closed vocabularies. Every `TaskResult` construction site in this
173
+ * file spells its status and limit through these, never as a string literal, so a rename cannot
174
+ * silently desync the two fields — which is exactly how `status:"timeout"` came to ship with an
175
+ * empty `limit` in three of the seven ports (addendum A18).
176
+ */
177
+ export const S: Readonly<Record<TaskStatus, TaskStatus>> = Object.freeze({
178
+ done: "done", pending: "pending", incomplete: "incomplete", interrupted: "interrupted",
179
+ closed: "closed", timeout: "timeout", error: "error",
180
+ })
181
+
182
+ export const L: Readonly<Record<TaskLimit, TaskLimit>> = Object.freeze({
183
+ maxTurns: "maxTurns", maxTokens: "maxTokens", maxToolCalls: "maxToolCalls", maxWallMs: "maxWallMs",
184
+ maxChildren: "maxChildren", maxConcurrent: "maxConcurrent", maxDepth: "maxDepth",
185
+ completion: "completion", timeout: "timeout",
186
+ })
187
+
188
+ /**
189
+ * THE INVARIANT `S`/`L` exist to protect (addendum A18): a LIMIT stop (`incomplete`, `timeout`)
190
+ * names its limit; every other status carries none. It is asserted by the suite, not exported —
191
+ * a predicate that exists so our own tests can state a rule is not public API (A19b, ADR 0019).
192
+ * Ports copy the PREDICATE, not an export.
193
+ */
194
+
121
195
  export type TaskStatus =
122
196
  | "done"
123
197
  | "pending" // §10 durable suspension — resume via runtime.resume(answer)
@@ -135,8 +209,23 @@ export interface TaskResult {
135
209
  /** Present iff status === "pending". `data.path` carries the suspended handle's
136
210
  * id path (§10 agent-escalation addendum) — never a field grafted on Request. */
137
211
  pending?: Request
212
+ /** Cumulative LLM round trips for this handle's SUBTREE — never per-run, never reset. */
138
213
  turns: number
214
+ /**
215
+ * Cumulative TREE total: this handle's own spend plus every descendant's, on EVERY status
216
+ * (ADR 0025 D1). It was already the tree total on four of the seven status branches; this
217
+ * gives the field one meaning. `parent.totalTokens >= child.totalTokens` always holds.
218
+ */
139
219
  totalTokens: number
220
+ /** This handle's OWN accumulated spend, excluding children. */
221
+ ownTokens: number
222
+ /**
223
+ * WHICH limit stopped the run, as a value to branch on rather than prose to match. Drawn from
224
+ * the CLOSED, cross-port `TASK_LIMITS` vocabulary (addendum A14) — it names the `Budget` field,
225
+ * or `completion` / `timeout`. Absent otherwise. A stop you cannot explain to a user is a bug,
226
+ * not a state.
227
+ */
228
+ limit?: TaskLimit
140
229
  }
141
230
 
142
231
  /** Uniform verb failure — returned, never thrown (only the root throws to the host). */
@@ -206,6 +295,8 @@ export class Handle {
206
295
  readonly deadline?: number
207
296
  /** Total tokens attributed to this handle (own turns + descendant roll-up). */
208
297
  usageTotal = 0
298
+ /** Tokens spent by THIS handle's own turns, excluding descendants (ADR 0025 D1). */
299
+ ownTokens = 0
209
300
  /** Total tool calls attributed to this handle (own + descendant roll-up). */
210
301
  toolCallsTotal = 0
211
302
  /** LLM round trips run by this handle itself (never reset — resume grows it). */
@@ -270,8 +361,16 @@ export interface RuntimeOptions {
270
361
  registry: Record<string, AgentDef>
271
362
  /** LLM endpoint. Omit for hermetic runs against an injected `fetch` mock. */
272
363
  llm?: { baseUrl?: string; style?: ClientStyle; apiKey?: string; model?: string }
273
- /** HTTP transport for LLM requests (§8 Gap 2) — the hermetic-test seam. */
364
+ /** HTTP transport for LLM requests (§8 Gap 2) — the hermetic-test seam.
365
+ * Mutually exclusive with `inProcess` — `new AgentRuntime(...)` throws if both are set. */
274
366
  fetch?: typeof fetch
367
+ /** A model running IN THIS PROCESS (ADR 0030): the semantic counterpart to
368
+ * `fetch`, so a host whose model is a plain function never has to build its own
369
+ * `fetch`-shaped adapter. Internally this is turned into a `createInProcessFetch`
370
+ * adapter and wrapped by the SAME global turn gate as `fetch` — there is no second
371
+ * code path. Mutually exclusive with `fetch` and with `llm` — `new AgentRuntime(...)`
372
+ * throws if more than one of `fetch`/`inProcess` is set. */
373
+ inProcess?: (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>
275
374
  /** Time source for every timer/timeout/deadline. Default: the system clock. */
276
375
  clock?: Clock
277
376
  /** The ONE ConversationStore for all handles (conversation id = handle id).
@@ -324,6 +423,20 @@ export class AgentRuntime {
324
423
  private readonly turnQueue: GateWaiter[] = []
325
424
 
326
425
  constructor(opts: RuntimeOptions) {
426
+ if (opts.fetch && opts.inProcess) {
427
+ throw new Error("toolnexus/agents: RuntimeOptions.fetch and RuntimeOptions.inProcess are mutually exclusive — set one, not both")
428
+ }
429
+ if (opts.llm && opts.inProcess) {
430
+ throw new Error(
431
+ "toolnexus/agents: RuntimeOptions.llm and RuntimeOptions.inProcess are mutually exclusive — an in-process model has no wire endpoint to point llm at",
432
+ )
433
+ }
434
+ if (opts.inProcess) {
435
+ // Reuse the SAME adapter createInProcessClient builds on (ADR 0030) — the
436
+ // runtime's in-process wiring is a CALLER of that export, not a second copy
437
+ // of the request/response assembly.
438
+ opts = { ...opts, fetch: createInProcessFetch(opts.inProcess) }
439
+ }
327
440
  this.opts = opts
328
441
  this.registry = opts.registry
329
442
  this.clock = opts.clock ?? systemClock
@@ -418,11 +531,11 @@ export class AgentRuntime {
418
531
  if (opts?.by && h.parent !== opts.by) {
419
532
  return Promise.resolve({
420
533
  text: `wait refused: only the spawner may wait on ${h.id}`,
421
- isError: true, status: "error", turns: h.turnsTotal, totalTokens: h.usageTotal,
534
+ isError: true, status: S.error, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens,
422
535
  })
423
536
  }
424
537
  if (h.state === "closed") {
425
- return Promise.resolve(h.lastResult ?? { text: "closed", isError: true, status: "closed", turns: h.turnsTotal, totalTokens: h.usageTotal })
538
+ return Promise.resolve(h.lastResult ?? { text: "closed", isError: true, status: S.closed, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens })
426
539
  }
427
540
  if (h.state === "suspended") return Promise.resolve(this.pendingResult(h))
428
541
  if (h.state === "idle" && h.lastResult && !this.isQueued(h)) return Promise.resolve(h.lastResult)
@@ -440,7 +553,7 @@ export class AgentRuntime {
440
553
  cancel = this.clock.setTimeout(() => {
441
554
  const i = h.waiters.indexOf(finish)
442
555
  if (i >= 0) h.waiters.splice(i, 1)
443
- finish({ text: `wait timeout after ${opts.timeoutMs}ms (child still ${h.state})`, isError: true, status: "timeout", turns: h.turnsTotal, totalTokens: h.usageTotal })
556
+ finish({ text: `wait timeout after ${opts.timeoutMs}ms (child still ${h.state})`, isError: true, status: S.timeout, limit: L.timeout, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens })
444
557
  }, opts.timeoutMs)
445
558
  }
446
559
  })
@@ -496,7 +609,7 @@ export class AgentRuntime {
496
609
  await h.def.onClose?.(h, reason)
497
610
  const prev = h.state
498
611
  h.state = "closed"
499
- const final: TaskResult = { text: "closed", isError: true, status: "closed", turns: h.turnsTotal, totalTokens: h.usageTotal }
612
+ const final: TaskResult = { text: "closed", isError: true, status: S.closed, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens }
500
613
  for (const w of h.waiters.splice(0)) w(final)
501
614
  this.t(`${h.id}: ${prev}→closed (${reason})`)
502
615
  }
@@ -527,16 +640,19 @@ export class AgentRuntime {
527
640
  * REATTACHES to the existing child by task key — never spawns a duplicate.
528
641
  * The root alone may throw to the host.
529
642
  */
530
- async resume(answer: Answer): Promise<void> {
643
+ async resume(answer: Answer): Promise<TaskResult> {
531
644
  const leaf = this.deepestSuspended(this.root)
532
645
  if (!leaf) throw new Error("no suspended handle to resume")
533
646
  this.t(`${leaf.id}: resume with Answer(ok=${answer.ok}) at checkpoint (turns so far: ${leaf.turnsTotal})`)
534
- await this.resumeSuspended(leaf, answer, { inline: false })
647
+ // The value is the settled result of the TOPMOST handle the cascade re-ran — what the host
648
+ // would have got from `Agent.run` had the suspension never happened (ADR 0025 D3).
649
+ let topmost = await this.resumeSuspended(leaf, answer, { inline: false })
535
650
  for (let p = leaf.parent; p && p !== this.root; p = p.parent) {
536
651
  if (p.state !== "suspended") break
537
652
  this.t(`${p.id}: cascade resume (reattaching delegated work)`)
538
- await this.resumeSuspended(p, undefined, { inline: false })
653
+ topmost = await this.resumeSuspended(p, undefined, { inline: false })
539
654
  }
655
+ return topmost
540
656
  }
541
657
 
542
658
  private deepestSuspended(h: Handle): Handle | null {
@@ -580,25 +696,28 @@ export class AgentRuntime {
580
696
  // ---- budgets (live ancestor-chain enforcement + roll-up ledger) --------
581
697
 
582
698
  /** Walk the LIVE ancestor chain; name the first exhausted pool, if any. */
583
- private poolLimit(h: Handle): string | undefined {
699
+ private poolLimit(h: Handle): TaskLimit | undefined {
584
700
  const now = this.clock.now()
585
701
  for (let a: Handle | null = h; a; a = a.parent) {
586
- if (a.pool.tokens <= 0) return "maxTokens"
587
- if (a.pool.toolCalls <= 0) return "maxToolCalls"
588
- if (a.deadline !== undefined && now >= a.deadline) return "maxWallMs"
702
+ if (a.pool.tokens <= 0) return L.maxTokens
703
+ if (a.pool.toolCalls <= 0) return L.maxToolCalls
704
+ if (a.deadline !== undefined && now >= a.deadline) return L.maxWallMs
589
705
  }
590
706
  return undefined
591
707
  }
592
708
 
593
709
  /** Lifetime turn cap on the handle itself (turns never reset across resumes). */
594
- private turnCap(h: Handle): string | undefined {
595
- return h.turnsTotal >= h.eff.maxTurns ? "maxTurns" : undefined
710
+ private turnCap(h: Handle): TaskLimit | undefined {
711
+ return h.turnsTotal >= h.eff.maxTurns ? L.maxTurns : undefined
596
712
  }
597
713
 
598
714
  /** A limit stop is LOUD: settle an incomplete result (never silent, never a throw). */
599
- private settleRefused(h: Handle, limit: string): string {
715
+ private settleRefused(h: Handle, limit: TaskLimit): string {
716
+ // `limit` here is already a Budget field name (`poolLimit`/`turnCap` return them verbatim);
717
+ // `canonicalLimit` below is the guard that keeps it that way if either ever changes.
600
718
  const text = `budget exhausted (${limit}); partial work preserved`
601
- const r: TaskResult = { text, isError: true, status: "incomplete", turns: h.turnsTotal, totalTokens: h.usageTotal }
719
+ // The exhausted pool is COMPUTED here and used to be discarded into prose. `limit` carries it.
720
+ const r: TaskResult = { text, isError: true, status: S.incomplete, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens, limit }
602
721
  h.lastResult = r
603
722
  for (const w of h.waiters.splice(0)) w(r)
604
723
  return text
@@ -606,6 +725,7 @@ export class AgentRuntime {
606
725
 
607
726
  /** Usage roll-up IS the budget ledger: every ancestor's pool drains live. */
608
727
  private rollUp(h: Handle, tokens: number, toolCalls: number): void {
728
+ h.ownTokens += tokens // own figure: outside the ancestor walk, by construction
609
729
  for (let a: Handle | null = h; a; a = a.parent) {
610
730
  a.usageTotal += tokens
611
731
  a.toolCallsTotal += toolCalls
@@ -728,7 +848,7 @@ export class AgentRuntime {
728
848
 
729
849
  private pendingResult(h: Handle): TaskResult {
730
850
  const req = h.pendingRequest
731
- return { text: req?.prompt ?? "", isError: false, status: "pending", pending: req, turns: h.turnsTotal, totalTokens: h.usageTotal }
851
+ return { text: req?.prompt ?? "", isError: false, status: S.pending, pending: req, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens }
732
852
  }
733
853
 
734
854
  /** The escalation seam: this handle's §10 interpreter authority, with the
@@ -801,7 +921,7 @@ export class AgentRuntime {
801
921
  h.checkpoint = { input }
802
922
  h.drained = [] // drained items ride the checkpointed input, not the inbox
803
923
  this.t(`${h.id}: running→suspended (pending "${stamped.kind}")`)
804
- result = { text: r.text, isError: false, status: "pending", pending: stamped, turns: r.turns, totalTokens: r.usage.totalTokens }
924
+ result = { text: r.text, isError: false, status: S.pending, pending: stamped, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens }
805
925
  } else if (r.status === "incomplete") {
806
926
  h.state = "idle"
807
927
  h.drained = []
@@ -810,13 +930,17 @@ export class AgentRuntime {
810
930
  // generic sentence. Reading `limit` is how the runtime tells them apart.
811
931
  result = {
812
932
  text: r.limit === "completion" ? r.text : "hit maxTurns without a final answer",
813
- isError: true, status: "incomplete", turns: r.turns, totalTokens: r.usage.totalTokens,
933
+ isError: true, status: S.incomplete, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens,
934
+ // The LATENT instance golang found: forwarding the client's `limit` straight through
935
+ // would hand a host `incomplete` with an empty limit whenever the client omits it.
936
+ // The `?? L.maxTurns` is what makes the invariant hold by construction, not by luck.
937
+ limit: canonicalLimit(r.limit) ?? L.maxTurns,
814
938
  }
815
939
  } else {
816
940
  h.state = "idle"
817
941
  h.drained = []
818
942
  this.t(`${h.id}: running→idle (done, turns=${r.turns}, tokens=${r.usage.totalTokens})`)
819
- result = { text: r.text, isError: false, status: "done", turns: r.turns, totalTokens: r.usage.totalTokens }
943
+ result = { text: r.text, isError: false, status: S.done, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens }
820
944
  }
821
945
  } catch (e) {
822
946
  const aborted = h.abort?.signal.aborted ?? false
@@ -828,8 +952,9 @@ export class AgentRuntime {
828
952
  this.t(`${h.id}: running→idle (${aborted ? "interrupted; inbox intact" : `error: ${msg}`})`)
829
953
  result = {
830
954
  text: msg, isError: true,
831
- status: closing ? "closed" : aborted ? "interrupted" : "error",
832
- turns: h.turnsTotal, totalTokens: h.usageTotal,
955
+ // None of these three is a limit stop, so none carries a `limit` (A18).
956
+ status: closing ? S.closed : aborted ? S.interrupted : S.error,
957
+ turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens,
833
958
  }
834
959
  } finally {
835
960
  this.releaseChildSlot(h)
@@ -904,7 +1029,7 @@ export class AgentRuntime {
904
1029
  } else if (existing.state === "running") {
905
1030
  r = await rt.wait(existing, { by: parent })
906
1031
  } else {
907
- r = existing.lastResult ?? { text: `no recorded result for ${existing.id}`, isError: true, status: "error", turns: existing.turnsTotal, totalTokens: existing.usageTotal }
1032
+ r = existing.lastResult ?? { text: `no recorded result for ${existing.id}`, isError: true, status: S.error, turns: existing.turnsTotal, totalTokens: existing.usageTotal, ownTokens: existing.ownTokens }
908
1033
  }
909
1034
  } else {
910
1035
  const spawned = rt.spawn(parent, name)