toolnexus 0.9.5 → 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 +8 -3
- package/dist/client.js +14 -9
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- 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 +19 -11
- package/src/index.ts +1 -0
package/dist/client.d.ts
CHANGED
|
@@ -184,10 +184,14 @@ export interface RunResult {
|
|
|
184
184
|
usage: Usage;
|
|
185
185
|
/** The model used. */
|
|
186
186
|
model: string;
|
|
187
|
-
/** "done" normally; "pending" iff a tool suspended and no waitFor was configured (§10)
|
|
188
|
-
|
|
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";
|
|
189
191
|
/** The unresolved suspension — present iff status === "pending" (§10). */
|
|
190
192
|
pending?: Request;
|
|
193
|
+
/** The limit that stopped the run (e.g. "maxTurns") — present iff status === "incomplete". */
|
|
194
|
+
limit?: string;
|
|
191
195
|
}
|
|
192
196
|
/** Events yielded by client.stream(). */
|
|
193
197
|
export type StreamEvent = {
|
|
@@ -242,7 +246,8 @@ export declare class Client {
|
|
|
242
246
|
metrics(): string;
|
|
243
247
|
/** Feed a semantic metric event to the built-in registry and the optional onMetric sink. */
|
|
244
248
|
private emit;
|
|
245
|
-
/** 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). */
|
|
246
251
|
private endRun;
|
|
247
252
|
/** Emit a `run` error metric event (once, on a thrown run). */
|
|
248
253
|
private emitRunError;
|
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) {
|
|
@@ -436,15 +437,19 @@ export class Client {
|
|
|
436
437
|
return this.pendingRun(runStart, s.halted, messages, toolCalls, turns, usage);
|
|
437
438
|
}
|
|
438
439
|
}
|
|
439
|
-
return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage);
|
|
440
|
+
return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage, true);
|
|
440
441
|
}
|
|
441
442
|
catch (e) {
|
|
442
443
|
this.emitRunError(runStart, toolCalls, turns, usage, e);
|
|
443
444
|
throw e;
|
|
444
445
|
}
|
|
445
446
|
}
|
|
446
|
-
result(text, messages, toolCalls, turns, usage) {
|
|
447
|
-
|
|
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;
|
|
448
453
|
}
|
|
449
454
|
/** §10: a run halted because a tool suspended and no `waitFor` was configured. */
|
|
450
455
|
pendingRun(runStart, request, messages, toolCalls, turns, usage) {
|
|
@@ -545,7 +550,7 @@ export class Client {
|
|
|
545
550
|
if (halted)
|
|
546
551
|
return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage);
|
|
547
552
|
}
|
|
548
|
-
return this.endRun(runStart, "", messages, toolCalls, turns, usage);
|
|
553
|
+
return this.endRun(runStart, "", messages, toolCalls, turns, usage, true);
|
|
549
554
|
}
|
|
550
555
|
catch (e) {
|
|
551
556
|
this.emitRunError(runStart, toolCalls, turns, usage, e);
|
|
@@ -655,7 +660,7 @@ export class Client {
|
|
|
655
660
|
yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError };
|
|
656
661
|
}
|
|
657
662
|
}
|
|
658
|
-
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) };
|
|
659
664
|
}
|
|
660
665
|
catch (e) {
|
|
661
666
|
this.emitRunError(runStart, toolCalls, turns, usage, e);
|
|
@@ -764,7 +769,7 @@ export class Client {
|
|
|
764
769
|
}
|
|
765
770
|
messages.push({ role: "user", content: results });
|
|
766
771
|
}
|
|
767
|
-
yield { type: "done", result: this.endRun(runStart, "", messages, toolCalls, turns, usage) };
|
|
772
|
+
yield { type: "done", result: this.endRun(runStart, "", messages, toolCalls, turns, usage, true) };
|
|
768
773
|
}
|
|
769
774
|
catch (e) {
|
|
770
775
|
this.emitRunError(runStart, toolCalls, turns, usage, e);
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
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
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent home — the persona surface over the §7D runtime (SPEC.md §7E).
|
|
3
|
+
*
|
|
4
|
+
* A persona is an identity that lives in **files**, keeps durable **memory** it can
|
|
5
|
+
* edit, and runs on a **heartbeat** so it can act unprompted. All three ride shipped
|
|
6
|
+
* seams — the composed soul is just a system prompt, the memory tool is a plain
|
|
7
|
+
* `Tool`, and the heartbeat is `post`+`wake` on the runtime's injectable clock — so
|
|
8
|
+
* this layer adds *no* new runtime behavior. It is convention (a directory layout)
|
|
9
|
+
* and composition (recipes over the six verbs), not new machinery.
|
|
10
|
+
*
|
|
11
|
+
* import { agents } from "toolnexus"
|
|
12
|
+
* const ava = agents.fromDir("./personas/ava")
|
|
13
|
+
* const started = agents.startAgent(ava, { fetch, clock }, { everyMs: 30 * 60_000 })
|
|
14
|
+
*/
|
|
15
|
+
import fs from "node:fs"
|
|
16
|
+
import path from "node:path"
|
|
17
|
+
import { defineTool } from "../native.js"
|
|
18
|
+
import type { Tool } from "../types.js"
|
|
19
|
+
import { agent, Agent, type AgentRunOptions } from "./agent.js"
|
|
20
|
+
import { AgentRuntime, isVerbError } from "./runtime.js"
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Bootstrap discovery order (§7E): identity first, durable memory last. Each present
|
|
24
|
+
* file is injected into the soul as a `## <filename>` section, in exactly this order.
|
|
25
|
+
*/
|
|
26
|
+
export const BOOTSTRAP_ORDER = [
|
|
27
|
+
"AGENTS.md",
|
|
28
|
+
"SOUL.md",
|
|
29
|
+
"IDENTITY.md",
|
|
30
|
+
"USER.md",
|
|
31
|
+
"TOOLS.md",
|
|
32
|
+
"HEARTBEAT.md",
|
|
33
|
+
"MEMORY.md",
|
|
34
|
+
] as const
|
|
35
|
+
|
|
36
|
+
/** Per-file bootstrap cap (§7E): a larger file is injected truncated, disk untouched. */
|
|
37
|
+
export const MAX_FILE_BYTES = 2 * 1024 * 1024
|
|
38
|
+
|
|
39
|
+
/** Notice appended to a bootstrap file truncated at {@link MAX_FILE_BYTES}. */
|
|
40
|
+
export const TRUNCATION_NOTICE = "\n[truncated: exceeds 2 MB bootstrap cap]"
|
|
41
|
+
|
|
42
|
+
/** The silent-no-op sentinel: a heartbeat reply containing this surfaces nothing. */
|
|
43
|
+
export const HEARTBEAT_OK = "HEARTBEAT_OK"
|
|
44
|
+
|
|
45
|
+
/** The prompt a heartbeat wakes the persona with. Contains `HEARTBEAT_OK` so the
|
|
46
|
+
* model knows the silent-reply contract, and the word "Heartbeat" so a HEARTBEAT.md
|
|
47
|
+
* that keys off it can recognise the trigger. */
|
|
48
|
+
export const HEARTBEAT_PROMPT =
|
|
49
|
+
`Heartbeat. Read your HEARTBEAT.md section and follow it. ` +
|
|
50
|
+
`If nothing needs attention, reply ${HEARTBEAT_OK}.`
|
|
51
|
+
|
|
52
|
+
/** Read a bootstrap file with the byte cap applied; `undefined` if it is absent. */
|
|
53
|
+
function readCapped(file: string): string | undefined {
|
|
54
|
+
let buf: Buffer
|
|
55
|
+
try {
|
|
56
|
+
buf = fs.readFileSync(file)
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined // absent files are skipped (§7E)
|
|
59
|
+
}
|
|
60
|
+
if (buf.byteLength > MAX_FILE_BYTES) {
|
|
61
|
+
return buf.subarray(0, MAX_FILE_BYTES).toString("utf8") + TRUNCATION_NOTICE
|
|
62
|
+
}
|
|
63
|
+
return buf.toString("utf8")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Compose the bootstrap files present in `dir` into one soul string — the frozen
|
|
68
|
+
* snapshot injected as the system prompt for the whole run. Only present files
|
|
69
|
+
* appear, always in {@link BOOTSTRAP_ORDER}, each as a `## <filename>` section.
|
|
70
|
+
*/
|
|
71
|
+
export function composeSoul(dir: string): { soul: string; found: string[] } {
|
|
72
|
+
const sections: string[] = []
|
|
73
|
+
const found: string[] = []
|
|
74
|
+
for (const file of BOOTSTRAP_ORDER) {
|
|
75
|
+
const body = readCapped(path.join(dir, file))
|
|
76
|
+
if (body !== undefined) {
|
|
77
|
+
sections.push(`## ${file}\n\n${body.trim()}`)
|
|
78
|
+
found.push(file)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { soul: sections.join("\n\n"), found }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Which memory file an action targets. `self` → MEMORY.md, `user` → USER.md. */
|
|
85
|
+
type MemoryTarget = "self" | "user"
|
|
86
|
+
|
|
87
|
+
function memoryFile(dir: string, target: unknown): string {
|
|
88
|
+
return path.join(dir, target === "user" ? "USER.md" : "MEMORY.md")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The `memory` builtin (§7E, file-backed, opt-in). One tool, three actions over
|
|
93
|
+
* `MEMORY.md` (the agent's own notes) and `USER.md` (its model of the user):
|
|
94
|
+
*
|
|
95
|
+
* - `add` — append an entry
|
|
96
|
+
* - `replace` — swap an existing substring for `with`
|
|
97
|
+
* - `remove` — delete an existing substring
|
|
98
|
+
*
|
|
99
|
+
* Every action writes to **disk**. It does **not** touch the live session's system
|
|
100
|
+
* prompt: the frozen-snapshot rule means the edit loads only at the START of the
|
|
101
|
+
* next session (keeping a long-lived persona cache-stable). A `replace`/`remove`
|
|
102
|
+
* whose substring is absent is a loud `isError` — never a silent no-op.
|
|
103
|
+
*/
|
|
104
|
+
export function memoryTool(dir: string): Tool {
|
|
105
|
+
return defineTool({
|
|
106
|
+
name: "memory",
|
|
107
|
+
description:
|
|
108
|
+
"Persist durable memory. action=add appends an entry; replace swaps an " +
|
|
109
|
+
"existing substring (with); remove deletes one. target=self (MEMORY.md, " +
|
|
110
|
+
"default) or user (USER.md). Writes persist to disk and load at the START of " +
|
|
111
|
+
"your NEXT session — they do NOT change your current context.",
|
|
112
|
+
inputSchema: {
|
|
113
|
+
type: "object",
|
|
114
|
+
properties: {
|
|
115
|
+
action: { type: "string", enum: ["add", "replace", "remove"] },
|
|
116
|
+
target: { type: "string", enum: ["self", "user"], description: "self=MEMORY.md (default), user=USER.md" },
|
|
117
|
+
text: { type: "string", description: "For add: the entry. For replace/remove: the existing text." },
|
|
118
|
+
with: { type: "string", description: "For replace: the replacement text." },
|
|
119
|
+
},
|
|
120
|
+
required: ["action", "text"],
|
|
121
|
+
},
|
|
122
|
+
run: (args: Record<string, unknown>) => {
|
|
123
|
+
const target = (args.target as MemoryTarget) ?? "self"
|
|
124
|
+
const file = memoryFile(dir, target)
|
|
125
|
+
const text = String(args.text)
|
|
126
|
+
const current = readCapped(file) ?? ""
|
|
127
|
+
let next: string
|
|
128
|
+
switch (args.action) {
|
|
129
|
+
case "add":
|
|
130
|
+
next = (current.trimEnd() + `\n- ${text}\n`).trimStart()
|
|
131
|
+
break
|
|
132
|
+
case "replace":
|
|
133
|
+
if (!current.includes(text)) return { output: `not found: ${text}`, isError: true }
|
|
134
|
+
next = current.replace(text, String(args.with ?? ""))
|
|
135
|
+
break
|
|
136
|
+
case "remove":
|
|
137
|
+
if (!current.includes(text)) return { output: `not found: ${text}`, isError: true }
|
|
138
|
+
next = current.replace(text, "")
|
|
139
|
+
break
|
|
140
|
+
default:
|
|
141
|
+
return { output: `unknown action: ${String(args.action)}`, isError: true }
|
|
142
|
+
}
|
|
143
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
144
|
+
fs.writeFileSync(file, next)
|
|
145
|
+
return `ok (${String(args.action)} → ${path.basename(file)}); loads next session`
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Options for {@link fromDir}. */
|
|
151
|
+
export interface FromDirOptions {
|
|
152
|
+
/** Routing description a delegating model sees; default derived from the dir. */
|
|
153
|
+
does?: string
|
|
154
|
+
/** Agent name; default the directory's basename. */
|
|
155
|
+
name?: string
|
|
156
|
+
/** Model id; default `"inherit"` (the runtime's `llm.model`). */
|
|
157
|
+
model?: string
|
|
158
|
+
/** Extra tools beyond the memory builtin. */
|
|
159
|
+
tools?: Tool[]
|
|
160
|
+
/** Set `false` to omit the memory tool (a read-only persona). */
|
|
161
|
+
memory?: boolean
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The directory IS the agent (§7E). Discovers the bootstrap files in `dir`, composes
|
|
166
|
+
* them into a frozen soul snapshot, and (unless `memory:false`) wires a `memory` tool
|
|
167
|
+
* over the same directory. Returns a plain {@link Agent} — `run`/`asTool`/`startAgent`
|
|
168
|
+
* all apply.
|
|
169
|
+
*/
|
|
170
|
+
export function fromDir(dir: string, opts: FromDirOptions = {}): Agent {
|
|
171
|
+
const { soul } = composeSoul(dir)
|
|
172
|
+
const tools = [...(opts.tools ?? [])]
|
|
173
|
+
if (opts.memory !== false) tools.push(memoryTool(dir))
|
|
174
|
+
return agent(opts.name ?? path.basename(dir), {
|
|
175
|
+
does: opts.does ?? `persona agent from ${dir}`,
|
|
176
|
+
soul,
|
|
177
|
+
model: opts.model ?? "inherit",
|
|
178
|
+
uses: { tools },
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Heartbeat configuration for {@link startAgent}. */
|
|
183
|
+
export interface HeartbeatOptions {
|
|
184
|
+
/** Interval between beats, in ms (measured on the runtime's clock). */
|
|
185
|
+
everyMs: number
|
|
186
|
+
/** Called for each SUBSTANTIVE beat — a `HEARTBEAT_OK` reply is stripped and never
|
|
187
|
+
* surfaces here. */
|
|
188
|
+
onBeat?: (text: string) => void
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A running persona: the live runtime, the collected substantive beats, and `stop`. */
|
|
192
|
+
export interface StartedAgent {
|
|
193
|
+
/** The runtime the persona runs on — expose so a host can `post`/`wake` it (channels). */
|
|
194
|
+
runtime: AgentRuntime
|
|
195
|
+
/** Substantive beats collected so far (HEARTBEAT_OK replies excluded). */
|
|
196
|
+
beats: string[]
|
|
197
|
+
/** Cancel the heartbeat and close the tree gracefully. */
|
|
198
|
+
stop: () => Promise<void>
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Give a persona its own clock (§7E). On each interval it `post`s a tick to its own
|
|
203
|
+
* inbox (the unsolicited rail — ticks **coalesce**, so a slow beat can't pile up) and,
|
|
204
|
+
* when idle, `wake`s it with {@link HEARTBEAT_PROMPT}. A reply containing
|
|
205
|
+
* {@link HEARTBEAT_OK} is treated as silent (no `onBeat`); only a substantive reply
|
|
206
|
+
* surfaces. All timing goes through the runtime's injectable clock, so a virtual-clock
|
|
207
|
+
* test is deterministic.
|
|
208
|
+
*
|
|
209
|
+
* Inbound channels are the host's job: deliver external events by calling
|
|
210
|
+
* `started.runtime.post`/`wake` on the persona's handle.
|
|
211
|
+
*/
|
|
212
|
+
export function startAgent(a: Agent, run: AgentRunOptions, hb: HeartbeatOptions): StartedAgent {
|
|
213
|
+
const runtime = a.createRuntime(run)
|
|
214
|
+
const handle = runtime.spawn(runtime.root, a.name)
|
|
215
|
+
if (isVerbError(handle)) throw new Error(handle.error)
|
|
216
|
+
|
|
217
|
+
const beats: string[] = []
|
|
218
|
+
let stopped = false
|
|
219
|
+
let cancel: (() => void) | undefined
|
|
220
|
+
|
|
221
|
+
const beat = (): void => {
|
|
222
|
+
if (stopped) return
|
|
223
|
+
// Unsolicited rail: the tick lands in the inbox (coalesces); a wake — only when
|
|
224
|
+
// idle — drains the whole inbox as one turn.
|
|
225
|
+
runtime.post(handle, { from: "clock", channel: "timer", text: "tick" })
|
|
226
|
+
if (handle.state === "idle") {
|
|
227
|
+
const woke = runtime.wake(handle, HEARTBEAT_PROMPT)
|
|
228
|
+
if (woke.ok) {
|
|
229
|
+
void runtime.wait(handle).then((r) => {
|
|
230
|
+
if (!r.isError && !r.text.includes(HEARTBEAT_OK)) {
|
|
231
|
+
beats.push(r.text)
|
|
232
|
+
hb.onBeat?.(r.text)
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
cancel = runtime.clock.setTimeout(beat, hb.everyMs) // self-reschedule → one live timer
|
|
238
|
+
}
|
|
239
|
+
cancel = runtime.clock.setTimeout(beat, hb.everyMs)
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
runtime,
|
|
243
|
+
beats,
|
|
244
|
+
stop: async () => {
|
|
245
|
+
stopped = true
|
|
246
|
+
cancel?.()
|
|
247
|
+
await runtime.close(runtime.root)
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-agents & agent runtime (SPEC.md §7D), exported as the `agents` namespace:
|
|
3
|
+
*
|
|
4
|
+
* import { agents } from "toolnexus"
|
|
5
|
+
* const explore = agents.agent("explore", { does: "read-only research" })
|
|
6
|
+
*/
|
|
7
|
+
export * from "./runtime.js"
|
|
8
|
+
export * from "./agent.js"
|
|
9
|
+
export * from "./home.js"
|
|
10
|
+
export * from "./compaction.js"
|