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.
@@ -1,16 +1,60 @@
1
- import { createClient, InMemoryConversationStore } from "../client.js";
1
+ import { createClient, createInProcessFetch, InMemoryConversationStore, } from "../client.js";
2
2
  import { runGated } from "./loop.js";
3
3
  import { createToolkit } from "../toolkit.js";
4
4
  import { defineTool } from "../native.js";
5
- /** The real clock (default). Timers are unref'd so they never hold the process open. */
5
+ /** The real clock (default). See `Clock.setTimeout` for why `keepAlive` defaults to true. */
6
6
  export const systemClock = {
7
7
  now: () => Date.now(),
8
- setTimeout(fn, ms) {
8
+ setTimeout(fn, ms, opts) {
9
9
  const t = setTimeout(fn, ms);
10
- t.unref?.();
10
+ if (opts?.keepAlive === false)
11
+ t.unref?.();
11
12
  return () => clearTimeout(t);
12
13
  },
13
14
  };
15
+ /**
16
+ * The §7D AGENT status vocabulary, as a value. It is a DIFFERENT, larger closed set than the
17
+ * §8 CLIENT vocabulary (`RUN_STATUSES` = done/pending/incomplete): only this one contains
18
+ * `"timeout"`, and `"timeout"` here means a `wait()` DEADLINE expired while the child keeps
19
+ * running — never that an LLM call timed out. Two fields are called `status`; they are not
20
+ * interchangeable (ADR 0027 D2.1).
21
+ */
22
+ export const TASK_STATUSES = [
23
+ "done", "pending", "incomplete", "interrupted", "closed", "timeout", "error",
24
+ ];
25
+ /**
26
+ * The CLOSED `limit` vocabulary (addendum A14). A value NAMES THE BUDGET FIELD that stopped the
27
+ * run, spelled exactly as `Budget` spells it, plus the two non-budget stops `completion` and
28
+ * `timeout`. Identical in all seven ports, like the `loopUnsupported` strings — "which limit
29
+ * stopped me" is only answerable if the answer is the same word everywhere.
30
+ *
31
+ * A port's INTERNAL pool/dimension name is an implementation detail and must never leak into
32
+ * the field; `canonicalLimit` is the boundary that enforces that here.
33
+ */
34
+ export const TASK_LIMITS = [
35
+ "maxTurns", "maxTokens", "maxToolCalls", "maxWallMs", "maxChildren", "maxConcurrent", "maxDepth",
36
+ "completion", "timeout",
37
+ ];
38
+ /** Map anything the internals produce onto the closed vocabulary; unknown ⇒ undefined, never a
39
+ * leaked internal name. */
40
+ export function canonicalLimit(limit) {
41
+ return limit && TASK_LIMITS.includes(limit) ? limit : undefined;
42
+ }
43
+ /**
44
+ * NAMED CONSTANTS for the two closed vocabularies. Every `TaskResult` construction site in this
45
+ * file spells its status and limit through these, never as a string literal, so a rename cannot
46
+ * silently desync the two fields — which is exactly how `status:"timeout"` came to ship with an
47
+ * empty `limit` in three of the seven ports (addendum A18).
48
+ */
49
+ export const S = Object.freeze({
50
+ done: "done", pending: "pending", incomplete: "incomplete", interrupted: "interrupted",
51
+ closed: "closed", timeout: "timeout", error: "error",
52
+ });
53
+ export const L = Object.freeze({
54
+ maxTurns: "maxTurns", maxTokens: "maxTokens", maxToolCalls: "maxToolCalls", maxWallMs: "maxWallMs",
55
+ maxChildren: "maxChildren", maxConcurrent: "maxConcurrent", maxDepth: "maxDepth",
56
+ completion: "completion", timeout: "timeout",
57
+ });
14
58
  /** Type guard for the `spawn` return union. */
15
59
  export function isVerbError(x) {
16
60
  return typeof x === "object" && x !== null && "error" in x && typeof x.error === "string";
@@ -36,6 +80,8 @@ export class Handle {
36
80
  deadline;
37
81
  /** Total tokens attributed to this handle (own turns + descendant roll-up). */
38
82
  usageTotal = 0;
83
+ /** Tokens spent by THIS handle's own turns, excluding descendants (ADR 0025 D1). */
84
+ ownTokens = 0;
39
85
  /** Total tool calls attributed to this handle (own + descendant roll-up). */
40
86
  toolCallsTotal = 0;
41
87
  /** LLM round trips run by this handle itself (never reset — resume grows it). */
@@ -112,6 +158,18 @@ export class AgentRuntime {
112
158
  turnSlots;
113
159
  turnQueue = [];
114
160
  constructor(opts) {
161
+ if (opts.fetch && opts.inProcess) {
162
+ throw new Error("toolnexus/agents: RuntimeOptions.fetch and RuntimeOptions.inProcess are mutually exclusive — set one, not both");
163
+ }
164
+ if (opts.llm && opts.inProcess) {
165
+ throw new Error("toolnexus/agents: RuntimeOptions.llm and RuntimeOptions.inProcess are mutually exclusive — an in-process model has no wire endpoint to point llm at");
166
+ }
167
+ if (opts.inProcess) {
168
+ // Reuse the SAME adapter createInProcessClient builds on (ADR 0030) — the
169
+ // runtime's in-process wiring is a CALLER of that export, not a second copy
170
+ // of the request/response assembly.
171
+ opts = { ...opts, fetch: createInProcessFetch(opts.inProcess) };
172
+ }
115
173
  this.opts = opts;
116
174
  this.registry = opts.registry;
117
175
  this.clock = opts.clock ?? systemClock;
@@ -207,11 +265,11 @@ export class AgentRuntime {
207
265
  if (opts?.by && h.parent !== opts.by) {
208
266
  return Promise.resolve({
209
267
  text: `wait refused: only the spawner may wait on ${h.id}`,
210
- isError: true, status: "error", turns: h.turnsTotal, totalTokens: h.usageTotal,
268
+ isError: true, status: S.error, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens,
211
269
  });
212
270
  }
213
271
  if (h.state === "closed") {
214
- return Promise.resolve(h.lastResult ?? { text: "closed", isError: true, status: "closed", turns: h.turnsTotal, totalTokens: h.usageTotal });
272
+ return Promise.resolve(h.lastResult ?? { text: "closed", isError: true, status: S.closed, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens });
215
273
  }
216
274
  if (h.state === "suspended")
217
275
  return Promise.resolve(this.pendingResult(h));
@@ -233,7 +291,7 @@ export class AgentRuntime {
233
291
  const i = h.waiters.indexOf(finish);
234
292
  if (i >= 0)
235
293
  h.waiters.splice(i, 1);
236
- finish({ text: `wait timeout after ${opts.timeoutMs}ms (child still ${h.state})`, isError: true, status: "timeout", turns: h.turnsTotal, totalTokens: h.usageTotal });
294
+ 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 });
237
295
  }, opts.timeoutMs);
238
296
  }
239
297
  });
@@ -290,7 +348,7 @@ export class AgentRuntime {
290
348
  await h.def.onClose?.(h, reason);
291
349
  const prev = h.state;
292
350
  h.state = "closed";
293
- const final = { text: "closed", isError: true, status: "closed", turns: h.turnsTotal, totalTokens: h.usageTotal };
351
+ const final = { text: "closed", isError: true, status: S.closed, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens };
294
352
  for (const w of h.waiters.splice(0))
295
353
  w(final);
296
354
  this.t(`${h.id}: ${prev}→closed (${reason})`);
@@ -324,13 +382,16 @@ export class AgentRuntime {
324
382
  if (!leaf)
325
383
  throw new Error("no suspended handle to resume");
326
384
  this.t(`${leaf.id}: resume with Answer(ok=${answer.ok}) at checkpoint (turns so far: ${leaf.turnsTotal})`);
327
- await this.resumeSuspended(leaf, answer, { inline: false });
385
+ // The value is the settled result of the TOPMOST handle the cascade re-ran — what the host
386
+ // would have got from `Agent.run` had the suspension never happened (ADR 0025 D3).
387
+ let topmost = await this.resumeSuspended(leaf, answer, { inline: false });
328
388
  for (let p = leaf.parent; p && p !== this.root; p = p.parent) {
329
389
  if (p.state !== "suspended")
330
390
  break;
331
391
  this.t(`${p.id}: cascade resume (reattaching delegated work)`);
332
- await this.resumeSuspended(p, undefined, { inline: false });
392
+ topmost = await this.resumeSuspended(p, undefined, { inline: false });
333
393
  }
394
+ return topmost;
334
395
  }
335
396
  deepestSuspended(h) {
336
397
  for (const c of h.children) {
@@ -377,22 +438,25 @@ export class AgentRuntime {
377
438
  const now = this.clock.now();
378
439
  for (let a = h; a; a = a.parent) {
379
440
  if (a.pool.tokens <= 0)
380
- return "maxTokens";
441
+ return L.maxTokens;
381
442
  if (a.pool.toolCalls <= 0)
382
- return "maxToolCalls";
443
+ return L.maxToolCalls;
383
444
  if (a.deadline !== undefined && now >= a.deadline)
384
- return "maxWallMs";
445
+ return L.maxWallMs;
385
446
  }
386
447
  return undefined;
387
448
  }
388
449
  /** Lifetime turn cap on the handle itself (turns never reset across resumes). */
389
450
  turnCap(h) {
390
- return h.turnsTotal >= h.eff.maxTurns ? "maxTurns" : undefined;
451
+ return h.turnsTotal >= h.eff.maxTurns ? L.maxTurns : undefined;
391
452
  }
392
453
  /** A limit stop is LOUD: settle an incomplete result (never silent, never a throw). */
393
454
  settleRefused(h, limit) {
455
+ // `limit` here is already a Budget field name (`poolLimit`/`turnCap` return them verbatim);
456
+ // `canonicalLimit` below is the guard that keeps it that way if either ever changes.
394
457
  const text = `budget exhausted (${limit}); partial work preserved`;
395
- const r = { text, isError: true, status: "incomplete", turns: h.turnsTotal, totalTokens: h.usageTotal };
458
+ // The exhausted pool is COMPUTED here and used to be discarded into prose. `limit` carries it.
459
+ const r = { text, isError: true, status: S.incomplete, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens, limit };
396
460
  h.lastResult = r;
397
461
  for (const w of h.waiters.splice(0))
398
462
  w(r);
@@ -400,6 +464,7 @@ export class AgentRuntime {
400
464
  }
401
465
  /** Usage roll-up IS the budget ledger: every ancestor's pool drains live. */
402
466
  rollUp(h, tokens, toolCalls) {
467
+ h.ownTokens += tokens; // own figure: outside the ancestor walk, by construction
403
468
  for (let a = h; a; a = a.parent) {
404
469
  a.usageTotal += tokens;
405
470
  a.toolCallsTotal += toolCalls;
@@ -524,7 +589,7 @@ export class AgentRuntime {
524
589
  }
525
590
  pendingResult(h) {
526
591
  const req = h.pendingRequest;
527
- return { text: req?.prompt ?? "", isError: false, status: "pending", pending: req, turns: h.turnsTotal, totalTokens: h.usageTotal };
592
+ return { text: req?.prompt ?? "", isError: false, status: S.pending, pending: req, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens };
528
593
  }
529
594
  /** The escalation seam: this handle's §10 interpreter authority, with the
530
595
  * escalation traced (nearest interpreter wins; the Request's `data.path` names
@@ -592,7 +657,7 @@ export class AgentRuntime {
592
657
  h.checkpoint = { input };
593
658
  h.drained = []; // drained items ride the checkpointed input, not the inbox
594
659
  this.t(`${h.id}: running→suspended (pending "${stamped.kind}")`);
595
- result = { text: r.text, isError: false, status: "pending", pending: stamped, turns: r.turns, totalTokens: r.usage.totalTokens };
660
+ result = { text: r.text, isError: false, status: S.pending, pending: stamped, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens };
596
661
  }
597
662
  else if (r.status === "incomplete") {
598
663
  h.state = "idle";
@@ -602,14 +667,18 @@ export class AgentRuntime {
602
667
  // generic sentence. Reading `limit` is how the runtime tells them apart.
603
668
  result = {
604
669
  text: r.limit === "completion" ? r.text : "hit maxTurns without a final answer",
605
- isError: true, status: "incomplete", turns: r.turns, totalTokens: r.usage.totalTokens,
670
+ isError: true, status: S.incomplete, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens,
671
+ // The LATENT instance golang found: forwarding the client's `limit` straight through
672
+ // would hand a host `incomplete` with an empty limit whenever the client omits it.
673
+ // The `?? L.maxTurns` is what makes the invariant hold by construction, not by luck.
674
+ limit: canonicalLimit(r.limit) ?? L.maxTurns,
606
675
  };
607
676
  }
608
677
  else {
609
678
  h.state = "idle";
610
679
  h.drained = [];
611
680
  this.t(`${h.id}: running→idle (done, turns=${r.turns}, tokens=${r.usage.totalTokens})`);
612
- result = { text: r.text, isError: false, status: "done", turns: r.turns, totalTokens: r.usage.totalTokens };
681
+ result = { text: r.text, isError: false, status: S.done, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens };
613
682
  }
614
683
  }
615
684
  catch (e) {
@@ -624,8 +693,9 @@ export class AgentRuntime {
624
693
  this.t(`${h.id}: running→idle (${aborted ? "interrupted; inbox intact" : `error: ${msg}`})`);
625
694
  result = {
626
695
  text: msg, isError: true,
627
- status: closing ? "closed" : aborted ? "interrupted" : "error",
628
- turns: h.turnsTotal, totalTokens: h.usageTotal,
696
+ // None of these three is a limit stop, so none carries a `limit` (A18).
697
+ status: closing ? S.closed : aborted ? S.interrupted : S.error,
698
+ turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens,
629
699
  };
630
700
  }
631
701
  finally {
@@ -703,7 +773,7 @@ export class AgentRuntime {
703
773
  r = await rt.wait(existing, { by: parent });
704
774
  }
705
775
  else {
706
- r = existing.lastResult ?? { text: `no recorded result for ${existing.id}`, isError: true, status: "error", turns: existing.turnsTotal, totalTokens: existing.usageTotal };
776
+ r = existing.lastResult ?? { text: `no recorded result for ${existing.id}`, isError: true, status: S.error, turns: existing.turnsTotal, totalTokens: existing.usageTotal, ownTokens: existing.ownTokens };
707
777
  }
708
778
  }
709
779
  else {
package/dist/builtin.d.ts CHANGED
@@ -1,9 +1,33 @@
1
1
  import type { Tool } from "./types.js";
2
- /** Config for the single global builtin toggle (mirrors MCP isEnabled precedence). */
2
+ /**
3
+ * Config for the single global builtin toggle (mirrors MCP isEnabled precedence),
4
+ * plus the host boundary: which interpreter `bash` runs, what a relative path
5
+ * resolves against, and whether paths leaving it are refused (SPEC §4A, ADR 0034).
6
+ */
3
7
  export type BuiltinsConfig = boolean | {
4
8
  enabled?: boolean;
5
9
  disabled?: boolean;
6
10
  tools?: Record<string, boolean>;
11
+ /**
12
+ * The argv prefix `bash` runs a command with — `["sh","-c"]`,
13
+ * `["cmd","/d","/s","/c"]`, `["powershell","-NoProfile","-Command"]`. Set,
14
+ * it is used verbatim. Absent, an interpreter is DETECTED at toolkit
15
+ * construction (POSIX `sh -c`; Windows %COMSPEC% → pwsh → powershell → bash).
16
+ */
17
+ shell?: string[];
18
+ /**
19
+ * What a RELATIVE path means, for every builtin that touches the
20
+ * filesystem — including the paths inside apply_patch's patch text, and as
21
+ * bash's default workdir. Empty ⇒ the process cwd, i.e. today's behaviour.
22
+ */
23
+ baseDir?: string;
24
+ /**
25
+ * Refuse any path whose CANONICAL form leaves baseDir (symlinks, Windows
26
+ * junctions and short names resolved on both sides), and Windows reserved
27
+ * device names. Default off. A guarantee about path resolution in the file
28
+ * builtins, NOT a sandbox — `bash` still reaches the whole filesystem.
29
+ */
30
+ confineToBaseDir?: boolean;
7
31
  };
8
32
  /**
9
33
  * Whether the builtin source is on. Default ON. Same precedence as MCP:
@@ -22,4 +46,10 @@ export declare function selectBuiltins(cfg: BuiltinsConfig | undefined): Tool[];
22
46
  * for parity: bash, read, write, edit, grep, glob, webfetch, question,
23
47
  * apply_patch, todowrite.
24
48
  */
25
- export declare function createBuiltinTools(): Tool[];
49
+ export declare function createBuiltinTools(cfg?: BuiltinsConfig): Tool[];
50
+ /**
51
+ * The interpreter the builtins resolved for `bash` — what a host prints, and
52
+ * what `metadata.shell` carries on every bash result. Throws the same error
53
+ * `selectBuiltins` fails construction with when nothing resolves.
54
+ */
55
+ export declare function builtinShell(cfg?: BuiltinsConfig): string[];