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/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
- status: "done" | "pending";
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
- endRun(runStart, text, messages, toolCalls, turns, usage) {
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 || !RETRYABLE.has(res.status) || attempt === retries)
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
- if (attempt === retries)
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
- return { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: "done" };
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
@@ -10,3 +10,4 @@ export * from "./mcpserve.js";
10
10
  export * from "./adapters.js";
11
11
  export * from "./toolkit.js";
12
12
  export * from "./client.js";
13
+ export * as agents from "./agents/index.js";
package/dist/index.js CHANGED
@@ -10,3 +10,4 @@ export * from "./mcpserve.js";
10
10
  export * from "./adapters.js";
11
11
  export * from "./toolkit.js";
12
12
  export * from "./client.js";
13
+ export * as agents from "./agents/index.js";
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: opts.skillsFilter,
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.9.4",
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
+ }