toolnexus 0.9.4 → 0.10.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/README.md +31 -1
- package/dist/agents/agent.d.ts +60 -0
- package/dist/agents/agent.js +99 -0
- package/dist/agents/compaction.d.ts +77 -0
- package/dist/agents/compaction.js +96 -0
- package/dist/agents/home.d.ts +90 -0
- package/dist/agents/home.js +203 -0
- package/dist/agents/index.d.ts +10 -0
- package/dist/agents/index.js +10 -0
- package/dist/agents/runtime.d.ts +357 -0
- package/dist/agents/runtime.js +733 -0
- package/dist/client.d.ts +28 -4
- package/dist/client.js +26 -12
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/toolkit.d.ts +7 -0
- package/dist/toolkit.js +13 -3
- package/package.json +2 -2
- package/src/agents/agent.ts +129 -0
- package/src/agents/compaction.ts +148 -0
- package/src/agents/home.ts +250 -0
- package/src/agents/index.ts +10 -0
- package/src/agents/runtime.ts +890 -0
- package/src/client.ts +50 -14
- package/src/index.ts +1 -0
- package/src/toolkit.ts +19 -2
package/dist/client.d.ts
CHANGED
|
@@ -44,6 +44,24 @@ export interface ClientOptions {
|
|
|
44
44
|
/** §8 Gap 2. HTTP transport for LLM requests (retries included). Omit ⇒ global fetch. Scope is the
|
|
45
45
|
* LLM path only; MCP transports use the SDK's own client. */
|
|
46
46
|
fetch?: typeof fetch;
|
|
47
|
+
/** §8 Resilience. Classify each LLM-call failure into "retry" or "fail". Omit ⇒ the default
|
|
48
|
+
* classifier (retryable status/network within budget ⇒ retry, else fail) — byte-identical to
|
|
49
|
+
* today. A "retry" is always bounded by `retries`; the classifier cannot loop unbounded.
|
|
50
|
+
* There is no "suspend" tier — suspension (§10) stays a user-action pause, not an error path. */
|
|
51
|
+
onError?: (info: ErrorInfo) => ErrorTier;
|
|
52
|
+
}
|
|
53
|
+
/** §8 Resilience. What to do with a failed LLM call. */
|
|
54
|
+
export type ErrorTier = "retry" | "fail";
|
|
55
|
+
/** §8 Resilience. Context passed to `onError` for each failed LLM attempt. */
|
|
56
|
+
export interface ErrorInfo {
|
|
57
|
+
/** The thrown network/transport error, when the failure was not an HTTP response. */
|
|
58
|
+
error?: unknown;
|
|
59
|
+
/** The HTTP status, when the failure was a non-ok response. */
|
|
60
|
+
status?: number;
|
|
61
|
+
/** Zero-based attempt index (0 = first try). */
|
|
62
|
+
attempt: number;
|
|
63
|
+
/** Whether `status`/the error is in the default retryable set (429/5xx/network). */
|
|
64
|
+
retryable: boolean;
|
|
47
65
|
}
|
|
48
66
|
/**
|
|
49
67
|
* Semantic observability events (§8). NOT counter/histogram primitives — readable records the
|
|
@@ -166,10 +184,14 @@ export interface RunResult {
|
|
|
166
184
|
usage: Usage;
|
|
167
185
|
/** The model used. */
|
|
168
186
|
model: string;
|
|
169
|
-
/** "done" normally; "pending" iff a tool suspended and no waitFor was configured (§10)
|
|
170
|
-
|
|
187
|
+
/** "done" normally; "pending" iff a tool suspended and no waitFor was configured (§10);
|
|
188
|
+
* "incomplete" iff a limit stopped the run loudly (§7D) — a maxTurns stop with no final
|
|
189
|
+
* text is never a silent "done". */
|
|
190
|
+
status: "done" | "pending" | "incomplete";
|
|
171
191
|
/** The unresolved suspension — present iff status === "pending" (§10). */
|
|
172
192
|
pending?: Request;
|
|
193
|
+
/** The limit that stopped the run (e.g. "maxTurns") — present iff status === "incomplete". */
|
|
194
|
+
limit?: string;
|
|
173
195
|
}
|
|
174
196
|
/** Events yielded by client.stream(). */
|
|
175
197
|
export type StreamEvent = {
|
|
@@ -224,7 +246,8 @@ export declare class Client {
|
|
|
224
246
|
metrics(): string;
|
|
225
247
|
/** Feed a semantic metric event to the built-in registry and the optional onMetric sink. */
|
|
226
248
|
private emit;
|
|
227
|
-
/** Emit the terminal `run` metric event and build the RunResult.
|
|
249
|
+
/** Emit the terminal `run` metric event and build the RunResult. `exhausted` marks a
|
|
250
|
+
* maxTurns loop exit — with no final text that is a LOUD "incomplete", never "done" (§7D). */
|
|
228
251
|
private endRun;
|
|
229
252
|
/** Emit a `run` error metric event (once, on a thrown run). */
|
|
230
253
|
private emitRunError;
|
|
@@ -263,7 +286,8 @@ export declare class Client {
|
|
|
263
286
|
private system;
|
|
264
287
|
/** Build a run-scoped abort signal from the optional run-level timeout + an external signal. */
|
|
265
288
|
private makeSignal;
|
|
266
|
-
/** fetch with retry + exponential backoff on 429/5xx/network, honoring Retry-After; aborts via signal.
|
|
289
|
+
/** fetch with retry + exponential backoff on 429/5xx/network, honoring Retry-After; aborts via signal.
|
|
290
|
+
* The retry-vs-fail decision is the host's `onError` (default: retryable-within-budget ⇒ retry). */
|
|
267
291
|
private llmFetch;
|
|
268
292
|
/** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
|
|
269
293
|
private runTool;
|
package/dist/client.js
CHANGED
|
@@ -213,10 +213,11 @@ export class Client {
|
|
|
213
213
|
this.registry.record(ev);
|
|
214
214
|
this.opts.onMetric?.(ev);
|
|
215
215
|
}
|
|
216
|
-
/** Emit the terminal `run` metric event and build the RunResult.
|
|
217
|
-
|
|
216
|
+
/** Emit the terminal `run` metric event and build the RunResult. `exhausted` marks a
|
|
217
|
+
* maxTurns loop exit — with no final text that is a LOUD "incomplete", never "done" (§7D). */
|
|
218
|
+
endRun(runStart, text, messages, toolCalls, turns, usage, exhausted = false) {
|
|
218
219
|
this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart });
|
|
219
|
-
return this.result(text, messages, toolCalls, turns, usage);
|
|
220
|
+
return this.result(text, messages, toolCalls, turns, usage, exhausted);
|
|
220
221
|
}
|
|
221
222
|
/** Emit a `run` error metric event (once, on a thrown run). */
|
|
222
223
|
emitRunError(runStart, toolCalls, turns, usage, e) {
|
|
@@ -291,16 +292,24 @@ export class Client {
|
|
|
291
292
|
}
|
|
292
293
|
return ctrl.signal;
|
|
293
294
|
}
|
|
294
|
-
/** fetch with retry + exponential backoff on 429/5xx/network, honoring Retry-After; aborts via signal.
|
|
295
|
+
/** fetch with retry + exponential backoff on 429/5xx/network, honoring Retry-After; aborts via signal.
|
|
296
|
+
* The retry-vs-fail decision is the host's `onError` (default: retryable-within-budget ⇒ retry). */
|
|
295
297
|
async llmFetch(url, init, signal) {
|
|
296
298
|
const retries = this.opts.retries ?? 2;
|
|
297
299
|
const base = this.opts.retryBaseMs ?? 500;
|
|
300
|
+
// Default classifier = today's behavior, expressed as an onError. A "retry" is always
|
|
301
|
+
// capped by the budget below (attempt === retries stops regardless of the classifier).
|
|
302
|
+
const classify = this.opts.onError ?? ((info) => (info.retryable ? "retry" : "fail"));
|
|
298
303
|
let lastErr;
|
|
299
304
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
300
305
|
try {
|
|
301
306
|
const res = await (this.opts.fetch ?? fetch)(url, { ...init, signal });
|
|
302
|
-
if (res.ok
|
|
307
|
+
if (res.ok)
|
|
303
308
|
return res;
|
|
309
|
+
const retryable = RETRYABLE.has(res.status);
|
|
310
|
+
const tier = classify({ status: res.status, attempt, retryable });
|
|
311
|
+
if (tier === "fail" || attempt === retries)
|
|
312
|
+
return res; // caller surfaces the non-ok status
|
|
304
313
|
const ra = Number(res.headers.get("retry-after"));
|
|
305
314
|
await delay(ra ? ra * 1000 : base * 2 ** attempt + Math.random() * 100, signal);
|
|
306
315
|
}
|
|
@@ -308,7 +317,8 @@ export class Client {
|
|
|
308
317
|
if (signal.aborted)
|
|
309
318
|
throw e; // abort/timeout: don't retry
|
|
310
319
|
lastErr = e;
|
|
311
|
-
|
|
320
|
+
const tier = classify({ error: e, attempt, retryable: true });
|
|
321
|
+
if (tier === "fail" || attempt === retries)
|
|
312
322
|
throw e;
|
|
313
323
|
await delay(base * 2 ** attempt + Math.random() * 100, signal);
|
|
314
324
|
}
|
|
@@ -427,15 +437,19 @@ export class Client {
|
|
|
427
437
|
return this.pendingRun(runStart, s.halted, messages, toolCalls, turns, usage);
|
|
428
438
|
}
|
|
429
439
|
}
|
|
430
|
-
return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage);
|
|
440
|
+
return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage, true);
|
|
431
441
|
}
|
|
432
442
|
catch (e) {
|
|
433
443
|
this.emitRunError(runStart, toolCalls, turns, usage, e);
|
|
434
444
|
throw e;
|
|
435
445
|
}
|
|
436
446
|
}
|
|
437
|
-
result(text, messages, toolCalls, turns, usage) {
|
|
438
|
-
|
|
447
|
+
result(text, messages, toolCalls, turns, usage, exhausted = false) {
|
|
448
|
+
const incomplete = exhausted && text === "";
|
|
449
|
+
const r = { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: incomplete ? "incomplete" : "done" };
|
|
450
|
+
if (incomplete)
|
|
451
|
+
r.limit = "maxTurns";
|
|
452
|
+
return r;
|
|
439
453
|
}
|
|
440
454
|
/** §10: a run halted because a tool suspended and no `waitFor` was configured. */
|
|
441
455
|
pendingRun(runStart, request, messages, toolCalls, turns, usage) {
|
|
@@ -536,7 +550,7 @@ export class Client {
|
|
|
536
550
|
if (halted)
|
|
537
551
|
return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage);
|
|
538
552
|
}
|
|
539
|
-
return this.endRun(runStart, "", messages, toolCalls, turns, usage);
|
|
553
|
+
return this.endRun(runStart, "", messages, toolCalls, turns, usage, true);
|
|
540
554
|
}
|
|
541
555
|
catch (e) {
|
|
542
556
|
this.emitRunError(runStart, toolCalls, turns, usage, e);
|
|
@@ -646,7 +660,7 @@ export class Client {
|
|
|
646
660
|
yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError };
|
|
647
661
|
}
|
|
648
662
|
}
|
|
649
|
-
yield { type: "done", result: this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage) };
|
|
663
|
+
yield { type: "done", result: this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage, true) };
|
|
650
664
|
}
|
|
651
665
|
catch (e) {
|
|
652
666
|
this.emitRunError(runStart, toolCalls, turns, usage, e);
|
|
@@ -755,7 +769,7 @@ export class Client {
|
|
|
755
769
|
}
|
|
756
770
|
messages.push({ role: "user", content: results });
|
|
757
771
|
}
|
|
758
|
-
yield { type: "done", result: this.endRun(runStart, "", messages, toolCalls, turns, usage) };
|
|
772
|
+
yield { type: "done", result: this.endRun(runStart, "", messages, toolCalls, turns, usage, true) };
|
|
759
773
|
}
|
|
760
774
|
catch (e) {
|
|
761
775
|
this.emitRunError(runStart, toolCalls, turns, usage, e);
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/toolkit.d.ts
CHANGED
|
@@ -18,6 +18,13 @@ export interface ToolkitOptions {
|
|
|
18
18
|
skillProvider?: () => SkillDef[] | Promise<SkillDef[]>;
|
|
19
19
|
/** Per-agent skill allowlist keyed on name; same semantics as the MCP tools filter (§3, S2). */
|
|
20
20
|
skillsFilter?: Record<string, boolean>;
|
|
21
|
+
/** Drop tools by their FINAL exposed name across every source (MCP `server_tool`, builtins,
|
|
22
|
+
* native, a2a, extras), applied after aggregation — the simple "just turn these off" list.
|
|
23
|
+
* Empty/undefined ⇒ nothing disabled. Composes with builtins/skillsFilter/server allowlists. */
|
|
24
|
+
disableTools?: string[];
|
|
25
|
+
/** Drop skills by name from the `skill` catalog — sugar over a drop-list in `skillsFilter`
|
|
26
|
+
* (each name folded in as `false`, without overriding an explicit filter entry). */
|
|
27
|
+
disableSkills?: string[];
|
|
21
28
|
/** Sibling-file sample cap: 0 ⇒ default 10, n>0 ⇒ cap, -1 ⇒ omit <skill_files> (§3, S5). */
|
|
22
29
|
skillSampleLimit?: number;
|
|
23
30
|
extraTools?: Tool[];
|
package/dist/toolkit.js
CHANGED
|
@@ -14,7 +14,9 @@ export class Toolkit {
|
|
|
14
14
|
/** A2A profile read from a top-level `a2a` config block (serve() falls back to this). */
|
|
15
15
|
a2aConfig,
|
|
16
16
|
/** MCP serve profile read from a top-level `mcpServer` config block. */
|
|
17
|
-
mcpServerConfig
|
|
17
|
+
mcpServerConfig,
|
|
18
|
+
/** Final-name drop-list (§4A) applied across every source after aggregation. */
|
|
19
|
+
disableTools = []) {
|
|
18
20
|
this.mcp = mcp;
|
|
19
21
|
this.skill = skill;
|
|
20
22
|
this.a2aConfig = a2aConfig;
|
|
@@ -24,8 +26,11 @@ export class Toolkit {
|
|
|
24
26
|
// front, then apply the normal first-wins dedupe for MCP/skill/agents/extras.
|
|
25
27
|
const extraNames = new Set(extraTools.map((t) => t.name));
|
|
26
28
|
const activeBuiltins = builtins.filter((b) => !extraNames.has(b.name));
|
|
29
|
+
const disabled = new Set(disableTools);
|
|
27
30
|
const all = [...(mcp?.tools ?? []), ...(skill ? [skill.tool] : []), ...activeBuiltins, ...agents, ...extraTools];
|
|
28
31
|
for (const t of all) {
|
|
32
|
+
if (disabled.has(t.name))
|
|
33
|
+
continue; // §4A drop-list by final exposed name
|
|
29
34
|
if (this.byName.has(t.name)) {
|
|
30
35
|
console.warn(`[toolnexus] duplicate tool name "${t.name}" — keeping first`);
|
|
31
36
|
continue;
|
|
@@ -51,10 +56,15 @@ export class Toolkit {
|
|
|
51
56
|
}
|
|
52
57
|
}
|
|
53
58
|
const dataDefs = [...(opts.skills ?? []), ...providerDefs];
|
|
59
|
+
// disableSkills is sugar over a skillsFilter drop-list: fold each name in as false
|
|
60
|
+
// WITHOUT overriding an explicit skillsFilter entry (explicit entries win).
|
|
61
|
+
const filter = opts.disableSkills && opts.disableSkills.length > 0
|
|
62
|
+
? { ...Object.fromEntries(opts.disableSkills.map((n) => [n, false])), ...opts.skillsFilter }
|
|
63
|
+
: opts.skillsFilter;
|
|
54
64
|
skill = loadSkills({
|
|
55
65
|
dirs: opts.skillsDir,
|
|
56
66
|
skills: dataDefs.length > 0 ? dataDefs : undefined,
|
|
57
|
-
filter
|
|
67
|
+
filter,
|
|
58
68
|
sampleLimit: opts.skillSampleLimit,
|
|
59
69
|
});
|
|
60
70
|
}
|
|
@@ -93,7 +103,7 @@ export class Toolkit {
|
|
|
93
103
|
if (opts.mcpConfig && typeof opts.mcpConfig === "object" && "mcpServer" in opts.mcpConfig) {
|
|
94
104
|
mcpServerConfig = opts.mcpConfig.mcpServer;
|
|
95
105
|
}
|
|
96
|
-
return new Toolkit(mcp, skill, builtins, agentToolsFlat, opts.extraTools ?? [], a2aConfig, mcpServerConfig);
|
|
106
|
+
return new Toolkit(mcp, skill, builtins, agentToolsFlat, opts.extraTools ?? [], a2aConfig, mcpServerConfig, opts.disableTools ?? []);
|
|
97
107
|
}
|
|
98
108
|
tools() {
|
|
99
109
|
return [...this.byName.values()];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolnexus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|
|
20
|
+
"test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/home.test.ts test/compaction.test.ts",
|
|
21
21
|
"example": "node --experimental-strip-types examples/basic.ts"
|
|
22
22
|
},
|
|
23
23
|
"keywords": [
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Level-1 subagent surface — SPEC.md §7D.
|
|
3
|
+
*
|
|
4
|
+
* One new noun: `agent(name, spec)`. An Agent is (identity/system prompt × a
|
|
5
|
+
* filtered toolkit view × the shipped client loop), and — the §7A/§7B axiom
|
|
6
|
+
* completed locally — an Agent IS a Tool: `asTool()` bridges it into the classic
|
|
7
|
+
* API's `extraTools`; `run()` executes one-shot against a private runtime.
|
|
8
|
+
* Everything compiles down to the six host verbs in runtime.ts.
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs"
|
|
11
|
+
import { defineTool } from "../native.js"
|
|
12
|
+
import type { Answer, Request, Tool } from "../types.js"
|
|
13
|
+
import { AgentRuntime, isVerbError, type AgentDef, type Budget, type Handle, type RuntimeOptions, type TaskResult } from "./runtime.js"
|
|
14
|
+
|
|
15
|
+
/** The declarative agent spec (§7D Level-1 surface). */
|
|
16
|
+
export interface AgentSpec {
|
|
17
|
+
/** Required routing description — what a delegating model sees. */
|
|
18
|
+
does: string
|
|
19
|
+
/** The toolkit VIEW for this agent (scoping is the security model). */
|
|
20
|
+
uses?: { tools?: Tool[] }
|
|
21
|
+
/** Identity: inline soul text (becomes the system prompt). */
|
|
22
|
+
soul?: string
|
|
23
|
+
/** Path form of `soul` — read when the registry is built. */
|
|
24
|
+
soulFile?: string
|
|
25
|
+
/** Declaring agents here IS the subagent wiring: they become the `task`
|
|
26
|
+
* tool's only reachable targets. No team ⇒ no `task` tool. */
|
|
27
|
+
team?: Agent[]
|
|
28
|
+
budget?: Budget
|
|
29
|
+
/** Model id; default "inherit" (the runtime's `llm.model`). */
|
|
30
|
+
model?: string
|
|
31
|
+
/** §10 interpreter authority for this agent's subtree. */
|
|
32
|
+
waitFor?: (request: Request) => Promise<Answer>
|
|
33
|
+
onSpawn?: AgentDef["onSpawn"]
|
|
34
|
+
onClose?: AgentDef["onClose"]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Runtime options for a one-shot `run`/`asTool` (the registry is derived). */
|
|
38
|
+
export type AgentRunOptions = Omit<RuntimeOptions, "registry">
|
|
39
|
+
|
|
40
|
+
/** A one-shot run's result, with the runtime attached for durable resume. */
|
|
41
|
+
export interface AgentRunResult extends TaskResult {
|
|
42
|
+
/** The runtime that ran this agent — call `runtime.resume(answer)` after a
|
|
43
|
+
* `status:"pending"` result to continue from the checkpoint. */
|
|
44
|
+
runtime: AgentRuntime
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class Agent {
|
|
48
|
+
constructor(readonly name: string, readonly spec: AgentSpec) {}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Collect this agent plus its transitive team graph into a runtime registry —
|
|
52
|
+
* the registry IS the reachable graph; unreachable agents are not present.
|
|
53
|
+
*/
|
|
54
|
+
registry(acc: Record<string, AgentDef> = {}): Record<string, AgentDef> {
|
|
55
|
+
if (acc[this.name]) return acc
|
|
56
|
+
let soul = this.spec.soul ?? ""
|
|
57
|
+
if (this.spec.soulFile) soul = fs.readFileSync(this.spec.soulFile, "utf8")
|
|
58
|
+
acc[this.name] = {
|
|
59
|
+
name: this.name,
|
|
60
|
+
does: this.spec.does,
|
|
61
|
+
soul,
|
|
62
|
+
model: this.spec.model ?? "inherit",
|
|
63
|
+
tools: this.spec.uses?.tools,
|
|
64
|
+
team: (this.spec.team ?? []).map((a) => a.name),
|
|
65
|
+
budget: this.spec.budget,
|
|
66
|
+
waitFor: this.spec.waitFor,
|
|
67
|
+
onSpawn: this.spec.onSpawn,
|
|
68
|
+
onClose: this.spec.onClose,
|
|
69
|
+
}
|
|
70
|
+
for (const t of this.spec.team ?? []) t.registry(acc)
|
|
71
|
+
return acc
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Build a runtime scoped to this agent's reachable team graph. */
|
|
75
|
+
createRuntime(opts: AgentRunOptions = {}): AgentRuntime {
|
|
76
|
+
return new AgentRuntime({ ...opts, registry: this.registry() })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Level 1: one-shot. Builds a runtime, runs this agent to completion, and tears
|
|
81
|
+
* the tree down — unless the run suspends durably (`status:"pending"`), in which
|
|
82
|
+
* case the tree stays parked and `result.runtime.resume(answer)` continues it.
|
|
83
|
+
*/
|
|
84
|
+
async run(prompt: string, opts: AgentRunOptions = {}): Promise<AgentRunResult> {
|
|
85
|
+
const runtime = this.createRuntime(opts)
|
|
86
|
+
const h = runtime.spawn(runtime.root, this.name)
|
|
87
|
+
if (isVerbError(h)) {
|
|
88
|
+
return { text: h.error, isError: true, status: "error", turns: 0, totalTokens: 0, runtime }
|
|
89
|
+
}
|
|
90
|
+
const woke = runtime.wake(h, prompt)
|
|
91
|
+
if (!woke.ok) {
|
|
92
|
+
return { ...(await runtime.wait(h as Handle)), runtime }
|
|
93
|
+
}
|
|
94
|
+
const r = await runtime.wait(h as Handle)
|
|
95
|
+
if (r.status !== "pending") await runtime.close(runtime.root)
|
|
96
|
+
return { ...r, runtime }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The bridge: an Agent IS a Tool. Drop the result into the classic API's
|
|
101
|
+
* `extraTools`; the agent's own loop runs in isolation and the classic run
|
|
102
|
+
* receives one tool result carrying only the final text + metadata
|
|
103
|
+
* `{agent, turns, totalTokens}`.
|
|
104
|
+
*/
|
|
105
|
+
asTool(opts: AgentRunOptions = {}): Tool {
|
|
106
|
+
return defineTool({
|
|
107
|
+
name: this.name,
|
|
108
|
+
description: this.spec.does,
|
|
109
|
+
inputSchema: {
|
|
110
|
+
type: "object",
|
|
111
|
+
properties: { prompt: { type: "string", description: "The task for this agent" } },
|
|
112
|
+
required: ["prompt"],
|
|
113
|
+
},
|
|
114
|
+
run: async (args: Record<string, unknown>) => {
|
|
115
|
+
const r = await this.run(String(args.prompt), opts)
|
|
116
|
+
return {
|
|
117
|
+
output: r.text,
|
|
118
|
+
isError: r.isError,
|
|
119
|
+
metadata: { agent: this.name, turns: r.turns, totalTokens: r.totalTokens },
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The one new noun: define an agent. */
|
|
127
|
+
export function agent(name: string, spec: AgentSpec): Agent {
|
|
128
|
+
return new Agent(name, spec)
|
|
129
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context compaction (SPEC.md §7F) — keep a long-lived agent under budget.
|
|
3
|
+
*
|
|
4
|
+
* A long-running agent grows its transcript until it overflows the model's window.
|
|
5
|
+
* `compactor(opts)` returns a **`beforeLLM` hook** (§8) that summarizes the older
|
|
6
|
+
* transcript and keeps a recent tail. It rides the existing seam: the client loop
|
|
7
|
+
* applies a `beforeLLM` message rewrite by *replacing* the working transcript, and
|
|
8
|
+
* that array flows on into `RunResult.messages` and the `ConversationStore`. So
|
|
9
|
+
* compaction is a pure `messages → messages` helper and adds **no loop behavior** —
|
|
10
|
+
* it is the canonical use of the §8 `beforeLLM` hook.
|
|
11
|
+
*
|
|
12
|
+
* Invariants (SPEC §7F):
|
|
13
|
+
* 1. At/below `maxTokens` the hook is a **no-op** — it returns nothing and the run
|
|
14
|
+
* is byte-identical to a run with no compactor.
|
|
15
|
+
* 2. **Tool-pair safety** — the retained tail always begins at a `user` turn, so no
|
|
16
|
+
* `tool` message is ever orphaned from the `assistant` carrying its `tool_call_id`.
|
|
17
|
+
* 3. The **leading system prompt** (identity / soul / skills) is preserved verbatim;
|
|
18
|
+
* only the body between it and the tail is summarized.
|
|
19
|
+
*
|
|
20
|
+
* import { agents } from "toolnexus"
|
|
21
|
+
* const client = createClient({
|
|
22
|
+
* …,
|
|
23
|
+
* hooks: { beforeLLM: agents.compactor({ maxTokens: 120_000, summarize }) },
|
|
24
|
+
* })
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A chat message in the working transcript. Structural and provider-neutral — only the
|
|
29
|
+
* fields compaction reasons about are named; anything else on the message is carried
|
|
30
|
+
* through untouched.
|
|
31
|
+
*/
|
|
32
|
+
export interface Message {
|
|
33
|
+
role: string
|
|
34
|
+
content?: unknown
|
|
35
|
+
tool_calls?: unknown[]
|
|
36
|
+
tool_call_id?: string
|
|
37
|
+
[key: string]: unknown
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Cheap, deterministic token estimate: `ceil(chars/4)` over each message's JSON
|
|
42
|
+
* serialization, summed. This is an *estimator*, not a tokenizer — exactness is the
|
|
43
|
+
* host's call. Override via `CompactorOptions.countTokens` for a real tokenizer.
|
|
44
|
+
*/
|
|
45
|
+
export function estimateTokens(messages: Message[]): number {
|
|
46
|
+
let total = 0
|
|
47
|
+
for (const m of messages) total += Math.ceil(JSON.stringify(m).length / 4)
|
|
48
|
+
return total
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Options for {@link compactor}. */
|
|
52
|
+
export interface CompactorOptions {
|
|
53
|
+
/** Compact only when the transcript estimate exceeds this; at/below ⇒ no-op. */
|
|
54
|
+
maxTokens: number
|
|
55
|
+
/** Keep at least this many tokens of the most recent tail. Default `maxTokens/2`. */
|
|
56
|
+
keepTail?: number
|
|
57
|
+
/** Produce a summary of the older messages. MAY call an LLM (async). Required. */
|
|
58
|
+
summarize: (older: Message[]) => Promise<string> | string
|
|
59
|
+
/** Token estimator; default `ceil(chars/4)` summed over messages. */
|
|
60
|
+
countTokens?: (messages: Message[]) => number
|
|
61
|
+
/**
|
|
62
|
+
* When set, inject a pre-compact system reminder telling the model to persist
|
|
63
|
+
* durable facts via the §7E `memory` tool before the head is summarized. Off by
|
|
64
|
+
* default. Composes with the memory builtin.
|
|
65
|
+
*/
|
|
66
|
+
flushToMemory?: boolean
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The event a `beforeLLM` hook receives (§8). */
|
|
70
|
+
export interface BeforeLLMEvent {
|
|
71
|
+
messages: Message[]
|
|
72
|
+
tools: unknown[]
|
|
73
|
+
model: string
|
|
74
|
+
turn: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The system reminder injected when `flushToMemory` is set. */
|
|
78
|
+
const FLUSH_TO_MEMORY_REMINDER =
|
|
79
|
+
"Before continuing: if anything from earlier is worth keeping, save it with the memory tool now — the earlier transcript is about to be summarized."
|
|
80
|
+
|
|
81
|
+
/** Prefix on the summary system message; hosts/tests key off this to detect a compaction. */
|
|
82
|
+
const SUMMARY_PREFIX = "[Summary of earlier conversation]\n"
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build a `beforeLLM` hook (§8) that compacts the transcript once it exceeds
|
|
86
|
+
* `maxTokens`. Below budget it returns nothing (a byte-identical no-op); above it, it
|
|
87
|
+
* replaces the transcript with `[leading system prompt, summary system message,
|
|
88
|
+
* (flush reminder?), …recent tail]`, splitting at a clean `user` boundary so tool
|
|
89
|
+
* groups are never broken.
|
|
90
|
+
*/
|
|
91
|
+
export function compactor(
|
|
92
|
+
opts: CompactorOptions,
|
|
93
|
+
): (ev: BeforeLLMEvent) => Promise<{ messages: Message[] } | undefined> {
|
|
94
|
+
const count = opts.countTokens ?? estimateTokens
|
|
95
|
+
const keepTail = opts.keepTail ?? Math.floor(opts.maxTokens / 2)
|
|
96
|
+
|
|
97
|
+
return async (ev: BeforeLLMEvent): Promise<{ messages: Message[] } | undefined> => {
|
|
98
|
+
const msgs = ev.messages
|
|
99
|
+
if (count(msgs) <= opts.maxTokens) return undefined // under budget → no-op
|
|
100
|
+
|
|
101
|
+
// Preserve a leading system prompt verbatim (identity / soul / skills — never summarized).
|
|
102
|
+
const headEnd = msgs[0]?.role === "system" ? 1 : 0
|
|
103
|
+
const system = msgs.slice(0, headEnd)
|
|
104
|
+
|
|
105
|
+
const split = findTailStart(msgs, headEnd, keepTail, count)
|
|
106
|
+
if (split <= headEnd) return undefined // nothing safely compactible
|
|
107
|
+
|
|
108
|
+
const older = msgs.slice(headEnd, split)
|
|
109
|
+
if (older.length === 0) return undefined
|
|
110
|
+
const tail = msgs.slice(split)
|
|
111
|
+
|
|
112
|
+
const flushNote: Message[] = opts.flushToMemory
|
|
113
|
+
? [{ role: "system", content: FLUSH_TO_MEMORY_REMINDER }]
|
|
114
|
+
: []
|
|
115
|
+
|
|
116
|
+
const summary = await opts.summarize(older)
|
|
117
|
+
const summaryMsg: Message = { role: "system", content: `${SUMMARY_PREFIX}${summary}` }
|
|
118
|
+
|
|
119
|
+
return { messages: [...system, summaryMsg, ...flushNote, ...tail] }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Locate the index where the retained tail begins. Tool-pair safety requires the tail
|
|
125
|
+
* to start at a `user` turn: the largest user-boundary tail that still fits `keepTail`.
|
|
126
|
+
* If no user boundary fits within `keepTail`, fall back to the most recent user turn
|
|
127
|
+
* (favoring safety over size). Returns `msgs.length` (no tail) only when there is no
|
|
128
|
+
* user turn after the head at all.
|
|
129
|
+
*/
|
|
130
|
+
function findTailStart(
|
|
131
|
+
msgs: Message[],
|
|
132
|
+
headEnd: number,
|
|
133
|
+
keepTail: number,
|
|
134
|
+
count: (messages: Message[]) => number,
|
|
135
|
+
): number {
|
|
136
|
+
let split = msgs.length
|
|
137
|
+
for (let i = msgs.length - 1; i > headEnd; i--) {
|
|
138
|
+
if (count(msgs.slice(i)) > keepTail) break
|
|
139
|
+
if (msgs[i].role === "user") split = i
|
|
140
|
+
}
|
|
141
|
+
if (split !== msgs.length) return split
|
|
142
|
+
|
|
143
|
+
// No user boundary fit within keepTail — extend back to the most recent user turn.
|
|
144
|
+
for (let i = msgs.length - 1; i > headEnd; i--) {
|
|
145
|
+
if (msgs[i].role === "user") return i
|
|
146
|
+
}
|
|
147
|
+
return msgs.length
|
|
148
|
+
}
|