toolnexus 0.18.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/acp.d.ts ADDED
@@ -0,0 +1,42 @@
1
+ import type { InProcessRequest, InProcessResponse } from "./client.js";
2
+ /** Options for {@link loadACP}. `command`/`args` spawn the ACP agent as a child
3
+ * process; everything else tunes the session handshake. */
4
+ export interface ACPOptions {
5
+ /** The ACP agent binary, e.g. `"devin"`. */
6
+ command: string;
7
+ /** Arguments to the agent, e.g. `["acp"]`. */
8
+ args?: string[];
9
+ /** Working directory for `session/new`. Real ACP agents (e.g. `devin acp`)
10
+ * reject a relative `cwd` with `-32602 Invalid params` — this is always
11
+ * resolved to an absolute path before it is sent. Default: `process.cwd()`. */
12
+ cwd?: string;
13
+ /** Extra environment variables for the child, merged over the current
14
+ * process environment. */
15
+ env?: Record<string, string>;
16
+ /** ACP `initialize` protocolVersion. Default `1`. */
17
+ protocolVersion?: number;
18
+ /** Safety-net timeout (ms) for one `session/prompt` turn — NOT specifically
19
+ * for the permission handshake, which this client always answers instantly.
20
+ * It exists only so a broken/unresponsive agent cannot hang a caller
21
+ * forever. Default 30000. */
22
+ permissionTimeoutMs?: number;
23
+ /** Optional `session/set_mode` sent once, right after `session/new`. */
24
+ mode?: string;
25
+ }
26
+ /** One warm ACP connection: one child process, one session, alive across many
27
+ * turns. `.generate` has exactly the shape {@link InProcessOptions.generate}
28
+ * wants, so it plugs straight into {@link createInProcessClient}. */
29
+ export interface ACPClient {
30
+ generate: (request: InProcessRequest) => Promise<InProcessResponse>;
31
+ /** Terminates the agent process. Idempotent — a second call is a no-op. */
32
+ close: () => Promise<void>;
33
+ }
34
+ /**
35
+ * Opens one ACP connection: spawns `command`, performs `initialize` →
36
+ * `session/new` (with an absolute `cwd` and an `mcpServers` array — the exact
37
+ * trap real `devin acp` enforces with `-32602` otherwise) → an optional
38
+ * `session/set_mode`, then returns a client whose `.generate` sends exactly
39
+ * one `session/prompt` per call, accumulating only `agent_message_chunk`
40
+ * text.
41
+ */
42
+ export declare function loadACP(opts: ACPOptions): Promise<ACPClient>;
package/dist/acp.js ADDED
@@ -0,0 +1,246 @@
1
+ /**
2
+ * ACP (Agent Client Protocol) model source — issue #96, ADR 0031
3
+ * ("ACP: the warm session is the feature, and it breaks the stateless request"),
4
+ * openspec/changes/add-acp-model-source.
5
+ *
6
+ * ACP is to *agents* what MCP is to *tools*: JSON-RPC 2.0, one object per line,
7
+ * over a child process's stdin/stdout (`devin acp`, Gemini CLI, Zed's agents all
8
+ * speak it). This file ships ACP as nothing more than a `generate` function —
9
+ * the exact shape {@link InProcessOptions.generate} already defines — so the
10
+ * tool-calling loop, skills, MCP tools, adapters and sub-agents are entirely
11
+ * untouched. `loadACP` opens the connection once (`initialize` → `session/new` →
12
+ * optional `session/set_mode`) and hands back an {@link ACPClient} whose
13
+ * `.generate` can be passed straight to {@link createInProcessClient}.
14
+ *
15
+ * The hard part is not the JSON-RPC plumbing — it is that an ACP session is
16
+ * *stateful* while every other toolnexus model source is stateless-by-default.
17
+ * toolnexus assembles a complete request every turn; sending the whole thing
18
+ * into a session that already remembers the transcript makes some agents answer
19
+ * a near-duplicate prompt from stale history (reproduced in `spikes/acp/`).
20
+ * The mitigation (kept as the default, not left to the caller) is an explicit
21
+ * "this supersedes everything earlier" marker appended to every rendered
22
+ * prompt — see {@link renderPrompt}.
23
+ *
24
+ * Three more traps this client exists to close, all proven in the spike:
25
+ * - `session/update` notifications interleave with JSON-RPC replies on the
26
+ * same stdout stream, so everything arriving is demultiplexed by id.
27
+ * - Only `agent_message_chunk` text may be accumulated into the reply —
28
+ * `agent_thought_chunk` and tool-call narration wrap prose around
29
+ * structured output and corrupt it if mixed in.
30
+ * - An unanswered `session/request_permission` hangs the turn FOREVER, even
31
+ * in an agent's bypass mode. The client answers with the first `allow`-kind
32
+ * option the instant the request arrives; `permissionTimeoutMs` is only a
33
+ * safety net for a broken agent that never asks and never replies at all.
34
+ */
35
+ import { spawn } from "node:child_process";
36
+ import { createInterface } from "node:readline";
37
+ import path from "node:path";
38
+ /** The literal marker every rendered prompt ends with, naming the current
39
+ * turn as the one to answer. Kept as a named constant (not just inlined)
40
+ * because the exact wording is shared, by convention, with the other
41
+ * language ports and the spike that proved it fixes the stale-answer bug. */
42
+ const SUPERSEDES_MARKER = "SUPERSEDES-ALL-PRIOR:";
43
+ function renderContentPart(part) {
44
+ if (typeof part === "string")
45
+ return part;
46
+ if (part == null)
47
+ return "";
48
+ if (typeof part === "object" && "text" in part && typeof part.text === "string")
49
+ return part.text;
50
+ return JSON.stringify(part);
51
+ }
52
+ /** Flattens `content` (a string, or an array of content parts) to plain text. */
53
+ function renderContent(content) {
54
+ if (typeof content === "string")
55
+ return content;
56
+ if (Array.isArray(content))
57
+ return content.map(renderContentPart).join(" ");
58
+ return renderContentPart(content);
59
+ }
60
+ /** Renders the FULL assembled request as one prompt string — role + content
61
+ * per message — then appends the supersedes marker naming the latest user
62
+ * turn, per ADR 0031's proposed (and spike-confirmed) default: full request
63
+ * every turn, not a delta, so this stays stateless like every other
64
+ * toolnexus model source and never risks a shadow transcript drifting from
65
+ * the caller's own conversation state. */
66
+ function renderPrompt(request) {
67
+ const messages = request.messages ?? [];
68
+ const lines = messages.map((m) => `${m?.role ?? "user"}: ${renderContent(m?.content)}`);
69
+ let latest = "";
70
+ for (let i = messages.length - 1; i >= 0; i--) {
71
+ if (messages[i]?.role === "user") {
72
+ latest = renderContent(messages[i].content);
73
+ break;
74
+ }
75
+ }
76
+ if (!latest && messages.length > 0)
77
+ latest = renderContent(messages[messages.length - 1]?.content);
78
+ lines.push(`${SUPERSEDES_MARKER} ${latest}`);
79
+ return lines.join("\n");
80
+ }
81
+ /**
82
+ * Opens one ACP connection: spawns `command`, performs `initialize` →
83
+ * `session/new` (with an absolute `cwd` and an `mcpServers` array — the exact
84
+ * trap real `devin acp` enforces with `-32602` otherwise) → an optional
85
+ * `session/set_mode`, then returns a client whose `.generate` sends exactly
86
+ * one `session/prompt` per call, accumulating only `agent_message_chunk`
87
+ * text.
88
+ */
89
+ export async function loadACP(opts) {
90
+ const cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd();
91
+ const permissionTimeoutMs = opts.permissionTimeoutMs ?? 30000;
92
+ const child = spawn(opts.command, opts.args ?? [], {
93
+ cwd,
94
+ env: opts.env ? { ...process.env, ...opts.env } : process.env,
95
+ stdio: ["pipe", "pipe", "ignore"],
96
+ });
97
+ let closed = false;
98
+ let nextId = 0;
99
+ const pending = new Map();
100
+ let updateSink = null;
101
+ const rl = createInterface({ input: child.stdout });
102
+ rl.on("line", (raw) => {
103
+ const line = raw.trim();
104
+ if (!line)
105
+ return;
106
+ let msg;
107
+ try {
108
+ msg = JSON.parse(line);
109
+ }
110
+ catch {
111
+ return;
112
+ }
113
+ handleLine(msg);
114
+ });
115
+ function write(msg) {
116
+ child.stdin.write(JSON.stringify(msg) + "\n");
117
+ }
118
+ function reply(id, result) {
119
+ write({ jsonrpc: "2.0", id, result });
120
+ }
121
+ function call(method, params) {
122
+ const id = `c-${++nextId}`;
123
+ return new Promise((resolve, reject) => {
124
+ pending.set(id, (msg) => {
125
+ if (msg.error)
126
+ reject(new Error(`toolnexus: ACP error ${msg.error.code}: ${msg.error.message}`));
127
+ else
128
+ resolve(msg.result);
129
+ });
130
+ write({ jsonrpc: "2.0", id, method, params });
131
+ });
132
+ }
133
+ /** Demultiplexes EVERYTHING arriving on stdout: replies to our requests
134
+ * (matched by JSON-RPC id), `session/update` notifications (interleaved
135
+ * with replies, routed to whichever turn is in flight), and
136
+ * server-initiated requests like `session/request_permission`. */
137
+ function handleLine(msg) {
138
+ if (msg.method === undefined && msg.id !== undefined) {
139
+ const cb = pending.get(String(msg.id));
140
+ if (cb) {
141
+ pending.delete(String(msg.id));
142
+ cb(msg);
143
+ }
144
+ return;
145
+ }
146
+ if (msg.method === "session/update") {
147
+ updateSink?.(msg.params);
148
+ return;
149
+ }
150
+ if (msg.method === "session/request_permission" && msg.id !== undefined) {
151
+ answerPermission(msg);
152
+ return;
153
+ }
154
+ // An unhandled server->client request still needs a reply or a
155
+ // strict agent may itself hang waiting on one.
156
+ if (msg.id !== undefined)
157
+ reply(msg.id, {});
158
+ }
159
+ /** Answers with the FIRST option whose `kind` starts with `allow` — an
160
+ * unanswered permission request hangs the turn forever (proven in
161
+ * spikes/acp). This runs the instant the request arrives, never waiting
162
+ * for anything else. */
163
+ function answerPermission(req) {
164
+ const options = req.params?.options ?? [];
165
+ const chosen = options.find((o) => typeof o.kind === "string" && o.kind.startsWith("allow"));
166
+ const result = chosen
167
+ ? { outcome: { outcome: "selected", optionId: chosen.optionId } }
168
+ : { outcome: { outcome: "cancelled" } };
169
+ reply(req.id, result);
170
+ }
171
+ await call("initialize", {
172
+ protocolVersion: opts.protocolVersion ?? 1,
173
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
174
+ });
175
+ const sessionResult = await call("session/new", { cwd, mcpServers: [] });
176
+ const sessionId = sessionResult?.sessionId;
177
+ if (opts.mode) {
178
+ await call("session/set_mode", { sessionId, modeId: opts.mode });
179
+ }
180
+ // Serialises turns: one ACP session is one conversation, so concurrent
181
+ // prompts must not interleave into it. Idiomatic promise-chaining queue —
182
+ // each generate() call attaches after whatever is already queued, and the
183
+ // queue itself never rejects (a failed turn does not poison later ones).
184
+ let turnQueue = Promise.resolve();
185
+ async function promptOnce(request) {
186
+ const text = renderPrompt(request);
187
+ const id = `c-${++nextId}`;
188
+ let content = "";
189
+ updateSink = (params) => {
190
+ const update = params?.update;
191
+ if (update?.sessionUpdate === "agent_message_chunk") {
192
+ content += update?.content?.text ?? "";
193
+ }
194
+ // agent_thought_chunk and tool_call/tool_call_update narration are
195
+ // deliberately dropped — mixing them into the reply corrupts
196
+ // structured output a caller expects to parse.
197
+ };
198
+ const replyPromise = new Promise((resolve) => pending.set(id, resolve));
199
+ write({ jsonrpc: "2.0", id, method: "session/prompt", params: { sessionId, prompt: [{ type: "text", text }] } });
200
+ let timer;
201
+ const timeout = new Promise((_, reject) => {
202
+ timer = setTimeout(() => reject(new Error(`toolnexus: ACP turn timed out after ${permissionTimeoutMs}ms`)), permissionTimeoutMs);
203
+ });
204
+ try {
205
+ const msg = await Promise.race([replyPromise, timeout]);
206
+ if (msg.error)
207
+ throw new Error(`toolnexus: ACP error ${msg.error.code}: ${msg.error.message}`);
208
+ return { content };
209
+ }
210
+ finally {
211
+ clearTimeout(timer);
212
+ pending.delete(id);
213
+ updateSink = null;
214
+ }
215
+ }
216
+ const generate = (request) => {
217
+ if (closed)
218
+ return Promise.reject(new Error("toolnexus: ACP client is closed"));
219
+ const turn = turnQueue.then(() => promptOnce(request));
220
+ turnQueue = turn.then(() => undefined, () => undefined);
221
+ return turn;
222
+ };
223
+ const close = () => {
224
+ if (closed)
225
+ return Promise.resolve();
226
+ closed = true;
227
+ rl.close();
228
+ return new Promise((resolve) => {
229
+ if (child.exitCode !== null || child.signalCode !== null) {
230
+ resolve();
231
+ return;
232
+ }
233
+ const done = () => resolve();
234
+ child.once("exit", done);
235
+ child.kill();
236
+ // Belt-and-braces: resolve even if the child never emits `exit` (e.g.
237
+ // already reaped), so close() itself never hangs.
238
+ setTimeout(done, 1000);
239
+ });
240
+ };
241
+ const client = { generate, close };
242
+ // Non-enumerable, undocumented — internal test hook only, so the public
243
+ // ACPClient surface stays exactly {generate, close}.
244
+ Object.defineProperty(client, "_pid", { value: child.pid, enumerable: false });
245
+ return client;
246
+ }
@@ -31,7 +31,18 @@ export interface AgentSpec {
31
31
  budget?: Budget;
32
32
  /** Model id; default "inherit" (the runtime's `llm.model`). */
33
33
  model?: string;
34
- /** §10 interpreter authority for this agent's subtree. */
34
+ /** §10 interpreter authority for this agent's subtree.
35
+ *
36
+ * IDEMPOTENCY CONTRACT (SPEC §7D rewind-to-checkpoint). A durable resume REPLAYS the suspended
37
+ * turn from its PRE-TURN checkpoint: "continues from its checkpoint" means the transcript is
38
+ * REWOUND, not carried. EVERY tool that ran in that turn RUNS AGAIN, and the suspended tool
39
+ * itself is always re-executed (with `ctx.answer`) — that is §10's resolution mechanism, not a
40
+ * bug. Reattachment by task key makes a re-run `task` call idempotent; a leaf agent's own
41
+ * side-effecting tools (`git push`, a charge, a delete) have NO equivalent and are the host's
42
+ * responsibility. Any tool reachable in a suspendable turn must be idempotent.
43
+ * Replaying the leaf's stored transcript instead is DEFERRED to its own change; it would not
44
+ * remove this requirement anyway.
45
+ */
35
46
  waitFor?: (request: Request) => Promise<Answer>;
36
47
  onSpawn?: AgentDef["onSpawn"];
37
48
  onClose?: AgentDef["onClose"];
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import { defineTool } from "../native.js";
3
- import { AgentRuntime, isVerbError } from "./runtime.js";
3
+ import { AgentRuntime, isVerbError, S } from "./runtime.js";
4
4
  import { Loop, guardedHooks } from "./loop.js";
5
5
  export class Agent {
6
6
  name;
@@ -61,7 +61,8 @@ export class Agent {
61
61
  const runtime = this.createRuntime(opts);
62
62
  const h = runtime.spawn(runtime.root, this.name);
63
63
  if (isVerbError(h)) {
64
- return { text: h.error, isError: true, status: "error", turns: 0, totalTokens: 0, runtime };
64
+ // A spawn refusal is an ERROR, not a limit stop: it names no `limit` (A18).
65
+ return { text: h.error, isError: true, status: S.error, turns: 0, totalTokens: 0, ownTokens: 0, runtime };
65
66
  }
66
67
  const woke = runtime.wake(h, prompt);
67
68
  if (!woke.ok) {
@@ -188,9 +188,11 @@ export function startAgent(a, run, hb) {
188
188
  });
189
189
  }
190
190
  }
191
- cancel = runtime.clock.setTimeout(beat, hb.everyMs); // self-reschedule → one live timer
191
+ // keepAlive:false — a heartbeat is housekeeping and must never be the reason
192
+ // a host process stays up.
193
+ cancel = runtime.clock.setTimeout(beat, hb.everyMs, { keepAlive: false }); // self-reschedule → one live timer
192
194
  };
193
- cancel = runtime.clock.setTimeout(beat, hb.everyMs);
195
+ cancel = runtime.clock.setTimeout(beat, hb.everyMs, { keepAlive: false });
194
196
  return {
195
197
  runtime,
196
198
  beats,
@@ -79,6 +79,27 @@ export declare function allTodosDone(result: RunResult): Verdict;
79
79
  * it owes an Answer or a fix.
80
80
  */
81
81
  export declare function runGated(ask: (prompt: PromptInput) => Promise<RunResult>, prompt: PromptInput, completion: Completion | undefined): Promise<RunResult>;
82
+ /**
83
+ * The spec fields a Loop drive CANNOT honour, named rather than dropped silently.
84
+ *
85
+ * A `Loop` is one client run: it has no runtime, so it has no handle tree to spawn a `team`
86
+ * into, no §10 suspension ledger for `waitFor`, and no per-agent metric sink routing. Those
87
+ * fields are honoured by `Agent.run` / `AgentRuntime` only. `uses.tools` is not honoured either
88
+ * — a Loop is handed its toolkit by the caller.
89
+ *
90
+ * Returns a FIXED CANONICAL vocabulary — `"tools"`, `"team"`, `"waitFor"`, `"onMetric"` —
91
+ * identical in all seven ports, exactly as the `limit` strings are, and NOT this language's own
92
+ * spelling of the field. Empty ⇒ nothing is lost.
93
+ * Additive and advisory: a Loop never refuses to run because of it (that would be breaking).
94
+ */
95
+ export declare function loopUnsupported(spec: {
96
+ uses?: {
97
+ tools?: unknown[];
98
+ };
99
+ team?: unknown[];
100
+ waitFor?: unknown;
101
+ onMetric?: unknown;
102
+ }): string[];
82
103
  /** A live execution of an Agent. Its only verbs are `run` and reading state. */
83
104
  export declare class Loop {
84
105
  #private;
@@ -109,6 +109,31 @@ export async function runGated(ask, prompt, completion) {
109
109
  text: `completion.verify failed ${maxAttempts}×: ${reason}`,
110
110
  };
111
111
  }
112
+ /**
113
+ * The spec fields a Loop drive CANNOT honour, named rather than dropped silently.
114
+ *
115
+ * A `Loop` is one client run: it has no runtime, so it has no handle tree to spawn a `team`
116
+ * into, no §10 suspension ledger for `waitFor`, and no per-agent metric sink routing. Those
117
+ * fields are honoured by `Agent.run` / `AgentRuntime` only. `uses.tools` is not honoured either
118
+ * — a Loop is handed its toolkit by the caller.
119
+ *
120
+ * Returns a FIXED CANONICAL vocabulary — `"tools"`, `"team"`, `"waitFor"`, `"onMetric"` —
121
+ * identical in all seven ports, exactly as the `limit` strings are, and NOT this language's own
122
+ * spelling of the field. Empty ⇒ nothing is lost.
123
+ * Additive and advisory: a Loop never refuses to run because of it (that would be breaking).
124
+ */
125
+ export function loopUnsupported(spec) {
126
+ const out = [];
127
+ if (spec.uses?.tools && spec.uses.tools.length > 0)
128
+ out.push("tools");
129
+ if (spec.team && spec.team.length > 0)
130
+ out.push("team");
131
+ if (spec.waitFor)
132
+ out.push("waitFor");
133
+ if (spec.onMetric)
134
+ out.push("onMetric");
135
+ return out;
136
+ }
112
137
  /** A live execution of an Agent. Its only verbs are `run` and reading state. */
113
138
  export class Loop {
114
139
  agent;
@@ -163,10 +188,21 @@ export class Loop {
163
188
  /** Applies a per-call model override via `requestParams` (`model` is not in the
164
189
  * forbidden set — the client forbids only messages/tools/stream). */
165
190
  #clientOptions(opts) {
191
+ const spec = this.agent.spec;
166
192
  const base = {
167
193
  ...this.options,
168
- systemPrompt: this.agent.spec.soul ?? this.options.systemPrompt,
169
- hooks: guardedHooks(this.agent.spec.guardrails, this.agent.spec.hooks ?? this.options.hooks),
194
+ // CALLER-WINS (ADR 0024 / issue #87). A `systemPrompt` the caller put on the client
195
+ // options is an explicit instruction for THIS drive; the soul is the agent's default.
196
+ // js used to let the soul override it, which python and csharp never did.
197
+ systemPrompt: this.options.systemPrompt ?? spec.soul,
198
+ // The spec's model and turn ceiling become Loop DEFAULTS — the same spec drives both
199
+ // doors. `ClientOptions.model` is REQUIRED in js, so "the caller did not choose" is
200
+ // spelled the way the runtime already spells it: absent, or the `"inherit"` sentinel.
201
+ model: !this.options.model || this.options.model === "inherit"
202
+ ? (spec.model && spec.model !== "inherit" ? spec.model : this.options.model)
203
+ : this.options.model,
204
+ maxTurns: this.options.maxTurns ?? spec.budget?.maxTurns,
205
+ hooks: guardedHooks(spec.guardrails, spec.hooks ?? this.options.hooks),
170
206
  };
171
207
  if (!opts.model)
172
208
  return base;
@@ -15,17 +15,26 @@
15
15
  * identical per-handle transition traces against `examples/subagent-*` fixtures.
16
16
  */
17
17
  import type { PromptInput } from "../content.js";
18
- import { type ClientStyle, type ConversationStore, type Hooks, type MetricEvent } from "../client.js";
18
+ import { type ClientStyle, type ConversationStore, type Hooks, type InProcessRequest, type InProcessResponse, type MetricEvent } from "../client.js";
19
19
  import { type Completion } from "./loop.js";
20
20
  import type { Answer, Request, Tool } from "../types.js";
21
21
  /** Injectable time source. All runtime timers, timeouts, and deadlines use it. */
22
22
  export interface Clock {
23
23
  /** Current time in ms (epoch or virtual). */
24
24
  now(): number;
25
- /** Schedule `fn` after `ms`; returns a cancel function. */
26
- setTimeout(fn: () => void, ms: number): () => void;
25
+ /** Schedule `fn` after `ms`; returns a cancel function.
26
+ *
27
+ * `keepAlive` defaults to TRUE, and the default is the important half: a timer
28
+ * that cannot hold the event loop open never fires when it is the only thing
29
+ * left to do — which is precisely a deadline whose promise nothing else will
30
+ * settle. Background housekeeping that should never hold a process open (a
31
+ * heartbeat) passes `false` explicitly. A host's own Clock may ignore the
32
+ * option; it then behaves as that host's timers always have. */
33
+ setTimeout(fn: () => void, ms: number, opts?: {
34
+ keepAlive?: boolean;
35
+ }): () => void;
27
36
  }
28
- /** The real clock (default). Timers are unref'd so they never hold the process open. */
37
+ /** The real clock (default). See `Clock.setTimeout` for why `keepAlive` defaults to true. */
29
38
  export declare const systemClock: Clock;
30
39
  /** §7D hierarchical budget. Carved at spawn (`min(own, parent remaining)`) and
31
40
  * live-enforced against the whole ancestor chain before each turn and spawn.
@@ -93,6 +102,42 @@ export interface InboxItem {
93
102
  text: string;
94
103
  }
95
104
  export type HandleState = "idle" | "running" | "suspended" | "closed";
105
+ /**
106
+ * The §7D AGENT status vocabulary, as a value. It is a DIFFERENT, larger closed set than the
107
+ * §8 CLIENT vocabulary (`RUN_STATUSES` = done/pending/incomplete): only this one contains
108
+ * `"timeout"`, and `"timeout"` here means a `wait()` DEADLINE expired while the child keeps
109
+ * running — never that an LLM call timed out. Two fields are called `status`; they are not
110
+ * interchangeable (ADR 0027 D2.1).
111
+ */
112
+ export declare const TASK_STATUSES: readonly ["done", "pending", "incomplete", "interrupted", "closed", "timeout", "error"];
113
+ /**
114
+ * The CLOSED `limit` vocabulary (addendum A14). A value NAMES THE BUDGET FIELD that stopped the
115
+ * run, spelled exactly as `Budget` spells it, plus the two non-budget stops `completion` and
116
+ * `timeout`. Identical in all seven ports, like the `loopUnsupported` strings — "which limit
117
+ * stopped me" is only answerable if the answer is the same word everywhere.
118
+ *
119
+ * A port's INTERNAL pool/dimension name is an implementation detail and must never leak into
120
+ * the field; `canonicalLimit` is the boundary that enforces that here.
121
+ */
122
+ export declare const TASK_LIMITS: readonly ["maxTurns", "maxTokens", "maxToolCalls", "maxWallMs", "maxChildren", "maxConcurrent", "maxDepth", "completion", "timeout"];
123
+ export type TaskLimit = (typeof TASK_LIMITS)[number];
124
+ /** Map anything the internals produce onto the closed vocabulary; unknown ⇒ undefined, never a
125
+ * leaked internal name. */
126
+ export declare function canonicalLimit(limit: string | undefined): TaskLimit | undefined;
127
+ /**
128
+ * NAMED CONSTANTS for the two closed vocabularies. Every `TaskResult` construction site in this
129
+ * file spells its status and limit through these, never as a string literal, so a rename cannot
130
+ * silently desync the two fields — which is exactly how `status:"timeout"` came to ship with an
131
+ * empty `limit` in three of the seven ports (addendum A18).
132
+ */
133
+ export declare const S: Readonly<Record<TaskStatus, TaskStatus>>;
134
+ export declare const L: Readonly<Record<TaskLimit, TaskLimit>>;
135
+ /**
136
+ * THE INVARIANT `S`/`L` exist to protect (addendum A18): a LIMIT stop (`incomplete`, `timeout`)
137
+ * names its limit; every other status carries none. It is asserted by the suite, not exported —
138
+ * a predicate that exists so our own tests can state a rule is not public API (A19b, ADR 0019).
139
+ * Ports copy the PREDICATE, not an export.
140
+ */
96
141
  export type TaskStatus = "done" | "pending" | "incomplete" | "interrupted" | "closed" | "timeout" | "error";
97
142
  /** The result of one completed (or halted) turn, as seen by waiters and `task`. */
98
143
  export interface TaskResult {
@@ -102,8 +147,23 @@ export interface TaskResult {
102
147
  /** Present iff status === "pending". `data.path` carries the suspended handle's
103
148
  * id path (§10 agent-escalation addendum) — never a field grafted on Request. */
104
149
  pending?: Request;
150
+ /** Cumulative LLM round trips for this handle's SUBTREE — never per-run, never reset. */
105
151
  turns: number;
152
+ /**
153
+ * Cumulative TREE total: this handle's own spend plus every descendant's, on EVERY status
154
+ * (ADR 0025 D1). It was already the tree total on four of the seven status branches; this
155
+ * gives the field one meaning. `parent.totalTokens >= child.totalTokens` always holds.
156
+ */
106
157
  totalTokens: number;
158
+ /** This handle's OWN accumulated spend, excluding children. */
159
+ ownTokens: number;
160
+ /**
161
+ * WHICH limit stopped the run, as a value to branch on rather than prose to match. Drawn from
162
+ * the CLOSED, cross-port `TASK_LIMITS` vocabulary (addendum A14) — it names the `Budget` field,
163
+ * or `completion` / `timeout`. Absent otherwise. A stop you cannot explain to a user is a bug,
164
+ * not a state.
165
+ */
166
+ limit?: TaskLimit;
107
167
  }
108
168
  /** Uniform verb failure — returned, never thrown (only the root throws to the host). */
109
169
  export interface VerbError {
@@ -164,6 +224,8 @@ export declare class Handle {
164
224
  readonly deadline?: number;
165
225
  /** Total tokens attributed to this handle (own turns + descendant roll-up). */
166
226
  usageTotal: number;
227
+ /** Tokens spent by THIS handle's own turns, excluding descendants (ADR 0025 D1). */
228
+ ownTokens: number;
167
229
  /** Total tool calls attributed to this handle (own + descendant roll-up). */
168
230
  toolCallsTotal: number;
169
231
  /** LLM round trips run by this handle itself (never reset — resume grows it). */
@@ -202,8 +264,16 @@ export interface RuntimeOptions {
202
264
  apiKey?: string;
203
265
  model?: string;
204
266
  };
205
- /** HTTP transport for LLM requests (§8 Gap 2) — the hermetic-test seam. */
267
+ /** HTTP transport for LLM requests (§8 Gap 2) — the hermetic-test seam.
268
+ * Mutually exclusive with `inProcess` — `new AgentRuntime(...)` throws if both are set. */
206
269
  fetch?: typeof fetch;
270
+ /** A model running IN THIS PROCESS (ADR 0030): the semantic counterpart to
271
+ * `fetch`, so a host whose model is a plain function never has to build its own
272
+ * `fetch`-shaped adapter. Internally this is turned into a `createInProcessFetch`
273
+ * adapter and wrapped by the SAME global turn gate as `fetch` — there is no second
274
+ * code path. Mutually exclusive with `fetch` and with `llm` — `new AgentRuntime(...)`
275
+ * throws if more than one of `fetch`/`inProcess` is set. */
276
+ inProcess?: (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>;
207
277
  /** Time source for every timer/timeout/deadline. Default: the system clock. */
208
278
  clock?: Clock;
209
279
  /** The ONE ConversationStore for all handles (conversation id = handle id).
@@ -315,7 +385,7 @@ export declare class AgentRuntime {
315
385
  * REATTACHES to the existing child by task key — never spawns a duplicate.
316
386
  * The root alone may throw to the host.
317
387
  */
318
- resume(answer: Answer): Promise<void>;
388
+ resume(answer: Answer): Promise<TaskResult>;
319
389
  private deepestSuspended;
320
390
  /**
321
391
  * Resume a suspended handle by REPLAYING its halted turn from the checkpoint: