toolnexus 0.14.0 → 0.15.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.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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolnexus",
3
- "version": "0.14.0",
3
+ "version": "0.15.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",
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