toolnexus 0.14.0 → 0.16.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,5 +1,8 @@
1
1
  import type { Answer, Request, Tool } from "../types.js";
2
2
  import { AgentRuntime, type AgentDef, type Budget, type RuntimeOptions, type TaskResult } from "./runtime.js";
3
+ import { Loop, type Completion, type Guardrail } from "./loop.js";
4
+ import type { ClientOptions } from "../client.js";
5
+ import type { Toolkit } from "../toolkit.js";
3
6
  /** The declarative agent spec (§7D Level-1 surface). */
4
7
  export interface AgentSpec {
5
8
  /** Required routing description — what a delegating model sees. */
@@ -28,6 +31,15 @@ export interface AgentSpec {
28
31
  /** §8 observability sink for this agent. Set ⇒ replaces the runtime-wide
29
32
  * `onMetric` for this agent; resolves independently of `hooks`. */
30
33
  onMetric?: AgentDef["onMetric"];
34
+ /** POLICY checks on tool calls — "may it?", never "is it right?". They compile
35
+ * into one `beforeTool` with FIRST-DENY-WINS: a later guardrail can never widen
36
+ * an earlier denial. Composed with `hooks.beforeTool`, which runs only if every
37
+ * guardrail allows. Empty ⇒ byte-identical. */
38
+ guardrails?: Guardrail[];
39
+ /** When set, the gate that stops this agent claiming `done` before its work
40
+ * verifies. It travels WITH the agent, so a caller that delegates via `task`
41
+ * inherits it — which a host-side retry loop cannot do. Omitted ⇒ byte-identical. */
42
+ completion?: Completion;
31
43
  }
32
44
  /** Runtime options for a one-shot `run`/`asTool` (the registry is derived). */
33
45
  export type AgentRunOptions = Omit<RuntimeOptions, "registry">;
@@ -46,6 +58,12 @@ export declare class Agent {
46
58
  * the registry IS the reachable graph; unreachable agents are not present.
47
59
  */
48
60
  registry(acc?: Record<string, AgentDef>): Record<string, AgentDef>;
61
+ /**
62
+ * Open a LIVE EXECUTION of this agent. Takes client OPTIONS rather than a built
63
+ * client, because a per-call `model` override must be able to change the model —
64
+ * which is fixed when a client is constructed.
65
+ */
66
+ loop(options: ClientOptions, toolkit: Toolkit): Loop;
49
67
  /** Build a runtime scoped to this agent's reachable team graph. */
50
68
  createRuntime(opts?: AgentRunOptions): AgentRuntime;
51
69
  /**
@@ -64,3 +82,10 @@ export declare class Agent {
64
82
  }
65
83
  /** The one new noun: define an agent. */
66
84
  export declare function agent(name: string, spec: AgentSpec): Agent;
85
+ /**
86
+ * `Harness` is a NAME, not a type. An agent spec already IS the harness — tools,
87
+ * soul, team, budget, model, policy, ceilings — so this is the word landing in the
88
+ * API without a second concept to learn or migrate to. A spec built through
89
+ * `harness` and one written inline are indistinguishable.
90
+ */
91
+ export declare function harness(spec: AgentSpec): AgentSpec;
@@ -10,6 +10,7 @@
10
10
  import fs from "node:fs";
11
11
  import { defineTool } from "../native.js";
12
12
  import { AgentRuntime, isVerbError } from "./runtime.js";
13
+ import { Loop, guardedHooks } from "./loop.js";
13
14
  export class Agent {
14
15
  name;
15
16
  spec;
@@ -38,13 +39,24 @@ export class Agent {
38
39
  waitFor: this.spec.waitFor,
39
40
  onSpawn: this.spec.onSpawn,
40
41
  onClose: this.spec.onClose,
41
- hooks: this.spec.hooks,
42
+ // Guardrails are compiled into the hook HERE, at the registry boundary, so a
43
+ // delegated child is governed by them too — not only a directly-driven one.
44
+ hooks: guardedHooks(this.spec.guardrails, this.spec.hooks),
42
45
  onMetric: this.spec.onMetric,
46
+ completion: this.spec.completion,
43
47
  };
44
48
  for (const t of this.spec.team ?? [])
45
49
  t.registry(acc);
46
50
  return acc;
47
51
  }
52
+ /**
53
+ * Open a LIVE EXECUTION of this agent. Takes client OPTIONS rather than a built
54
+ * client, because a per-call `model` override must be able to change the model —
55
+ * which is fixed when a client is constructed.
56
+ */
57
+ loop(options, toolkit) {
58
+ return new Loop(this, options, toolkit);
59
+ }
48
60
  /** Build a runtime scoped to this agent's reachable team graph. */
49
61
  createRuntime(opts = {}) {
50
62
  return new AgentRuntime({ ...opts, registry: this.registry() });
@@ -99,3 +111,12 @@ export class Agent {
99
111
  export function agent(name, spec) {
100
112
  return new Agent(name, spec);
101
113
  }
114
+ /**
115
+ * `Harness` is a NAME, not a type. An agent spec already IS the harness — tools,
116
+ * soul, team, budget, model, policy, ceilings — so this is the word landing in the
117
+ * API without a second concept to learn or migrate to. A spec built through
118
+ * `harness` and one written inline are indistinguishable.
119
+ */
120
+ export function harness(spec) {
121
+ return spec;
122
+ }
@@ -8,3 +8,4 @@ export * from "./runtime.js";
8
8
  export * from "./agent.js";
9
9
  export * from "./home.js";
10
10
  export * from "./compaction.js";
11
+ export * from "./loop.js";
@@ -8,3 +8,4 @@ export * from "./runtime.js";
8
8
  export * from "./agent.js";
9
9
  export * from "./home.js";
10
10
  export * from "./compaction.js";
11
+ export * from "./loop.js";
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Loop — a live execution of an Agent, and the completion gate that stops it
3
+ * claiming `done` too early. A layer over the shipped §8 client: nothing here
4
+ * changes existing behaviour.
5
+ *
6
+ * The placement law this encodes:
7
+ *
8
+ * AgentSpec (the harness) answers "MAY it?" — capability, ceilings. Per problem.
9
+ * RunOptions answers "with WHAT?" — model for this call. Per call.
10
+ * Loop answers "DID it?" — status, turns. Observed.
11
+ * none of them answers "is it RIGHT?"— a tool, skill or agent.
12
+ *
13
+ * So Loop takes no options: it is read, not configured.
14
+ */
15
+ import { type ClientOptions, type Hooks, type RunResult } from "../client.js";
16
+ import type { Toolkit } from "../toolkit.js";
17
+ import type { Agent } from "./agent.js";
18
+ /** A POLICY check on a tool call — "may it?", never "is it right?".
19
+ * Return `"allow"` (or nothing) to permit; any other string DENIES with that reason. */
20
+ export type Guardrail = (ev: {
21
+ name: string;
22
+ args: Record<string, unknown>;
23
+ id?: string;
24
+ turn: number;
25
+ }) => string | undefined | void;
26
+ /** The verdict a completion verifier returns. */
27
+ export interface Verdict {
28
+ ok: boolean;
29
+ reason?: string;
30
+ }
31
+ /** The gate that stops an agent claiming `done` before its work verifies. */
32
+ export interface Completion {
33
+ /** Judges the run. Receives the tool calls ACCUMULATED across attempts. */
34
+ verify: (result: RunResult) => Verdict | Promise<Verdict>;
35
+ /** REQUIRED. An unbounded verify loop is a denial-of-service on the caller's bill. */
36
+ maxAttempts: number;
37
+ }
38
+ /** What varies PER CALL. `model` is here — not on the spec's default and not on the
39
+ * Loop — so the same conversation may change model between turns. */
40
+ export interface LoopRunOptions {
41
+ /** Overrides the agent's model for this call only. Omitted ⇒ the agent's. */
42
+ model?: string;
43
+ }
44
+ /** What a run reports. `status` reuses the SHIPPED vocabulary — no new status
45
+ * strings are minted (SPEC.md pins TaskStatus identical across ports). */
46
+ export interface Outcome {
47
+ text: string;
48
+ status: "done" | "incomplete" | "pending" | "error";
49
+ /** Named whenever `status` is not `done` — never a silent stop. */
50
+ stoppedBy?: string;
51
+ attempts: number;
52
+ turns: number;
53
+ result?: RunResult;
54
+ }
55
+ /**
56
+ * Compiles a spec's guardrails into one `beforeTool` with FIRST-DENY-WINS,
57
+ * composed ahead of any hook the spec already set. No guardrails ⇒ the spec's
58
+ * hooks are returned untouched, so absent is byte-identical.
59
+ */
60
+ export declare function guardedHooks(guardrails: Guardrail[] | undefined, hooks: Hooks | undefined): Hooks | undefined;
61
+ /**
62
+ * The built-in completion verifier. Reads the SHIPPED `todowrite` builtin's
63
+ * result metadata and requires every item to be checked.
64
+ *
65
+ * Structural, not domain: it counts unchecked boxes and never learns what a todo
66
+ * means, so the loop stays domain-blind. No plan declared ⇒ nothing to verify ⇒
67
+ * pass, so the gate never punishes an agent that does not use the builtin.
68
+ */
69
+ export declare function allTodosDone(result: RunResult): Verdict;
70
+ /**
71
+ * Wraps a client run with the completion gate. SHARED by the standalone Loop and
72
+ * the §7D runtime turn, so a delegated child gets exactly the same guarantee as a
73
+ * directly-driven one.
74
+ *
75
+ * Rule 2 in force: a run that is `pending` (suspended on a human) or otherwise
76
+ * non-done already carries its own reason, so the gate never re-judges it. That
77
+ * keeps `pending` and `incomplete` distinct — the caller can always tell whether
78
+ * it owes an Answer or a fix.
79
+ */
80
+ export declare function runGated(ask: (prompt: string) => Promise<RunResult>, prompt: string, completion: Completion | undefined): Promise<RunResult>;
81
+ /** A live execution of an Agent. Its only verbs are `run` and reading state. */
82
+ export declare class Loop {
83
+ #private;
84
+ private readonly agent;
85
+ /** CLIENT OPTIONS rather than a built client, because a per-call `model`
86
+ * override must be able to change the model — which is fixed at construction. */
87
+ private readonly options;
88
+ private readonly toolkit;
89
+ constructor(agent: Agent,
90
+ /** CLIENT OPTIONS rather than a built client, because a per-call `model`
91
+ * override must be able to change the model — which is fixed at construction. */
92
+ options: ClientOptions, toolkit: Toolkit);
93
+ /** Observed, never set by the caller. */
94
+ get status(): string;
95
+ /** Model round trips this loop has spent. */
96
+ get turns(): number;
97
+ run(prompt: string, opts?: LoopRunOptions): Promise<Outcome>;
98
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Loop — a live execution of an Agent, and the completion gate that stops it
3
+ * claiming `done` too early. A layer over the shipped §8 client: nothing here
4
+ * changes existing behaviour.
5
+ *
6
+ * The placement law this encodes:
7
+ *
8
+ * AgentSpec (the harness) answers "MAY it?" — capability, ceilings. Per problem.
9
+ * RunOptions answers "with WHAT?" — model for this call. Per call.
10
+ * Loop answers "DID it?" — status, turns. Observed.
11
+ * none of them answers "is it RIGHT?"— a tool, skill or agent.
12
+ *
13
+ * So Loop takes no options: it is read, not configured.
14
+ */
15
+ import { createClient } from "../client.js";
16
+ /**
17
+ * Compiles a spec's guardrails into one `beforeTool` with FIRST-DENY-WINS,
18
+ * composed ahead of any hook the spec already set. No guardrails ⇒ the spec's
19
+ * hooks are returned untouched, so absent is byte-identical.
20
+ */
21
+ export function guardedHooks(guardrails, hooks) {
22
+ if (!guardrails || guardrails.length === 0)
23
+ return hooks;
24
+ const prior = hooks?.beforeTool;
25
+ return {
26
+ ...(hooks ?? {}),
27
+ beforeTool: async (ev) => {
28
+ for (const g of guardrails) {
29
+ const verdict = g(ev);
30
+ if (verdict && verdict !== "allow") {
31
+ return { result: { output: `denied: ${verdict}`, isError: true } };
32
+ }
33
+ }
34
+ return prior ? prior(ev) : undefined;
35
+ },
36
+ };
37
+ }
38
+ /**
39
+ * The built-in completion verifier. Reads the SHIPPED `todowrite` builtin's
40
+ * result metadata and requires every item to be checked.
41
+ *
42
+ * Structural, not domain: it counts unchecked boxes and never learns what a todo
43
+ * means, so the loop stays domain-blind. No plan declared ⇒ nothing to verify ⇒
44
+ * pass, so the gate never punishes an agent that does not use the builtin.
45
+ */
46
+ export function allTodosDone(result) {
47
+ for (let i = result.toolCalls.length - 1; i >= 0; i--) {
48
+ if (result.toolCalls[i].name !== "todowrite")
49
+ continue;
50
+ const todos = result.toolCalls[i].metadata?.todos;
51
+ if (!Array.isArray(todos))
52
+ return { ok: true };
53
+ const open = todos
54
+ .filter((t) => !t?.completed)
55
+ .map((t) => String(t?.text ?? ""));
56
+ return open.length > 0
57
+ ? { ok: false, reason: `${open.length} item(s) still open: ${open.join("; ")}` }
58
+ : { ok: true };
59
+ }
60
+ return { ok: true };
61
+ }
62
+ /**
63
+ * Wraps a client run with the completion gate. SHARED by the standalone Loop and
64
+ * the §7D runtime turn, so a delegated child gets exactly the same guarantee as a
65
+ * directly-driven one.
66
+ *
67
+ * Rule 2 in force: a run that is `pending` (suspended on a human) or otherwise
68
+ * non-done already carries its own reason, so the gate never re-judges it. That
69
+ * keeps `pending` and `incomplete` distinct — the caller can always tell whether
70
+ * it owes an Answer or a fix.
71
+ */
72
+ export async function runGated(ask, prompt, completion) {
73
+ if (!completion)
74
+ return ask(prompt);
75
+ const { maxAttempts } = completion;
76
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
77
+ throw new Error("toolnexus: completion.maxAttempts must be an integer >= 1");
78
+ }
79
+ if (typeof completion.verify !== "function") {
80
+ throw new Error("toolnexus: completion.verify is required");
81
+ }
82
+ let accumulated = [];
83
+ let last;
84
+ let reason = "";
85
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
86
+ const p = attempt === 1 ? prompt : `Your work did not verify: ${reason}. Fix it and finish.`;
87
+ const r = await ask(p);
88
+ // The gate judges the ACCUMULATED work, so an agent cannot escape it by
89
+ // declining to re-declare its plan on a retry.
90
+ accumulated = [...accumulated, ...r.toolCalls];
91
+ const judged = { ...r, toolCalls: accumulated };
92
+ last = judged;
93
+ if (r.status && r.status !== "done") {
94
+ // The run stopped for its own reason (suspension, budget). If the gate was
95
+ // mid-retry the caller must learn BOTH — otherwise a budget stop masks the
96
+ // verification failure and they never see why it was looping.
97
+ if (reason && r.status !== "pending") {
98
+ judged.text = `${r.text} [while verifying: attempt ${attempt} last failed: ${reason}]`;
99
+ }
100
+ return judged;
101
+ }
102
+ const verdict = await completion.verify(judged);
103
+ if (verdict.ok)
104
+ return judged;
105
+ reason = verdict.reason ?? "unspecified";
106
+ }
107
+ // Structured, not prose: `limit` is how a caller (and the §7D runtime) tells
108
+ // WHICH limit stopped the run. `text` carries the human reason.
109
+ return {
110
+ ...last,
111
+ status: "incomplete",
112
+ limit: "completion",
113
+ text: `completion.verify failed ${maxAttempts}×: ${reason}`,
114
+ };
115
+ }
116
+ /** A live execution of an Agent. Its only verbs are `run` and reading state. */
117
+ export class Loop {
118
+ agent;
119
+ options;
120
+ toolkit;
121
+ #status = "idle";
122
+ #turns = 0;
123
+ constructor(agent,
124
+ /** CLIENT OPTIONS rather than a built client, because a per-call `model`
125
+ * override must be able to change the model — which is fixed at construction. */
126
+ options, toolkit) {
127
+ this.agent = agent;
128
+ this.options = options;
129
+ this.toolkit = toolkit;
130
+ }
131
+ /** Observed, never set by the caller. */
132
+ get status() {
133
+ return this.#status;
134
+ }
135
+ /** Model round trips this loop has spent. */
136
+ get turns() {
137
+ return this.#turns;
138
+ }
139
+ async run(prompt, opts = {}) {
140
+ const completion = this.agent.spec.completion;
141
+ const client = createClient(this.#clientOptions(opts));
142
+ this.#status = "running";
143
+ let attempts = 0;
144
+ let history = [];
145
+ const ask = async (p) => {
146
+ attempts++;
147
+ const r = await client.run(p, { toolkit: this.toolkit, history });
148
+ this.#turns += r.turns;
149
+ history = r.messages;
150
+ return r;
151
+ };
152
+ try {
153
+ const r = await runGated(ask, prompt, completion);
154
+ if (r.status && r.status !== "done") {
155
+ this.#status = r.status;
156
+ const stoppedBy = r.limit === "completion" ? r.text : `run reported ${r.status}`;
157
+ return { text: r.text, status: r.status, stoppedBy, attempts, turns: this.#turns, result: r };
158
+ }
159
+ this.#status = "idle";
160
+ return { text: r.text, status: "done", attempts, turns: this.#turns, result: r };
161
+ }
162
+ catch (e) {
163
+ this.#status = "error";
164
+ throw e;
165
+ }
166
+ }
167
+ /** Applies a per-call model override via `requestParams` (`model` is not in the
168
+ * forbidden set — the client forbids only messages/tools/stream). */
169
+ #clientOptions(opts) {
170
+ const base = {
171
+ ...this.options,
172
+ systemPrompt: this.agent.spec.soul ?? this.options.systemPrompt,
173
+ hooks: guardedHooks(this.agent.spec.guardrails, this.agent.spec.hooks ?? this.options.hooks),
174
+ };
175
+ if (!opts.model)
176
+ return base;
177
+ return { ...base, requestParams: { ...(base.requestParams ?? {}), model: opts.model } };
178
+ }
179
+ }
@@ -15,6 +15,7 @@
15
15
  * identical per-handle transition traces against `examples/subagent-*` fixtures.
16
16
  */
17
17
  import { type ClientStyle, type ConversationStore, type Hooks, type MetricEvent } from "../client.js";
18
+ import { type Completion } from "./loop.js";
18
19
  import type { Answer, Request, Tool } from "../types.js";
19
20
  /** Injectable time source. All runtime timers, timeouts, and deadlines use it. */
20
21
  export interface Clock {
@@ -76,6 +77,10 @@ export interface AgentDef {
76
77
  /** §8 observability sink for THIS agent's client. Set ⇒ REPLACES the runtime-wide
77
78
  * `onMetric` for this agent. Resolves independently of `hooks`. Forwarded verbatim. */
78
79
  onMetric?: (ev: MetricEvent) => void;
80
+ /** §7D completion gate, projected from the agent spec. Present ⇒ this agent's
81
+ * turns run through the gate even when it is a DELEGATED child — which is what a
82
+ * host-side retry loop cannot do. */
83
+ completion?: Completion;
79
84
  }
80
85
  /** An unsolicited inbox item. Delivered ONLY as part of a fresh turn's coalesced
81
86
  * drain, with provenance rendered per item (non-ancestor senders are untrusted). */
@@ -15,6 +15,7 @@
15
15
  * identical per-handle transition traces against `examples/subagent-*` fixtures.
16
16
  */
17
17
  import { createClient, InMemoryConversationStore } from "../client.js";
18
+ import { runGated } from "./loop.js";
18
19
  import { createToolkit } from "../toolkit.js";
19
20
  import { defineTool } from "../native.js";
20
21
  /** The real clock (default). Timers are unref'd so they never hold the process open. */
@@ -584,7 +585,9 @@ export class AgentRuntime {
584
585
  hooks: h.def.hooks ?? this.opts.hooks,
585
586
  onMetric: h.def.onMetric ?? this.opts.onMetric,
586
587
  });
587
- const r = await client.ask(input, { toolkit, id: h.id, signal: h.abort.signal });
588
+ // The gate runs HERE, inside the runtime turn, which is what makes it reach a
589
+ // delegated child: `task` executes the child through this same path.
590
+ const r = await runGated((p) => client.ask(p, { toolkit, id: h.id, signal: h.abort.signal }), input, h.def.completion);
588
591
  h.turnsTotal += r.turns;
589
592
  this.rollUp(h, r.usage.totalTokens, r.toolCallCount);
590
593
  if (r.status === "pending" && r.pending) {
@@ -604,7 +607,12 @@ export class AgentRuntime {
604
607
  h.state = "idle";
605
608
  h.drained = [];
606
609
  this.t(`${h.id}: running→idle (incomplete: ${r.limit ?? "maxTurns"})`);
607
- result = { text: "hit maxTurns without a final answer", isError: true, status: "incomplete", turns: r.turns, totalTokens: r.usage.totalTokens };
610
+ // A completion stop carries its own reason; only a maxTurns stop gets the
611
+ // generic sentence. Reading `limit` is how the runtime tells them apart.
612
+ result = {
613
+ text: r.limit === "completion" ? r.text : "hit maxTurns without a final answer",
614
+ isError: true, status: "incomplete", turns: r.turns, totalTokens: r.usage.totalTokens,
615
+ };
608
616
  }
609
617
  else {
610
618
  h.state = "idle";
package/dist/client.d.ts CHANGED
@@ -333,6 +333,51 @@ export declare class Client {
333
333
  private streamAnthropic;
334
334
  }
335
335
  export declare function createClient(opts: ClientOptions): Client;
336
+ /** One tool call an in-process model asks for. Flat on purpose: the nested
337
+ * `function: {}` wrapper is a wire detail, not something a model author should type.
338
+ * `arguments` may be a structured value (encoded for you) or an encoded string. */
339
+ export interface InProcessToolCall {
340
+ id?: string;
341
+ name: string;
342
+ arguments?: unknown;
343
+ }
344
+ /** What an in-process model is handed: the assembled request for THIS call. */
345
+ export interface InProcessRequest {
346
+ messages: any[];
347
+ tools?: any[];
348
+ model: string;
349
+ /** Every other key the client assembled, for a model that wants them. */
350
+ body: Record<string, any>;
351
+ }
352
+ /** What an in-process model returns: exactly one assistant message. Return `content`
353
+ * to finish, or `toolCalls` to ask for tools — never both. `usage` is optional; when
354
+ * omitted the run reports zero tokens rather than failing. */
355
+ export interface InProcessResponse {
356
+ content?: string;
357
+ toolCalls?: InProcessToolCall[];
358
+ usage?: {
359
+ promptTokens?: number;
360
+ completionTokens?: number;
361
+ totalTokens?: number;
362
+ };
363
+ }
364
+ /** Options for {@link createInProcessClient}: every client option EXCEPT the three
365
+ * that describe a wire — `baseUrl`, `apiKey` and `style` — plus `generate`. */
366
+ export type InProcessOptions = Omit<ClientOptions, "baseUrl" | "apiKey" | "style" | "fetch"> & {
367
+ /** Your model. Receives the assembled request, returns one assistant message. */
368
+ generate: (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>;
369
+ };
370
+ /**
371
+ * A client backed by a model running IN THIS PROCESS — no server, no socket, and no
372
+ * HTTP types to construct.
373
+ *
374
+ * This is a second constructor, not a second seam: it builds on the same injectable
375
+ * transport that `fetch`/`httpClient` uses, so the tool-calling loop, MCP servers,
376
+ * skills, sub-agents, hooks, metrics and the completion gate all behave identically.
377
+ *
378
+ * Streaming is refused rather than faked — see the thrown error.
379
+ */
380
+ export declare function createInProcessClient(opts: InProcessOptions): Client;
336
381
  /** Stateful multi-turn conversation: each send() continues the same transcript (memory). */
337
382
  export declare class Conversation {
338
383
  private readonly client;
package/dist/client.js CHANGED
@@ -12,6 +12,28 @@ export class InMemoryConversationStore {
12
12
  }
13
13
  }
14
14
  const RETRYABLE = new Set([429, 500, 502, 503, 504]);
15
+ /** ~68 years; the widest whole-second count all seven ports represent exactly. */
16
+ const RETRY_AFTER_MAX_SECONDS = 2147483647;
17
+ /**
18
+ * Honour `Retry-After` only in its delay-seconds form: a run of ASCII digits
19
+ * (RFC 9110 §10.2.3) in 0…2147483647, returned in milliseconds.
20
+ *
21
+ * `Number()` is far too permissive for this — it accepts `"0.5"`, `"-5"`, `"1e3"`
22
+ * and `" "` (as 0), which is how this port came to wait for fractional seconds
23
+ * that five of the other six rejected outright. Fractional, signed, HTTP-date and
24
+ * out-of-range values are not delays we can honour, so the caller falls back to
25
+ * backoff rather than guessing. `0` is a real answer, so it returns `0`, not
26
+ * `null`, and callers must use `??` rather than `||`.
27
+ */
28
+ function retryAfterMs(raw) {
29
+ if (raw == null)
30
+ return null;
31
+ const s = raw.trim();
32
+ if (!/^[0-9]+$/.test(s))
33
+ return null;
34
+ const secs = Number(s);
35
+ return secs <= RETRY_AFTER_MAX_SECONDS ? secs * 1000 : null;
36
+ }
15
37
  function delay(ms, signal) {
16
38
  return new Promise((resolve, reject) => {
17
39
  const t = setTimeout(resolve, ms);
@@ -457,8 +479,9 @@ export class Client {
457
479
  const tier = classify({ status: res.status, attempt, retryable });
458
480
  if (tier === "fail" || attempt === retries)
459
481
  return res; // caller surfaces the non-ok status
460
- const ra = Number(res.headers.get("retry-after"));
461
- await delay(ra ? ra * 1000 : base * 2 ** attempt + Math.random() * 100, signal);
482
+ const ra = retryAfterMs(res.headers.get("retry-after"));
483
+ // `?? `, not `|| `: Retry-After: 0 means "retry now", not "no opinion".
484
+ await delay(ra ?? base * 2 ** attempt + Math.random() * 100, signal);
462
485
  }
463
486
  catch (e) {
464
487
  if (signal.aborted)
@@ -927,6 +950,84 @@ export class Client {
927
950
  export function createClient(opts) {
928
951
  return new Client(opts);
929
952
  }
953
+ /** The sentinel base URL. Never dialled — the transport below answers every request
954
+ * before the network is reached — but the client builds a URL string internally, so
955
+ * it must be syntactically valid. `.invalid` is reserved by RFC 2606 precisely so a
956
+ * name can never resolve. */
957
+ const IN_PROCESS_BASE_URL = "http://in-process.invalid/v1";
958
+ /**
959
+ * A client backed by a model running IN THIS PROCESS — no server, no socket, and no
960
+ * HTTP types to construct.
961
+ *
962
+ * This is a second constructor, not a second seam: it builds on the same injectable
963
+ * transport that `fetch`/`httpClient` uses, so the tool-calling loop, MCP servers,
964
+ * skills, sub-agents, hooks, metrics and the completion gate all behave identically.
965
+ *
966
+ * Streaming is refused rather than faked — see the thrown error.
967
+ */
968
+ export function createInProcessClient(opts) {
969
+ const { generate, ...rest } = opts;
970
+ if (typeof generate !== "function") {
971
+ throw new Error("toolnexus: createInProcessClient requires a `generate` function");
972
+ }
973
+ const inProcessFetch = async (_url, init) => {
974
+ const body = JSON.parse(String(init?.body ?? "{}"));
975
+ // A generate() returns a whole answer, so a stream would be one chunk pretending
976
+ // to be many. Content and delta-count assertions cannot tell that from a real
977
+ // stream, so refuse instead of degrading silently.
978
+ if (body.stream) {
979
+ throw new Error("toolnexus: createInProcessClient does not support streaming — `generate` returns a " +
980
+ "complete answer. Use the non-streaming run, or supply a `fetch`/transport that streams.");
981
+ }
982
+ const answer = await generate({
983
+ messages: body.messages ?? [],
984
+ tools: body.tools,
985
+ model: body.model,
986
+ body,
987
+ });
988
+ const message = { role: "assistant" };
989
+ if (answer.toolCalls && answer.toolCalls.length > 0) {
990
+ message.tool_calls = answer.toolCalls.map((c, i) => ({
991
+ id: c.id ?? `call_${i}`,
992
+ type: "function",
993
+ function: {
994
+ name: c.name,
995
+ // A string is already encoded; anything else is encoded here so a model
996
+ // author never has to think about the wire.
997
+ arguments: typeof c.arguments === "string" ? c.arguments : JSON.stringify(c.arguments ?? {}),
998
+ },
999
+ }));
1000
+ }
1001
+ else {
1002
+ message.content = answer.content ?? "";
1003
+ }
1004
+ const u = answer.usage ?? {};
1005
+ const prompt = u.promptTokens ?? 0;
1006
+ const completion = u.completionTokens ?? 0;
1007
+ return new Response(JSON.stringify({
1008
+ choices: [{ index: 0, message, finish_reason: message.tool_calls ? "tool_calls" : "stop" }],
1009
+ usage: {
1010
+ prompt_tokens: prompt,
1011
+ completion_tokens: completion,
1012
+ total_tokens: u.totalTokens ?? prompt + completion,
1013
+ },
1014
+ }), { status: 200, headers: { "content-type": "application/json" } });
1015
+ };
1016
+ return new Client({
1017
+ // Retries default to ZERO here, unlike a network client. There is no wire, so
1018
+ // there is no transient failure to ride out: whatever `generate` throws will
1019
+ // throw again. Retrying it only buys backoff before the user sees their own bug —
1020
+ // measured at 3.7s for the streaming refusal before this default. A host whose
1021
+ // model really is flaky (a GPU that OOMs, say) can still set `retries`.
1022
+ retries: 0,
1023
+ ...rest,
1024
+ baseUrl: IN_PROCESS_BASE_URL,
1025
+ style: "openai",
1026
+ // An in-process model has no endpoint to authenticate to, so the host must never need a key — but the client resolves one from the environment and fails when it finds none. A sentinel keeps that resolution from ever running. Caught by CI, which has no OPENROUTER_API_KEY; every local run passed because a developer shell has one.
1027
+ apiKey: "in-process",
1028
+ fetch: inProcessFetch,
1029
+ });
1030
+ }
930
1031
  /** Stateful multi-turn conversation: each send() continues the same transcript (memory). */
931
1032
  export class Conversation {
932
1033
  client;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolnexus",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Dynamic MCP servers + agent skills as uniform tools for any LLM (opencode-style).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "scripts": {
19
19
  "build": "tsc -p tsconfig.json",
20
- "test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/home.test.ts test/compaction.test.ts",
20
+ "test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts",
21
21
  "example": "node --experimental-strip-types examples/basic.ts"
22
22
  },
23
23
  "keywords": [
@@ -11,6 +11,9 @@ import fs from "node:fs"
11
11
  import { defineTool } from "../native.js"
12
12
  import type { Answer, Request, Tool } from "../types.js"
13
13
  import { AgentRuntime, isVerbError, type AgentDef, type Budget, type Handle, type RuntimeOptions, type TaskResult } from "./runtime.js"
14
+ import { Loop, guardedHooks, type Completion, type Guardrail } from "./loop.js"
15
+ import type { ClientOptions } from "../client.js"
16
+ import type { Toolkit } from "../toolkit.js"
14
17
 
15
18
  /** The declarative agent spec (§7D Level-1 surface). */
16
19
  export interface AgentSpec {
@@ -38,6 +41,15 @@ export interface AgentSpec {
38
41
  /** §8 observability sink for this agent. Set ⇒ replaces the runtime-wide
39
42
  * `onMetric` for this agent; resolves independently of `hooks`. */
40
43
  onMetric?: AgentDef["onMetric"]
44
+ /** POLICY checks on tool calls — "may it?", never "is it right?". They compile
45
+ * into one `beforeTool` with FIRST-DENY-WINS: a later guardrail can never widen
46
+ * an earlier denial. Composed with `hooks.beforeTool`, which runs only if every
47
+ * guardrail allows. Empty ⇒ byte-identical. */
48
+ guardrails?: Guardrail[]
49
+ /** When set, the gate that stops this agent claiming `done` before its work
50
+ * verifies. It travels WITH the agent, so a caller that delegates via `task`
51
+ * inherits it — which a host-side retry loop cannot do. Omitted ⇒ byte-identical. */
52
+ completion?: Completion
41
53
  }
42
54
 
43
55
  /** Runtime options for a one-shot `run`/`asTool` (the registry is derived). */
@@ -72,13 +84,25 @@ export class Agent {
72
84
  waitFor: this.spec.waitFor,
73
85
  onSpawn: this.spec.onSpawn,
74
86
  onClose: this.spec.onClose,
75
- hooks: this.spec.hooks,
87
+ // Guardrails are compiled into the hook HERE, at the registry boundary, so a
88
+ // delegated child is governed by them too — not only a directly-driven one.
89
+ hooks: guardedHooks(this.spec.guardrails, this.spec.hooks),
76
90
  onMetric: this.spec.onMetric,
91
+ completion: this.spec.completion,
77
92
  }
78
93
  for (const t of this.spec.team ?? []) t.registry(acc)
79
94
  return acc
80
95
  }
81
96
 
97
+ /**
98
+ * Open a LIVE EXECUTION of this agent. Takes client OPTIONS rather than a built
99
+ * client, because a per-call `model` override must be able to change the model —
100
+ * which is fixed when a client is constructed.
101
+ */
102
+ loop(options: ClientOptions, toolkit: Toolkit): Loop {
103
+ return new Loop(this, options, toolkit)
104
+ }
105
+
82
106
  /** Build a runtime scoped to this agent's reachable team graph. */
83
107
  createRuntime(opts: AgentRunOptions = {}): AgentRuntime {
84
108
  return new AgentRuntime({ ...opts, registry: this.registry() })
@@ -135,3 +159,13 @@ export class Agent {
135
159
  export function agent(name: string, spec: AgentSpec): Agent {
136
160
  return new Agent(name, spec)
137
161
  }
162
+
163
+ /**
164
+ * `Harness` is a NAME, not a type. An agent spec already IS the harness — tools,
165
+ * soul, team, budget, model, policy, ceilings — so this is the word landing in the
166
+ * API without a second concept to learn or migrate to. A spec built through
167
+ * `harness` and one written inline are indistinguishable.
168
+ */
169
+ export function harness(spec: AgentSpec): AgentSpec {
170
+ return spec
171
+ }
@@ -8,3 +8,4 @@ export * from "./runtime.js"
8
8
  export * from "./agent.js"
9
9
  export * from "./home.js"
10
10
  export * from "./compaction.js"
11
+ export * from "./loop.js"
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Loop — a live execution of an Agent, and the completion gate that stops it
3
+ * claiming `done` too early. A layer over the shipped §8 client: nothing here
4
+ * changes existing behaviour.
5
+ *
6
+ * The placement law this encodes:
7
+ *
8
+ * AgentSpec (the harness) answers "MAY it?" — capability, ceilings. Per problem.
9
+ * RunOptions answers "with WHAT?" — model for this call. Per call.
10
+ * Loop answers "DID it?" — status, turns. Observed.
11
+ * none of them answers "is it RIGHT?"— a tool, skill or agent.
12
+ *
13
+ * So Loop takes no options: it is read, not configured.
14
+ */
15
+ import { createClient, type ClientOptions, type Hooks, type RunResult } from "../client.js"
16
+ import type { Toolkit } from "../toolkit.js"
17
+ import type { Agent } from "./agent.js"
18
+
19
+ /** A POLICY check on a tool call — "may it?", never "is it right?".
20
+ * Return `"allow"` (or nothing) to permit; any other string DENIES with that reason. */
21
+ export type Guardrail = (ev: {
22
+ name: string
23
+ args: Record<string, unknown>
24
+ id?: string
25
+ turn: number
26
+ }) => string | undefined | void
27
+
28
+ /** The verdict a completion verifier returns. */
29
+ export interface Verdict {
30
+ ok: boolean
31
+ reason?: string
32
+ }
33
+
34
+ /** The gate that stops an agent claiming `done` before its work verifies. */
35
+ export interface Completion {
36
+ /** Judges the run. Receives the tool calls ACCUMULATED across attempts. */
37
+ verify: (result: RunResult) => Verdict | Promise<Verdict>
38
+ /** REQUIRED. An unbounded verify loop is a denial-of-service on the caller's bill. */
39
+ maxAttempts: number
40
+ }
41
+
42
+ /** What varies PER CALL. `model` is here — not on the spec's default and not on the
43
+ * Loop — so the same conversation may change model between turns. */
44
+ export interface LoopRunOptions {
45
+ /** Overrides the agent's model for this call only. Omitted ⇒ the agent's. */
46
+ model?: string
47
+ }
48
+
49
+ /** What a run reports. `status` reuses the SHIPPED vocabulary — no new status
50
+ * strings are minted (SPEC.md pins TaskStatus identical across ports). */
51
+ export interface Outcome {
52
+ text: string
53
+ status: "done" | "incomplete" | "pending" | "error"
54
+ /** Named whenever `status` is not `done` — never a silent stop. */
55
+ stoppedBy?: string
56
+ attempts: number
57
+ turns: number
58
+ result?: RunResult
59
+ }
60
+
61
+ /**
62
+ * Compiles a spec's guardrails into one `beforeTool` with FIRST-DENY-WINS,
63
+ * composed ahead of any hook the spec already set. No guardrails ⇒ the spec's
64
+ * hooks are returned untouched, so absent is byte-identical.
65
+ */
66
+ export function guardedHooks(guardrails: Guardrail[] | undefined, hooks: Hooks | undefined): Hooks | undefined {
67
+ if (!guardrails || guardrails.length === 0) return hooks
68
+ const prior = hooks?.beforeTool
69
+ return {
70
+ ...(hooks ?? {}),
71
+ beforeTool: async (ev) => {
72
+ for (const g of guardrails) {
73
+ const verdict = g(ev)
74
+ if (verdict && verdict !== "allow") {
75
+ return { result: { output: `denied: ${verdict}`, isError: true } }
76
+ }
77
+ }
78
+ return prior ? prior(ev) : undefined
79
+ },
80
+ }
81
+ }
82
+
83
+ /**
84
+ * The built-in completion verifier. Reads the SHIPPED `todowrite` builtin's
85
+ * result metadata and requires every item to be checked.
86
+ *
87
+ * Structural, not domain: it counts unchecked boxes and never learns what a todo
88
+ * means, so the loop stays domain-blind. No plan declared ⇒ nothing to verify ⇒
89
+ * pass, so the gate never punishes an agent that does not use the builtin.
90
+ */
91
+ export function allTodosDone(result: RunResult): Verdict {
92
+ for (let i = result.toolCalls.length - 1; i >= 0; i--) {
93
+ if (result.toolCalls[i].name !== "todowrite") continue
94
+ const todos = result.toolCalls[i].metadata?.todos
95
+ if (!Array.isArray(todos)) return { ok: true }
96
+ const open = todos
97
+ .filter((t: any) => !t?.completed)
98
+ .map((t: any) => String(t?.text ?? ""))
99
+ return open.length > 0
100
+ ? { ok: false, reason: `${open.length} item(s) still open: ${open.join("; ")}` }
101
+ : { ok: true }
102
+ }
103
+ return { ok: true }
104
+ }
105
+
106
+ /**
107
+ * Wraps a client run with the completion gate. SHARED by the standalone Loop and
108
+ * the §7D runtime turn, so a delegated child gets exactly the same guarantee as a
109
+ * directly-driven one.
110
+ *
111
+ * Rule 2 in force: a run that is `pending` (suspended on a human) or otherwise
112
+ * non-done already carries its own reason, so the gate never re-judges it. That
113
+ * keeps `pending` and `incomplete` distinct — the caller can always tell whether
114
+ * it owes an Answer or a fix.
115
+ */
116
+ export async function runGated(
117
+ ask: (prompt: string) => Promise<RunResult>,
118
+ prompt: string,
119
+ completion: Completion | undefined,
120
+ ): Promise<RunResult> {
121
+ if (!completion) return ask(prompt)
122
+ const { maxAttempts } = completion
123
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
124
+ throw new Error("toolnexus: completion.maxAttempts must be an integer >= 1")
125
+ }
126
+ if (typeof completion.verify !== "function") {
127
+ throw new Error("toolnexus: completion.verify is required")
128
+ }
129
+
130
+ let accumulated: RunResult["toolCalls"] = []
131
+ let last: RunResult | undefined
132
+ let reason = ""
133
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
134
+ const p = attempt === 1 ? prompt : `Your work did not verify: ${reason}. Fix it and finish.`
135
+ const r = await ask(p)
136
+ // The gate judges the ACCUMULATED work, so an agent cannot escape it by
137
+ // declining to re-declare its plan on a retry.
138
+ accumulated = [...accumulated, ...r.toolCalls]
139
+ const judged: RunResult = { ...r, toolCalls: accumulated }
140
+ last = judged
141
+
142
+ if (r.status && r.status !== "done") {
143
+ // The run stopped for its own reason (suspension, budget). If the gate was
144
+ // mid-retry the caller must learn BOTH — otherwise a budget stop masks the
145
+ // verification failure and they never see why it was looping.
146
+ if (reason && r.status !== "pending") {
147
+ judged.text = `${r.text} [while verifying: attempt ${attempt} last failed: ${reason}]`
148
+ }
149
+ return judged
150
+ }
151
+
152
+ const verdict = await completion.verify(judged)
153
+ if (verdict.ok) return judged
154
+ reason = verdict.reason ?? "unspecified"
155
+ }
156
+
157
+ // Structured, not prose: `limit` is how a caller (and the §7D runtime) tells
158
+ // WHICH limit stopped the run. `text` carries the human reason.
159
+ return {
160
+ ...(last as RunResult),
161
+ status: "incomplete",
162
+ limit: "completion",
163
+ text: `completion.verify failed ${maxAttempts}×: ${reason}`,
164
+ }
165
+ }
166
+
167
+ /** A live execution of an Agent. Its only verbs are `run` and reading state. */
168
+ export class Loop {
169
+ #status: Outcome["status"] | "idle" | "running" = "idle"
170
+ #turns = 0
171
+
172
+ constructor(
173
+ private readonly agent: Agent,
174
+ /** CLIENT OPTIONS rather than a built client, because a per-call `model`
175
+ * override must be able to change the model — which is fixed at construction. */
176
+ private readonly options: ClientOptions,
177
+ private readonly toolkit: Toolkit,
178
+ ) {}
179
+
180
+ /** Observed, never set by the caller. */
181
+ get status(): string {
182
+ return this.#status
183
+ }
184
+
185
+ /** Model round trips this loop has spent. */
186
+ get turns(): number {
187
+ return this.#turns
188
+ }
189
+
190
+ async run(prompt: string, opts: LoopRunOptions = {}): Promise<Outcome> {
191
+ const completion = this.agent.spec.completion
192
+ const client = createClient(this.#clientOptions(opts))
193
+ this.#status = "running"
194
+
195
+ let attempts = 0
196
+ let history: any[] = []
197
+ const ask = async (p: string): Promise<RunResult> => {
198
+ attempts++
199
+ const r = await client.run(p, { toolkit: this.toolkit, history })
200
+ this.#turns += r.turns
201
+ history = r.messages
202
+ return r
203
+ }
204
+
205
+ try {
206
+ const r = await runGated(ask, prompt, completion)
207
+ if (r.status && r.status !== "done") {
208
+ this.#status = r.status as Outcome["status"]
209
+ const stoppedBy =
210
+ r.limit === "completion" ? r.text : `run reported ${r.status}`
211
+ return { text: r.text, status: r.status as Outcome["status"], stoppedBy, attempts, turns: this.#turns, result: r }
212
+ }
213
+ this.#status = "idle"
214
+ return { text: r.text, status: "done", attempts, turns: this.#turns, result: r }
215
+ } catch (e) {
216
+ this.#status = "error"
217
+ throw e
218
+ }
219
+ }
220
+
221
+ /** Applies a per-call model override via `requestParams` (`model` is not in the
222
+ * forbidden set — the client forbids only messages/tools/stream). */
223
+ #clientOptions(opts: LoopRunOptions): ClientOptions {
224
+ const base: ClientOptions = {
225
+ ...this.options,
226
+ systemPrompt: this.agent.spec.soul ?? this.options.systemPrompt,
227
+ hooks: guardedHooks(this.agent.spec.guardrails, this.agent.spec.hooks ?? this.options.hooks),
228
+ }
229
+ if (!opts.model) return base
230
+ return { ...base, requestParams: { ...(base.requestParams ?? {}), model: opts.model } }
231
+ }
232
+ }
@@ -15,6 +15,7 @@
15
15
  * identical per-handle transition traces against `examples/subagent-*` fixtures.
16
16
  */
17
17
  import { createClient, InMemoryConversationStore, type ClientStyle, type ConversationStore, type Hooks, type MetricEvent } from "../client.js"
18
+ import { runGated, type Completion } from "./loop.js"
18
19
  import { createToolkit, type Toolkit } from "../toolkit.js"
19
20
  import { defineTool } from "../native.js"
20
21
  import type { Answer, Request, Tool, ToolResult } from "../types.js"
@@ -98,6 +99,10 @@ export interface AgentDef {
98
99
  /** §8 observability sink for THIS agent's client. Set ⇒ REPLACES the runtime-wide
99
100
  * `onMetric` for this agent. Resolves independently of `hooks`. Forwarded verbatim. */
100
101
  onMetric?: (ev: MetricEvent) => void
102
+ /** §7D completion gate, projected from the agent spec. Present ⇒ this agent's
103
+ * turns run through the gate even when it is a DELEGATED child — which is what a
104
+ * host-side retry loop cannot do. */
105
+ completion?: Completion
101
106
  }
102
107
 
103
108
  /** An unsolicited inbox item. Delivered ONLY as part of a fresh turn's coalesced
@@ -768,7 +773,13 @@ export class AgentRuntime {
768
773
  hooks: h.def.hooks ?? this.opts.hooks,
769
774
  onMetric: h.def.onMetric ?? this.opts.onMetric,
770
775
  })
771
- const r = await client.ask(input, { toolkit, id: h.id, signal: h.abort!.signal })
776
+ // The gate runs HERE, inside the runtime turn, which is what makes it reach a
777
+ // delegated child: `task` executes the child through this same path.
778
+ const r = await runGated(
779
+ (p) => client.ask(p, { toolkit, id: h.id, signal: h.abort!.signal }),
780
+ input,
781
+ h.def.completion,
782
+ )
772
783
  h.turnsTotal += r.turns
773
784
  this.rollUp(h, r.usage.totalTokens, r.toolCallCount)
774
785
  if (r.status === "pending" && r.pending) {
@@ -787,7 +798,12 @@ export class AgentRuntime {
787
798
  h.state = "idle"
788
799
  h.drained = []
789
800
  this.t(`${h.id}: running→idle (incomplete: ${r.limit ?? "maxTurns"})`)
790
- result = { text: "hit maxTurns without a final answer", isError: true, status: "incomplete", turns: r.turns, totalTokens: r.usage.totalTokens }
801
+ // A completion stop carries its own reason; only a maxTurns stop gets the
802
+ // generic sentence. Reading `limit` is how the runtime tells them apart.
803
+ result = {
804
+ text: r.limit === "completion" ? r.text : "hit maxTurns without a final answer",
805
+ isError: true, status: "incomplete", turns: r.turns, totalTokens: r.usage.totalTokens,
806
+ }
791
807
  } else {
792
808
  h.state = "idle"
793
809
  h.drained = []
package/src/client.ts CHANGED
@@ -114,6 +114,28 @@ export class InMemoryConversationStore implements ConversationStore {
114
114
 
115
115
  const RETRYABLE = new Set([429, 500, 502, 503, 504])
116
116
 
117
+ /** ~68 years; the widest whole-second count all seven ports represent exactly. */
118
+ const RETRY_AFTER_MAX_SECONDS = 2147483647
119
+
120
+ /**
121
+ * Honour `Retry-After` only in its delay-seconds form: a run of ASCII digits
122
+ * (RFC 9110 §10.2.3) in 0…2147483647, returned in milliseconds.
123
+ *
124
+ * `Number()` is far too permissive for this — it accepts `"0.5"`, `"-5"`, `"1e3"`
125
+ * and `" "` (as 0), which is how this port came to wait for fractional seconds
126
+ * that five of the other six rejected outright. Fractional, signed, HTTP-date and
127
+ * out-of-range values are not delays we can honour, so the caller falls back to
128
+ * backoff rather than guessing. `0` is a real answer, so it returns `0`, not
129
+ * `null`, and callers must use `??` rather than `||`.
130
+ */
131
+ function retryAfterMs(raw: string | null | undefined): number | null {
132
+ if (raw == null) return null
133
+ const s = raw.trim()
134
+ if (!/^[0-9]+$/.test(s)) return null
135
+ const secs = Number(s)
136
+ return secs <= RETRY_AFTER_MAX_SECONDS ? secs * 1000 : null
137
+ }
138
+
117
139
  function delay(ms: number, signal: AbortSignal): Promise<void> {
118
140
  return new Promise((resolve, reject) => {
119
141
  const t = setTimeout(resolve, ms)
@@ -643,8 +665,9 @@ export class Client {
643
665
  const retryable = RETRYABLE.has(res.status)
644
666
  const tier = classify({ status: res.status, attempt, retryable })
645
667
  if (tier === "fail" || attempt === retries) return res // caller surfaces the non-ok status
646
- const ra = Number(res.headers.get("retry-after"))
647
- await delay(ra ? ra * 1000 : base * 2 ** attempt + Math.random() * 100, signal)
668
+ const ra = retryAfterMs(res.headers.get("retry-after"))
669
+ // `?? `, not `|| `: Retry-After: 0 means "retry now", not "no opinion".
670
+ await delay(ra ?? base * 2 ** attempt + Math.random() * 100, signal)
648
671
  } catch (e) {
649
672
  if (signal.aborted) throw e // abort/timeout: don't retry
650
673
  lastErr = e
@@ -1074,6 +1097,135 @@ export function createClient(opts: ClientOptions): Client {
1074
1097
  return new Client(opts)
1075
1098
  }
1076
1099
 
1100
+
1101
+ // --------------------------------------------------------------------------- //
1102
+ // In-process models (SPEC §8 Gap 2, semantic form).
1103
+ // --------------------------------------------------------------------------- //
1104
+
1105
+ /** One tool call an in-process model asks for. Flat on purpose: the nested
1106
+ * `function: {}` wrapper is a wire detail, not something a model author should type.
1107
+ * `arguments` may be a structured value (encoded for you) or an encoded string. */
1108
+ export interface InProcessToolCall {
1109
+ id?: string
1110
+ name: string
1111
+ arguments?: unknown
1112
+ }
1113
+
1114
+ /** What an in-process model is handed: the assembled request for THIS call. */
1115
+ export interface InProcessRequest {
1116
+ messages: any[]
1117
+ tools?: any[]
1118
+ model: string
1119
+ /** Every other key the client assembled, for a model that wants them. */
1120
+ body: Record<string, any>
1121
+ }
1122
+
1123
+ /** What an in-process model returns: exactly one assistant message. Return `content`
1124
+ * to finish, or `toolCalls` to ask for tools — never both. `usage` is optional; when
1125
+ * omitted the run reports zero tokens rather than failing. */
1126
+ export interface InProcessResponse {
1127
+ content?: string
1128
+ toolCalls?: InProcessToolCall[]
1129
+ usage?: { promptTokens?: number; completionTokens?: number; totalTokens?: number }
1130
+ }
1131
+
1132
+ /** Options for {@link createInProcessClient}: every client option EXCEPT the three
1133
+ * that describe a wire — `baseUrl`, `apiKey` and `style` — plus `generate`. */
1134
+ export type InProcessOptions = Omit<ClientOptions, "baseUrl" | "apiKey" | "style" | "fetch"> & {
1135
+ /** Your model. Receives the assembled request, returns one assistant message. */
1136
+ generate: (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>
1137
+ }
1138
+
1139
+ /** The sentinel base URL. Never dialled — the transport below answers every request
1140
+ * before the network is reached — but the client builds a URL string internally, so
1141
+ * it must be syntactically valid. `.invalid` is reserved by RFC 2606 precisely so a
1142
+ * name can never resolve. */
1143
+ const IN_PROCESS_BASE_URL = "http://in-process.invalid/v1"
1144
+
1145
+ /**
1146
+ * A client backed by a model running IN THIS PROCESS — no server, no socket, and no
1147
+ * HTTP types to construct.
1148
+ *
1149
+ * This is a second constructor, not a second seam: it builds on the same injectable
1150
+ * transport that `fetch`/`httpClient` uses, so the tool-calling loop, MCP servers,
1151
+ * skills, sub-agents, hooks, metrics and the completion gate all behave identically.
1152
+ *
1153
+ * Streaming is refused rather than faked — see the thrown error.
1154
+ */
1155
+ export function createInProcessClient(opts: InProcessOptions): Client {
1156
+ const { generate, ...rest } = opts
1157
+ if (typeof generate !== "function") {
1158
+ throw new Error("toolnexus: createInProcessClient requires a `generate` function")
1159
+ }
1160
+
1161
+ const inProcessFetch: typeof fetch = async (_url, init) => {
1162
+ const body = JSON.parse(String((init as any)?.body ?? "{}"))
1163
+
1164
+ // A generate() returns a whole answer, so a stream would be one chunk pretending
1165
+ // to be many. Content and delta-count assertions cannot tell that from a real
1166
+ // stream, so refuse instead of degrading silently.
1167
+ if (body.stream) {
1168
+ throw new Error(
1169
+ "toolnexus: createInProcessClient does not support streaming — `generate` returns a " +
1170
+ "complete answer. Use the non-streaming run, or supply a `fetch`/transport that streams.",
1171
+ )
1172
+ }
1173
+
1174
+ const answer = await generate({
1175
+ messages: body.messages ?? [],
1176
+ tools: body.tools,
1177
+ model: body.model,
1178
+ body,
1179
+ })
1180
+
1181
+ const message: Record<string, any> = { role: "assistant" }
1182
+ if (answer.toolCalls && answer.toolCalls.length > 0) {
1183
+ message.tool_calls = answer.toolCalls.map((c, i) => ({
1184
+ id: c.id ?? `call_${i}`,
1185
+ type: "function",
1186
+ function: {
1187
+ name: c.name,
1188
+ // A string is already encoded; anything else is encoded here so a model
1189
+ // author never has to think about the wire.
1190
+ arguments: typeof c.arguments === "string" ? c.arguments : JSON.stringify(c.arguments ?? {}),
1191
+ },
1192
+ }))
1193
+ } else {
1194
+ message.content = answer.content ?? ""
1195
+ }
1196
+
1197
+ const u = answer.usage ?? {}
1198
+ const prompt = u.promptTokens ?? 0
1199
+ const completion = u.completionTokens ?? 0
1200
+ return new Response(
1201
+ JSON.stringify({
1202
+ choices: [{ index: 0, message, finish_reason: message.tool_calls ? "tool_calls" : "stop" }],
1203
+ usage: {
1204
+ prompt_tokens: prompt,
1205
+ completion_tokens: completion,
1206
+ total_tokens: u.totalTokens ?? prompt + completion,
1207
+ },
1208
+ }),
1209
+ { status: 200, headers: { "content-type": "application/json" } },
1210
+ )
1211
+ }
1212
+
1213
+ return new Client({
1214
+ // Retries default to ZERO here, unlike a network client. There is no wire, so
1215
+ // there is no transient failure to ride out: whatever `generate` throws will
1216
+ // throw again. Retrying it only buys backoff before the user sees their own bug —
1217
+ // measured at 3.7s for the streaming refusal before this default. A host whose
1218
+ // model really is flaky (a GPU that OOMs, say) can still set `retries`.
1219
+ retries: 0,
1220
+ ...(rest as ClientOptions),
1221
+ baseUrl: IN_PROCESS_BASE_URL,
1222
+ style: "openai",
1223
+ // An in-process model has no endpoint to authenticate to, so the host must never need a key — but the client resolves one from the environment and fails when it finds none. A sentinel keeps that resolution from ever running. Caught by CI, which has no OPENROUTER_API_KEY; every local run passed because a developer shell has one.
1224
+ apiKey: "in-process",
1225
+ fetch: inProcessFetch,
1226
+ })
1227
+ }
1228
+
1077
1229
  /** Stateful multi-turn conversation: each send() continues the same transcript (memory). */
1078
1230
  export class Conversation {
1079
1231
  /** Full running transcript (system + user + assistant + tool messages). */