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 CHANGED
@@ -347,7 +347,37 @@ const res = await tk.execute(name, args) // → { output, isError, metadata }
347
347
  | **HTTP / REST** | `httpTool({...})` — an endpoint becomes a tool, `${ENV}` headers |
348
348
 
349
349
  All appear as one uniform `Tool` in `tk.tools()`. Built-ins are a fifth source; remote A2A agents
350
- a sixth.
350
+ a sixth; in-process **sub-agents** a seventh (below).
351
+
352
+ ## Sub-agents & teams
353
+
354
+ An **Agent is a Tool**: a system prompt × a scoped toolkit view × the client loop. One agent
355
+ delegates to another **in-process** via one `task` tool — isolated context, one result back,
356
+ tokens rolled up, hierarchical budgets, durable suspension (`SPEC.md §7D`). The surface lives in
357
+ the **`agents`** namespace (never the A2A `agent` above):
358
+
359
+ ```ts
360
+ import { agents } from "toolnexus"
361
+
362
+ const explore = agents.agent("explore", {
363
+ does: "read-only research",
364
+ uses: { tools: [lookup] }, // this child sees ONLY these tools
365
+ })
366
+ const coder = agents.agent("coder", {
367
+ does: "implements changes",
368
+ soulFile: "./AGENTS.md", // identity file → system prompt
369
+ team: [explore], // team = the task tool's only targets; no team ⇒ no task tool
370
+ budget: { maxTokens: 10_000 },
371
+ })
372
+
373
+ const r = await coder.run("fix the failing test", {
374
+ llm: { baseUrl: "https://openrouter.ai/api/v1", style: "openai", model: "openai/gpt-4o-mini" },
375
+ })
376
+ console.log(r.status, r.text, r.totalTokens) // "done" | "incomplete" | "pending", final text, tree-wide tokens
377
+ ```
378
+
379
+ `agents.asTool(...)` drops an agent back into `extraTools` for the classic API. Full guide:
380
+ [Sub-agents & teams](https://muthuishere.github.io/toolnexus/subagents/).
351
381
 
352
382
  ## API
353
383
 
@@ -0,0 +1,60 @@
1
+ import type { Answer, Request, Tool } from "../types.js";
2
+ import { AgentRuntime, type AgentDef, type Budget, type RuntimeOptions, type TaskResult } from "./runtime.js";
3
+ /** The declarative agent spec (§7D Level-1 surface). */
4
+ export interface AgentSpec {
5
+ /** Required routing description — what a delegating model sees. */
6
+ does: string;
7
+ /** The toolkit VIEW for this agent (scoping is the security model). */
8
+ uses?: {
9
+ tools?: Tool[];
10
+ };
11
+ /** Identity: inline soul text (becomes the system prompt). */
12
+ soul?: string;
13
+ /** Path form of `soul` — read when the registry is built. */
14
+ soulFile?: string;
15
+ /** Declaring agents here IS the subagent wiring: they become the `task`
16
+ * tool's only reachable targets. No team ⇒ no `task` tool. */
17
+ team?: Agent[];
18
+ budget?: Budget;
19
+ /** Model id; default "inherit" (the runtime's `llm.model`). */
20
+ model?: string;
21
+ /** §10 interpreter authority for this agent's subtree. */
22
+ waitFor?: (request: Request) => Promise<Answer>;
23
+ onSpawn?: AgentDef["onSpawn"];
24
+ onClose?: AgentDef["onClose"];
25
+ }
26
+ /** Runtime options for a one-shot `run`/`asTool` (the registry is derived). */
27
+ export type AgentRunOptions = Omit<RuntimeOptions, "registry">;
28
+ /** A one-shot run's result, with the runtime attached for durable resume. */
29
+ export interface AgentRunResult extends TaskResult {
30
+ /** The runtime that ran this agent — call `runtime.resume(answer)` after a
31
+ * `status:"pending"` result to continue from the checkpoint. */
32
+ runtime: AgentRuntime;
33
+ }
34
+ export declare class Agent {
35
+ readonly name: string;
36
+ readonly spec: AgentSpec;
37
+ constructor(name: string, spec: AgentSpec);
38
+ /**
39
+ * Collect this agent plus its transitive team graph into a runtime registry —
40
+ * the registry IS the reachable graph; unreachable agents are not present.
41
+ */
42
+ registry(acc?: Record<string, AgentDef>): Record<string, AgentDef>;
43
+ /** Build a runtime scoped to this agent's reachable team graph. */
44
+ createRuntime(opts?: AgentRunOptions): AgentRuntime;
45
+ /**
46
+ * Level 1: one-shot. Builds a runtime, runs this agent to completion, and tears
47
+ * the tree down — unless the run suspends durably (`status:"pending"`), in which
48
+ * case the tree stays parked and `result.runtime.resume(answer)` continues it.
49
+ */
50
+ run(prompt: string, opts?: AgentRunOptions): Promise<AgentRunResult>;
51
+ /**
52
+ * The bridge: an Agent IS a Tool. Drop the result into the classic API's
53
+ * `extraTools`; the agent's own loop runs in isolation and the classic run
54
+ * receives one tool result carrying only the final text + metadata
55
+ * `{agent, turns, totalTokens}`.
56
+ */
57
+ asTool(opts?: AgentRunOptions): Tool;
58
+ }
59
+ /** The one new noun: define an agent. */
60
+ export declare function agent(name: string, spec: AgentSpec): Agent;
@@ -0,0 +1,99 @@
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 { AgentRuntime, isVerbError } from "./runtime.js";
13
+ export class Agent {
14
+ name;
15
+ spec;
16
+ constructor(name, spec) {
17
+ this.name = name;
18
+ this.spec = spec;
19
+ }
20
+ /**
21
+ * Collect this agent plus its transitive team graph into a runtime registry —
22
+ * the registry IS the reachable graph; unreachable agents are not present.
23
+ */
24
+ registry(acc = {}) {
25
+ if (acc[this.name])
26
+ return acc;
27
+ let soul = this.spec.soul ?? "";
28
+ if (this.spec.soulFile)
29
+ soul = fs.readFileSync(this.spec.soulFile, "utf8");
30
+ acc[this.name] = {
31
+ name: this.name,
32
+ does: this.spec.does,
33
+ soul,
34
+ model: this.spec.model ?? "inherit",
35
+ tools: this.spec.uses?.tools,
36
+ team: (this.spec.team ?? []).map((a) => a.name),
37
+ budget: this.spec.budget,
38
+ waitFor: this.spec.waitFor,
39
+ onSpawn: this.spec.onSpawn,
40
+ onClose: this.spec.onClose,
41
+ };
42
+ for (const t of this.spec.team ?? [])
43
+ t.registry(acc);
44
+ return acc;
45
+ }
46
+ /** Build a runtime scoped to this agent's reachable team graph. */
47
+ createRuntime(opts = {}) {
48
+ return new AgentRuntime({ ...opts, registry: this.registry() });
49
+ }
50
+ /**
51
+ * Level 1: one-shot. Builds a runtime, runs this agent to completion, and tears
52
+ * the tree down — unless the run suspends durably (`status:"pending"`), in which
53
+ * case the tree stays parked and `result.runtime.resume(answer)` continues it.
54
+ */
55
+ async run(prompt, opts = {}) {
56
+ const runtime = this.createRuntime(opts);
57
+ const h = runtime.spawn(runtime.root, this.name);
58
+ if (isVerbError(h)) {
59
+ return { text: h.error, isError: true, status: "error", turns: 0, totalTokens: 0, runtime };
60
+ }
61
+ const woke = runtime.wake(h, prompt);
62
+ if (!woke.ok) {
63
+ return { ...(await runtime.wait(h)), runtime };
64
+ }
65
+ const r = await runtime.wait(h);
66
+ if (r.status !== "pending")
67
+ await runtime.close(runtime.root);
68
+ return { ...r, runtime };
69
+ }
70
+ /**
71
+ * The bridge: an Agent IS a Tool. Drop the result into the classic API's
72
+ * `extraTools`; the agent's own loop runs in isolation and the classic run
73
+ * receives one tool result carrying only the final text + metadata
74
+ * `{agent, turns, totalTokens}`.
75
+ */
76
+ asTool(opts = {}) {
77
+ return defineTool({
78
+ name: this.name,
79
+ description: this.spec.does,
80
+ inputSchema: {
81
+ type: "object",
82
+ properties: { prompt: { type: "string", description: "The task for this agent" } },
83
+ required: ["prompt"],
84
+ },
85
+ run: async (args) => {
86
+ const r = await this.run(String(args.prompt), opts);
87
+ return {
88
+ output: r.text,
89
+ isError: r.isError,
90
+ metadata: { agent: this.name, turns: r.turns, totalTokens: r.totalTokens },
91
+ };
92
+ },
93
+ });
94
+ }
95
+ }
96
+ /** The one new noun: define an agent. */
97
+ export function agent(name, spec) {
98
+ return new Agent(name, spec);
99
+ }
@@ -0,0 +1,77 @@
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
+ * A chat message in the working transcript. Structural and provider-neutral — only the
28
+ * fields compaction reasons about are named; anything else on the message is carried
29
+ * through untouched.
30
+ */
31
+ export interface Message {
32
+ role: string;
33
+ content?: unknown;
34
+ tool_calls?: unknown[];
35
+ tool_call_id?: string;
36
+ [key: string]: unknown;
37
+ }
38
+ /**
39
+ * Cheap, deterministic token estimate: `ceil(chars/4)` over each message's JSON
40
+ * serialization, summed. This is an *estimator*, not a tokenizer — exactness is the
41
+ * host's call. Override via `CompactorOptions.countTokens` for a real tokenizer.
42
+ */
43
+ export declare function estimateTokens(messages: Message[]): number;
44
+ /** Options for {@link compactor}. */
45
+ export interface CompactorOptions {
46
+ /** Compact only when the transcript estimate exceeds this; at/below ⇒ no-op. */
47
+ maxTokens: number;
48
+ /** Keep at least this many tokens of the most recent tail. Default `maxTokens/2`. */
49
+ keepTail?: number;
50
+ /** Produce a summary of the older messages. MAY call an LLM (async). Required. */
51
+ summarize: (older: Message[]) => Promise<string> | string;
52
+ /** Token estimator; default `ceil(chars/4)` summed over messages. */
53
+ countTokens?: (messages: Message[]) => number;
54
+ /**
55
+ * When set, inject a pre-compact system reminder telling the model to persist
56
+ * durable facts via the §7E `memory` tool before the head is summarized. Off by
57
+ * default. Composes with the memory builtin.
58
+ */
59
+ flushToMemory?: boolean;
60
+ }
61
+ /** The event a `beforeLLM` hook receives (§8). */
62
+ export interface BeforeLLMEvent {
63
+ messages: Message[];
64
+ tools: unknown[];
65
+ model: string;
66
+ turn: number;
67
+ }
68
+ /**
69
+ * Build a `beforeLLM` hook (§8) that compacts the transcript once it exceeds
70
+ * `maxTokens`. Below budget it returns nothing (a byte-identical no-op); above it, it
71
+ * replaces the transcript with `[leading system prompt, summary system message,
72
+ * (flush reminder?), …recent tail]`, splitting at a clean `user` boundary so tool
73
+ * groups are never broken.
74
+ */
75
+ export declare function compactor(opts: CompactorOptions): (ev: BeforeLLMEvent) => Promise<{
76
+ messages: Message[];
77
+ } | undefined>;
@@ -0,0 +1,96 @@
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
+ * Cheap, deterministic token estimate: `ceil(chars/4)` over each message's JSON
28
+ * serialization, summed. This is an *estimator*, not a tokenizer — exactness is the
29
+ * host's call. Override via `CompactorOptions.countTokens` for a real tokenizer.
30
+ */
31
+ export function estimateTokens(messages) {
32
+ let total = 0;
33
+ for (const m of messages)
34
+ total += Math.ceil(JSON.stringify(m).length / 4);
35
+ return total;
36
+ }
37
+ /** The system reminder injected when `flushToMemory` is set. */
38
+ const FLUSH_TO_MEMORY_REMINDER = "Before continuing: if anything from earlier is worth keeping, save it with the memory tool now — the earlier transcript is about to be summarized.";
39
+ /** Prefix on the summary system message; hosts/tests key off this to detect a compaction. */
40
+ const SUMMARY_PREFIX = "[Summary of earlier conversation]\n";
41
+ /**
42
+ * Build a `beforeLLM` hook (§8) that compacts the transcript once it exceeds
43
+ * `maxTokens`. Below budget it returns nothing (a byte-identical no-op); above it, it
44
+ * replaces the transcript with `[leading system prompt, summary system message,
45
+ * (flush reminder?), …recent tail]`, splitting at a clean `user` boundary so tool
46
+ * groups are never broken.
47
+ */
48
+ export function compactor(opts) {
49
+ const count = opts.countTokens ?? estimateTokens;
50
+ const keepTail = opts.keepTail ?? Math.floor(opts.maxTokens / 2);
51
+ return async (ev) => {
52
+ const msgs = ev.messages;
53
+ if (count(msgs) <= opts.maxTokens)
54
+ return undefined; // under budget → no-op
55
+ // Preserve a leading system prompt verbatim (identity / soul / skills — never summarized).
56
+ const headEnd = msgs[0]?.role === "system" ? 1 : 0;
57
+ const system = msgs.slice(0, headEnd);
58
+ const split = findTailStart(msgs, headEnd, keepTail, count);
59
+ if (split <= headEnd)
60
+ return undefined; // nothing safely compactible
61
+ const older = msgs.slice(headEnd, split);
62
+ if (older.length === 0)
63
+ return undefined;
64
+ const tail = msgs.slice(split);
65
+ const flushNote = opts.flushToMemory
66
+ ? [{ role: "system", content: FLUSH_TO_MEMORY_REMINDER }]
67
+ : [];
68
+ const summary = await opts.summarize(older);
69
+ const summaryMsg = { role: "system", content: `${SUMMARY_PREFIX}${summary}` };
70
+ return { messages: [...system, summaryMsg, ...flushNote, ...tail] };
71
+ };
72
+ }
73
+ /**
74
+ * Locate the index where the retained tail begins. Tool-pair safety requires the tail
75
+ * to start at a `user` turn: the largest user-boundary tail that still fits `keepTail`.
76
+ * If no user boundary fits within `keepTail`, fall back to the most recent user turn
77
+ * (favoring safety over size). Returns `msgs.length` (no tail) only when there is no
78
+ * user turn after the head at all.
79
+ */
80
+ function findTailStart(msgs, headEnd, keepTail, count) {
81
+ let split = msgs.length;
82
+ for (let i = msgs.length - 1; i > headEnd; i--) {
83
+ if (count(msgs.slice(i)) > keepTail)
84
+ break;
85
+ if (msgs[i].role === "user")
86
+ split = i;
87
+ }
88
+ if (split !== msgs.length)
89
+ return split;
90
+ // No user boundary fit within keepTail — extend back to the most recent user turn.
91
+ for (let i = msgs.length - 1; i > headEnd; i--) {
92
+ if (msgs[i].role === "user")
93
+ return i;
94
+ }
95
+ return msgs.length;
96
+ }
@@ -0,0 +1,90 @@
1
+ import type { Tool } from "../types.js";
2
+ import { Agent, type AgentRunOptions } from "./agent.js";
3
+ import { AgentRuntime } from "./runtime.js";
4
+ /**
5
+ * Bootstrap discovery order (§7E): identity first, durable memory last. Each present
6
+ * file is injected into the soul as a `## <filename>` section, in exactly this order.
7
+ */
8
+ export declare const BOOTSTRAP_ORDER: readonly ["AGENTS.md", "SOUL.md", "IDENTITY.md", "USER.md", "TOOLS.md", "HEARTBEAT.md", "MEMORY.md"];
9
+ /** Per-file bootstrap cap (§7E): a larger file is injected truncated, disk untouched. */
10
+ export declare const MAX_FILE_BYTES: number;
11
+ /** Notice appended to a bootstrap file truncated at {@link MAX_FILE_BYTES}. */
12
+ export declare const TRUNCATION_NOTICE = "\n[truncated: exceeds 2 MB bootstrap cap]";
13
+ /** The silent-no-op sentinel: a heartbeat reply containing this surfaces nothing. */
14
+ export declare const HEARTBEAT_OK = "HEARTBEAT_OK";
15
+ /** The prompt a heartbeat wakes the persona with. Contains `HEARTBEAT_OK` so the
16
+ * model knows the silent-reply contract, and the word "Heartbeat" so a HEARTBEAT.md
17
+ * that keys off it can recognise the trigger. */
18
+ export declare const HEARTBEAT_PROMPT: string;
19
+ /**
20
+ * Compose the bootstrap files present in `dir` into one soul string — the frozen
21
+ * snapshot injected as the system prompt for the whole run. Only present files
22
+ * appear, always in {@link BOOTSTRAP_ORDER}, each as a `## <filename>` section.
23
+ */
24
+ export declare function composeSoul(dir: string): {
25
+ soul: string;
26
+ found: string[];
27
+ };
28
+ /**
29
+ * The `memory` builtin (§7E, file-backed, opt-in). One tool, three actions over
30
+ * `MEMORY.md` (the agent's own notes) and `USER.md` (its model of the user):
31
+ *
32
+ * - `add` — append an entry
33
+ * - `replace` — swap an existing substring for `with`
34
+ * - `remove` — delete an existing substring
35
+ *
36
+ * Every action writes to **disk**. It does **not** touch the live session's system
37
+ * prompt: the frozen-snapshot rule means the edit loads only at the START of the
38
+ * next session (keeping a long-lived persona cache-stable). A `replace`/`remove`
39
+ * whose substring is absent is a loud `isError` — never a silent no-op.
40
+ */
41
+ export declare function memoryTool(dir: string): Tool;
42
+ /** Options for {@link fromDir}. */
43
+ export interface FromDirOptions {
44
+ /** Routing description a delegating model sees; default derived from the dir. */
45
+ does?: string;
46
+ /** Agent name; default the directory's basename. */
47
+ name?: string;
48
+ /** Model id; default `"inherit"` (the runtime's `llm.model`). */
49
+ model?: string;
50
+ /** Extra tools beyond the memory builtin. */
51
+ tools?: Tool[];
52
+ /** Set `false` to omit the memory tool (a read-only persona). */
53
+ memory?: boolean;
54
+ }
55
+ /**
56
+ * The directory IS the agent (§7E). Discovers the bootstrap files in `dir`, composes
57
+ * them into a frozen soul snapshot, and (unless `memory:false`) wires a `memory` tool
58
+ * over the same directory. Returns a plain {@link Agent} — `run`/`asTool`/`startAgent`
59
+ * all apply.
60
+ */
61
+ export declare function fromDir(dir: string, opts?: FromDirOptions): Agent;
62
+ /** Heartbeat configuration for {@link startAgent}. */
63
+ export interface HeartbeatOptions {
64
+ /** Interval between beats, in ms (measured on the runtime's clock). */
65
+ everyMs: number;
66
+ /** Called for each SUBSTANTIVE beat — a `HEARTBEAT_OK` reply is stripped and never
67
+ * surfaces here. */
68
+ onBeat?: (text: string) => void;
69
+ }
70
+ /** A running persona: the live runtime, the collected substantive beats, and `stop`. */
71
+ export interface StartedAgent {
72
+ /** The runtime the persona runs on — expose so a host can `post`/`wake` it (channels). */
73
+ runtime: AgentRuntime;
74
+ /** Substantive beats collected so far (HEARTBEAT_OK replies excluded). */
75
+ beats: string[];
76
+ /** Cancel the heartbeat and close the tree gracefully. */
77
+ stop: () => Promise<void>;
78
+ }
79
+ /**
80
+ * Give a persona its own clock (§7E). On each interval it `post`s a tick to its own
81
+ * inbox (the unsolicited rail — ticks **coalesce**, so a slow beat can't pile up) and,
82
+ * when idle, `wake`s it with {@link HEARTBEAT_PROMPT}. A reply containing
83
+ * {@link HEARTBEAT_OK} is treated as silent (no `onBeat`); only a substantive reply
84
+ * surfaces. All timing goes through the runtime's injectable clock, so a virtual-clock
85
+ * test is deterministic.
86
+ *
87
+ * Inbound channels are the host's job: deliver external events by calling
88
+ * `started.runtime.post`/`wake` on the persona's handle.
89
+ */
90
+ export declare function startAgent(a: Agent, run: AgentRunOptions, hb: HeartbeatOptions): StartedAgent;
@@ -0,0 +1,203 @@
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 { agent } from "./agent.js";
19
+ import { isVerbError } from "./runtime.js";
20
+ /**
21
+ * Bootstrap discovery order (§7E): identity first, durable memory last. Each present
22
+ * file is injected into the soul as a `## <filename>` section, in exactly this order.
23
+ */
24
+ export const BOOTSTRAP_ORDER = [
25
+ "AGENTS.md",
26
+ "SOUL.md",
27
+ "IDENTITY.md",
28
+ "USER.md",
29
+ "TOOLS.md",
30
+ "HEARTBEAT.md",
31
+ "MEMORY.md",
32
+ ];
33
+ /** Per-file bootstrap cap (§7E): a larger file is injected truncated, disk untouched. */
34
+ export const MAX_FILE_BYTES = 2 * 1024 * 1024;
35
+ /** Notice appended to a bootstrap file truncated at {@link MAX_FILE_BYTES}. */
36
+ export const TRUNCATION_NOTICE = "\n[truncated: exceeds 2 MB bootstrap cap]";
37
+ /** The silent-no-op sentinel: a heartbeat reply containing this surfaces nothing. */
38
+ export const HEARTBEAT_OK = "HEARTBEAT_OK";
39
+ /** The prompt a heartbeat wakes the persona with. Contains `HEARTBEAT_OK` so the
40
+ * model knows the silent-reply contract, and the word "Heartbeat" so a HEARTBEAT.md
41
+ * that keys off it can recognise the trigger. */
42
+ export const HEARTBEAT_PROMPT = `Heartbeat. Read your HEARTBEAT.md section and follow it. ` +
43
+ `If nothing needs attention, reply ${HEARTBEAT_OK}.`;
44
+ /** Read a bootstrap file with the byte cap applied; `undefined` if it is absent. */
45
+ function readCapped(file) {
46
+ let buf;
47
+ try {
48
+ buf = fs.readFileSync(file);
49
+ }
50
+ catch {
51
+ return undefined; // absent files are skipped (§7E)
52
+ }
53
+ if (buf.byteLength > MAX_FILE_BYTES) {
54
+ return buf.subarray(0, MAX_FILE_BYTES).toString("utf8") + TRUNCATION_NOTICE;
55
+ }
56
+ return buf.toString("utf8");
57
+ }
58
+ /**
59
+ * Compose the bootstrap files present in `dir` into one soul string — the frozen
60
+ * snapshot injected as the system prompt for the whole run. Only present files
61
+ * appear, always in {@link BOOTSTRAP_ORDER}, each as a `## <filename>` section.
62
+ */
63
+ export function composeSoul(dir) {
64
+ const sections = [];
65
+ const found = [];
66
+ for (const file of BOOTSTRAP_ORDER) {
67
+ const body = readCapped(path.join(dir, file));
68
+ if (body !== undefined) {
69
+ sections.push(`## ${file}\n\n${body.trim()}`);
70
+ found.push(file);
71
+ }
72
+ }
73
+ return { soul: sections.join("\n\n"), found };
74
+ }
75
+ function memoryFile(dir, target) {
76
+ return path.join(dir, target === "user" ? "USER.md" : "MEMORY.md");
77
+ }
78
+ /**
79
+ * The `memory` builtin (§7E, file-backed, opt-in). One tool, three actions over
80
+ * `MEMORY.md` (the agent's own notes) and `USER.md` (its model of the user):
81
+ *
82
+ * - `add` — append an entry
83
+ * - `replace` — swap an existing substring for `with`
84
+ * - `remove` — delete an existing substring
85
+ *
86
+ * Every action writes to **disk**. It does **not** touch the live session's system
87
+ * prompt: the frozen-snapshot rule means the edit loads only at the START of the
88
+ * next session (keeping a long-lived persona cache-stable). A `replace`/`remove`
89
+ * whose substring is absent is a loud `isError` — never a silent no-op.
90
+ */
91
+ export function memoryTool(dir) {
92
+ return defineTool({
93
+ name: "memory",
94
+ description: "Persist durable memory. action=add appends an entry; replace swaps an " +
95
+ "existing substring (with); remove deletes one. target=self (MEMORY.md, " +
96
+ "default) or user (USER.md). Writes persist to disk and load at the START of " +
97
+ "your NEXT session — they do NOT change your current context.",
98
+ inputSchema: {
99
+ type: "object",
100
+ properties: {
101
+ action: { type: "string", enum: ["add", "replace", "remove"] },
102
+ target: { type: "string", enum: ["self", "user"], description: "self=MEMORY.md (default), user=USER.md" },
103
+ text: { type: "string", description: "For add: the entry. For replace/remove: the existing text." },
104
+ with: { type: "string", description: "For replace: the replacement text." },
105
+ },
106
+ required: ["action", "text"],
107
+ },
108
+ run: (args) => {
109
+ const target = args.target ?? "self";
110
+ const file = memoryFile(dir, target);
111
+ const text = String(args.text);
112
+ const current = readCapped(file) ?? "";
113
+ let next;
114
+ switch (args.action) {
115
+ case "add":
116
+ next = (current.trimEnd() + `\n- ${text}\n`).trimStart();
117
+ break;
118
+ case "replace":
119
+ if (!current.includes(text))
120
+ return { output: `not found: ${text}`, isError: true };
121
+ next = current.replace(text, String(args.with ?? ""));
122
+ break;
123
+ case "remove":
124
+ if (!current.includes(text))
125
+ return { output: `not found: ${text}`, isError: true };
126
+ next = current.replace(text, "");
127
+ break;
128
+ default:
129
+ return { output: `unknown action: ${String(args.action)}`, isError: true };
130
+ }
131
+ fs.mkdirSync(path.dirname(file), { recursive: true });
132
+ fs.writeFileSync(file, next);
133
+ return `ok (${String(args.action)} → ${path.basename(file)}); loads next session`;
134
+ },
135
+ });
136
+ }
137
+ /**
138
+ * The directory IS the agent (§7E). Discovers the bootstrap files in `dir`, composes
139
+ * them into a frozen soul snapshot, and (unless `memory:false`) wires a `memory` tool
140
+ * over the same directory. Returns a plain {@link Agent} — `run`/`asTool`/`startAgent`
141
+ * all apply.
142
+ */
143
+ export function fromDir(dir, opts = {}) {
144
+ const { soul } = composeSoul(dir);
145
+ const tools = [...(opts.tools ?? [])];
146
+ if (opts.memory !== false)
147
+ tools.push(memoryTool(dir));
148
+ return agent(opts.name ?? path.basename(dir), {
149
+ does: opts.does ?? `persona agent from ${dir}`,
150
+ soul,
151
+ model: opts.model ?? "inherit",
152
+ uses: { tools },
153
+ });
154
+ }
155
+ /**
156
+ * Give a persona its own clock (§7E). On each interval it `post`s a tick to its own
157
+ * inbox (the unsolicited rail — ticks **coalesce**, so a slow beat can't pile up) and,
158
+ * when idle, `wake`s it with {@link HEARTBEAT_PROMPT}. A reply containing
159
+ * {@link HEARTBEAT_OK} is treated as silent (no `onBeat`); only a substantive reply
160
+ * surfaces. All timing goes through the runtime's injectable clock, so a virtual-clock
161
+ * test is deterministic.
162
+ *
163
+ * Inbound channels are the host's job: deliver external events by calling
164
+ * `started.runtime.post`/`wake` on the persona's handle.
165
+ */
166
+ export function startAgent(a, run, hb) {
167
+ const runtime = a.createRuntime(run);
168
+ const handle = runtime.spawn(runtime.root, a.name);
169
+ if (isVerbError(handle))
170
+ throw new Error(handle.error);
171
+ const beats = [];
172
+ let stopped = false;
173
+ let cancel;
174
+ const beat = () => {
175
+ if (stopped)
176
+ return;
177
+ // Unsolicited rail: the tick lands in the inbox (coalesces); a wake — only when
178
+ // idle — drains the whole inbox as one turn.
179
+ runtime.post(handle, { from: "clock", channel: "timer", text: "tick" });
180
+ if (handle.state === "idle") {
181
+ const woke = runtime.wake(handle, HEARTBEAT_PROMPT);
182
+ if (woke.ok) {
183
+ void runtime.wait(handle).then((r) => {
184
+ if (!r.isError && !r.text.includes(HEARTBEAT_OK)) {
185
+ beats.push(r.text);
186
+ hb.onBeat?.(r.text);
187
+ }
188
+ });
189
+ }
190
+ }
191
+ cancel = runtime.clock.setTimeout(beat, hb.everyMs); // self-reschedule → one live timer
192
+ };
193
+ cancel = runtime.clock.setTimeout(beat, hb.everyMs);
194
+ return {
195
+ runtime,
196
+ beats,
197
+ stop: async () => {
198
+ stopped = true;
199
+ cancel?.();
200
+ await runtime.close(runtime.root);
201
+ },
202
+ };
203
+ }
@@ -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";