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 +42 -0
- package/dist/acp.js +246 -0
- package/dist/agents/agent.d.ts +12 -1
- package/dist/agents/agent.js +3 -2
- package/dist/agents/home.js +4 -2
- package/dist/agents/loop.d.ts +21 -0
- package/dist/agents/loop.js +38 -2
- package/dist/agents/runtime.d.ts +76 -6
- package/dist/agents/runtime.js +92 -22
- package/dist/builtin.d.ts +32 -2
- package/dist/builtin.js +342 -58
- package/dist/classifier.d.ts +23 -0
- package/dist/classifier.js +33 -25
- package/dist/client.d.ts +116 -16
- package/dist/client.js +197 -43
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/order.d.ts +46 -0
- package/dist/order.js +71 -0
- package/dist/skill.d.ts +13 -0
- package/dist/skill.js +110 -27
- package/dist/types.d.ts +11 -0
- package/dist/types.js +22 -0
- package/package.json +2 -2
- package/src/acp.ts +308 -0
- package/src/agents/agent.ts +15 -3
- package/src/agents/home.ts +4 -2
- package/src/agents/loop.ts +40 -2
- package/src/agents/runtime.ts +154 -29
- package/src/builtin.ts +379 -55
- package/src/classifier.ts +45 -27
- package/src/client.ts +220 -51
- package/src/index.ts +1 -0
- package/src/order.ts +70 -0
- package/src/skill.ts +121 -27
- package/src/types.ts +22 -0
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
|
+
}
|
package/dist/agents/agent.d.ts
CHANGED
|
@@ -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"];
|
package/dist/agents/agent.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/agents/home.js
CHANGED
|
@@ -188,9 +188,11 @@ export function startAgent(a, run, hb) {
|
|
|
188
188
|
});
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
|
-
|
|
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,
|
package/dist/agents/loop.d.ts
CHANGED
|
@@ -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;
|
package/dist/agents/loop.js
CHANGED
|
@@ -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
|
-
|
|
169
|
-
|
|
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;
|
package/dist/agents/runtime.d.ts
CHANGED
|
@@ -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
|
-
|
|
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).
|
|
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<
|
|
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:
|