toolnexus 0.21.0 → 0.22.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/agents/agent.d.ts +12 -1
- package/dist/agents/agent.js +43 -7
- package/dist/agents/compaction.d.ts +19 -0
- package/dist/agents/compaction.js +26 -4
- package/dist/agents/runtime.d.ts +5 -0
- package/dist/agents/runtime.js +13 -2
- package/dist/builtin.d.ts +16 -0
- package/dist/builtin.js +315 -17
- package/dist/builtin_extras.d.ts +80 -0
- package/dist/builtin_extras.js +248 -0
- package/dist/client.d.ts +74 -2
- package/dist/client.js +292 -55
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/mcp.d.ts +32 -1
- package/dist/mcp.js +184 -6
- package/dist/models.d.ts +20 -0
- package/dist/models.js +46 -0
- package/dist/promptcache.d.ts +21 -0
- package/dist/promptcache.js +59 -0
- package/dist/store.d.ts +32 -0
- package/dist/store.js +131 -0
- package/dist/tooltiers.d.ts +49 -0
- package/dist/tooltiers.js +197 -0
- package/dist/types.d.ts +19 -0
- package/package.json +2 -2
- package/src/agents/agent.ts +39 -8
- package/src/agents/compaction.ts +45 -4
- package/src/agents/runtime.ts +16 -2
- package/src/builtin.ts +333 -18
- package/src/builtin_extras.ts +285 -0
- package/src/client.ts +317 -60
- package/src/index.ts +5 -0
- package/src/mcp.ts +204 -7
- package/src/models.ts +56 -0
- package/src/promptcache.ts +63 -0
- package/src/store.ts +142 -0
- package/src/tooltiers.ts +221 -0
- package/src/types.ts +20 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool tiers (SPEC §8 "Prompt caching" → Tool tiers, change add-prompt-cache phase 2). With
|
|
3
|
+
* `ClientOptions.toolTiers` set, only CORE tools are sent with full schemas. LISTED tools appear
|
|
4
|
+
* as "name — one line" and DEFERRED tools by name only, both inside the description of a
|
|
5
|
+
* `tool_search` tool that returns full schemas on demand, as a tool result: the tools array
|
|
6
|
+
* never changes mid-session, so the cached prefix survives. A loaded tool is called by name, or
|
|
7
|
+
* through `call_tool {name, arguments}`, which the loop unwraps BEFORE hooks.
|
|
8
|
+
*/
|
|
9
|
+
import type { Tool, ToolResult } from "./types.js";
|
|
10
|
+
/** `ClientOptions.toolTiers`. */
|
|
11
|
+
export interface ToolTierOptions {
|
|
12
|
+
/** Always sent with their full schema. */
|
|
13
|
+
core?: string[];
|
|
14
|
+
/** Named only. Every other tool is listed (name + one line). A tool registered after the
|
|
15
|
+
* plan froze is deferred and found by search. */
|
|
16
|
+
deferred?: string[];
|
|
17
|
+
/** Caps the tokens sent up front (core schemas + tool_search + call_tool). Absent/0 ⇒ 5% of
|
|
18
|
+
* `contextWindow`; both absent ⇒ no budget. Over budget, the least-used core tools move to
|
|
19
|
+
* listed, then the least-used listed tools to deferred. */
|
|
20
|
+
budgetTokens?: number;
|
|
21
|
+
contextWindow?: number;
|
|
22
|
+
/** Host per-tool usage stat (higher = more used), read only when the plan is built. */
|
|
23
|
+
usageCount?: (name: string) => number;
|
|
24
|
+
/** Token estimate of a JSON-encodable value; default ceil(bytes/4). */
|
|
25
|
+
countTokens?: (v: unknown) => number;
|
|
26
|
+
}
|
|
27
|
+
export type ToolTier = "core" | "listed" | "deferred";
|
|
28
|
+
/** The frozen tier assignment for one toolkit. */
|
|
29
|
+
export interface ToolPlan {
|
|
30
|
+
tier: Map<string, ToolTier>;
|
|
31
|
+
core: string[];
|
|
32
|
+
listed: string[];
|
|
33
|
+
deferred: string[];
|
|
34
|
+
search: Tool;
|
|
35
|
+
call: Tool;
|
|
36
|
+
}
|
|
37
|
+
export declare function defaultCountTokens(v: unknown): number;
|
|
38
|
+
export declare function toolDef(t: Tool): Record<string, unknown>;
|
|
39
|
+
/** Build a tier plan over `tools` (exported for tests and host UIs). */
|
|
40
|
+
export declare function buildToolPlan(o: ToolTierOptions, tools: Tool[]): ToolPlan;
|
|
41
|
+
/** `call_tool {name, arguments}` → the real call; `undefined` when `name` is not call_tool. */
|
|
42
|
+
export declare function unwrapCallTool(name: string, args: Record<string, unknown>): {
|
|
43
|
+
name: string;
|
|
44
|
+
args: Record<string, unknown>;
|
|
45
|
+
} | undefined;
|
|
46
|
+
/** Go's json escapes `<`, `>`, `&`, U+2028 and U+2029 inside strings; they never occur outside one. */
|
|
47
|
+
export declare function goHtmlEscape(json: string): string;
|
|
48
|
+
/** Run `tool_search` over every tool (any tier). */
|
|
49
|
+
export declare function toolSearch(all: Tool[], get: (name: string) => Tool | undefined, args: Record<string, unknown>): ToolResult;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
2
|
+
export function defaultCountTokens(v) {
|
|
3
|
+
return Math.ceil(Buffer.byteLength(JSON.stringify(v) ?? "", "utf8") / 4);
|
|
4
|
+
}
|
|
5
|
+
export function toolDef(t) {
|
|
6
|
+
return { name: t.name, description: t.description, inputSchema: t.inputSchema };
|
|
7
|
+
}
|
|
8
|
+
function firstLine(s) {
|
|
9
|
+
const i = s.indexOf("\n");
|
|
10
|
+
if (i >= 0)
|
|
11
|
+
s = s.slice(0, i);
|
|
12
|
+
// Go cuts at 120 BYTES; cut at 120 UTF-8 bytes too, without splitting a character.
|
|
13
|
+
if (Buffer.byteLength(s, "utf8") > 120) {
|
|
14
|
+
let out = "";
|
|
15
|
+
for (const ch of s) {
|
|
16
|
+
if (Buffer.byteLength(out + ch, "utf8") > 120)
|
|
17
|
+
break;
|
|
18
|
+
out += ch;
|
|
19
|
+
}
|
|
20
|
+
s = out + "…";
|
|
21
|
+
}
|
|
22
|
+
return s;
|
|
23
|
+
}
|
|
24
|
+
const notRun = async () => ({ output: "engine-internal tool: run by the client loop", isError: true });
|
|
25
|
+
function rebuild(p, byName) {
|
|
26
|
+
p.core = [];
|
|
27
|
+
p.listed = [];
|
|
28
|
+
p.deferred = [];
|
|
29
|
+
for (const [n, t] of p.tier)
|
|
30
|
+
(t === "core" ? p.core : t === "listed" ? p.listed : p.deferred).push(n);
|
|
31
|
+
p.core.sort(cmp);
|
|
32
|
+
p.listed.sort(cmp);
|
|
33
|
+
p.deferred.sort(cmp);
|
|
34
|
+
let d = 'Load the full schema of tools that are not shown in full, then call them by name (or with call_tool). Query by keywords, or "select:a,b" for exact names.';
|
|
35
|
+
if (p.listed.length) {
|
|
36
|
+
d += "\n\nAvailable tools:";
|
|
37
|
+
for (const n of p.listed) {
|
|
38
|
+
d += "\n- " + n;
|
|
39
|
+
const line = firstLine(byName.get(n)?.description ?? "");
|
|
40
|
+
if (line)
|
|
41
|
+
d += " — " + line;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (p.deferred.length)
|
|
45
|
+
d += "\n\nMore tools (name only): " + p.deferred.join(", ");
|
|
46
|
+
p.search = {
|
|
47
|
+
name: "tool_search",
|
|
48
|
+
description: d,
|
|
49
|
+
inputSchema: {
|
|
50
|
+
type: "object",
|
|
51
|
+
properties: {
|
|
52
|
+
query: { type: "string", description: "Keywords, or select:name1,name2" },
|
|
53
|
+
limit: { type: "number", description: "Maximum results for a keyword query (default 5)" },
|
|
54
|
+
},
|
|
55
|
+
required: ["query"],
|
|
56
|
+
additionalProperties: false,
|
|
57
|
+
},
|
|
58
|
+
source: "builtin",
|
|
59
|
+
hints: { readOnly: true, destructive: false, openWorld: false },
|
|
60
|
+
execute: notRun,
|
|
61
|
+
};
|
|
62
|
+
p.call = {
|
|
63
|
+
name: "call_tool",
|
|
64
|
+
description: "Call a tool loaded with tool_search, by name, with its arguments.",
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: "object",
|
|
67
|
+
properties: { name: { type: "string" }, arguments: { type: "object" } },
|
|
68
|
+
required: ["name"],
|
|
69
|
+
additionalProperties: false,
|
|
70
|
+
},
|
|
71
|
+
source: "builtin",
|
|
72
|
+
execute: notRun,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Build a tier plan over `tools` (exported for tests and host UIs). */
|
|
76
|
+
export function buildToolPlan(o, tools) {
|
|
77
|
+
const count = o.countTokens ?? defaultCountTokens;
|
|
78
|
+
const usage = (n) => (o.usageCount ? o.usageCount(n) : 0);
|
|
79
|
+
const byName = new Map(tools.map((t) => [t.name, t]));
|
|
80
|
+
const tier = new Map();
|
|
81
|
+
for (const t of tools)
|
|
82
|
+
tier.set(t.name, "listed");
|
|
83
|
+
for (const n of o.deferred ?? [])
|
|
84
|
+
if (tier.has(n))
|
|
85
|
+
tier.set(n, "deferred");
|
|
86
|
+
for (const n of o.core ?? [])
|
|
87
|
+
if (tier.has(n))
|
|
88
|
+
tier.set(n, "core");
|
|
89
|
+
const p = { tier };
|
|
90
|
+
rebuild(p, byName);
|
|
91
|
+
let budget = o.budgetTokens ?? 0;
|
|
92
|
+
if (!budget && o.contextWindow && o.contextWindow > 0)
|
|
93
|
+
budget = Math.floor(o.contextWindow / 20);
|
|
94
|
+
if (budget > 0) {
|
|
95
|
+
const cost = () => {
|
|
96
|
+
let n = count(toolDef(p.search)) + count(toolDef(p.call));
|
|
97
|
+
for (const name of p.core)
|
|
98
|
+
n += count(toolDef(byName.get(name)));
|
|
99
|
+
return n;
|
|
100
|
+
};
|
|
101
|
+
// least used first; ties: bigger schema first, then name
|
|
102
|
+
const order = (names) => [...names].sort((a, b) => {
|
|
103
|
+
const ua = usage(a), ub = usage(b);
|
|
104
|
+
if (ua !== ub)
|
|
105
|
+
return ua - ub;
|
|
106
|
+
const sa = count(toolDef(byName.get(a))), sb = count(toolDef(byName.get(b)));
|
|
107
|
+
if (sa !== sb)
|
|
108
|
+
return sb - sa;
|
|
109
|
+
return cmp(a, b);
|
|
110
|
+
});
|
|
111
|
+
for (const n of order(p.core)) {
|
|
112
|
+
if (cost() <= budget)
|
|
113
|
+
break;
|
|
114
|
+
tier.set(n, "listed");
|
|
115
|
+
rebuild(p, byName);
|
|
116
|
+
}
|
|
117
|
+
for (const n of order(p.listed)) {
|
|
118
|
+
if (cost() <= budget)
|
|
119
|
+
break;
|
|
120
|
+
tier.set(n, "deferred");
|
|
121
|
+
rebuild(p, byName);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return p;
|
|
125
|
+
}
|
|
126
|
+
/** `call_tool {name, arguments}` → the real call; `undefined` when `name` is not call_tool. */
|
|
127
|
+
export function unwrapCallTool(name, args) {
|
|
128
|
+
if (name !== "call_tool")
|
|
129
|
+
return undefined;
|
|
130
|
+
const real = typeof args?.name === "string" ? args.name : "";
|
|
131
|
+
const inner = args?.arguments && typeof args.arguments === "object" && !Array.isArray(args.arguments) ? args.arguments : {};
|
|
132
|
+
return { name: real, args: inner };
|
|
133
|
+
}
|
|
134
|
+
/** Keys sorted at every level, so the loaded definitions print the same bytes as the Go port. */
|
|
135
|
+
function sortKeys(v) {
|
|
136
|
+
if (Array.isArray(v))
|
|
137
|
+
return v.map(sortKeys);
|
|
138
|
+
if (v && typeof v === "object") {
|
|
139
|
+
const out = {};
|
|
140
|
+
for (const k of Object.keys(v).sort(cmp))
|
|
141
|
+
out[k] = sortKeys(v[k]);
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
return v;
|
|
145
|
+
}
|
|
146
|
+
/** Go's json escapes `<`, `>`, `&`, U+2028 and U+2029 inside strings; they never occur outside one. */
|
|
147
|
+
export function goHtmlEscape(json) {
|
|
148
|
+
return json.replace(/[<>&\u2028\u2029]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
|
|
149
|
+
}
|
|
150
|
+
/** Run `tool_search` over every tool (any tier). */
|
|
151
|
+
export function toolSearch(all, get, args) {
|
|
152
|
+
const q = (typeof args.query === "string" ? args.query : "").trim();
|
|
153
|
+
if (!q)
|
|
154
|
+
return { output: "tool_search: query is required", isError: true };
|
|
155
|
+
let picked = [];
|
|
156
|
+
if (q.startsWith("select:")) {
|
|
157
|
+
const missing = [];
|
|
158
|
+
for (const raw of q.slice("select:".length).split(",")) {
|
|
159
|
+
const n = raw.trim();
|
|
160
|
+
if (!n)
|
|
161
|
+
continue;
|
|
162
|
+
const t = get(n);
|
|
163
|
+
if (t)
|
|
164
|
+
picked.push(t);
|
|
165
|
+
else
|
|
166
|
+
missing.push(n);
|
|
167
|
+
}
|
|
168
|
+
if (missing.length && !picked.length)
|
|
169
|
+
return { output: "tool_search: no such tool: " + missing.join(", "), isError: true };
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
const limit = typeof args.limit === "number" && args.limit >= 1 ? Math.trunc(args.limit) : 5;
|
|
173
|
+
const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
|
|
174
|
+
const hits = [];
|
|
175
|
+
for (const t of all) {
|
|
176
|
+
const name = t.name.toLowerCase(), desc = t.description.toLowerCase();
|
|
177
|
+
let s = 0;
|
|
178
|
+
for (const term of terms) {
|
|
179
|
+
if (name.includes(term))
|
|
180
|
+
s += 3;
|
|
181
|
+
if (desc.includes(term))
|
|
182
|
+
s += 1;
|
|
183
|
+
}
|
|
184
|
+
if (s > 0)
|
|
185
|
+
hits.push({ t, score: s });
|
|
186
|
+
}
|
|
187
|
+
hits.sort((a, b) => b.score - a.score || cmp(a.t.name, b.t.name));
|
|
188
|
+
picked = hits.slice(0, limit).map((h) => h.t);
|
|
189
|
+
}
|
|
190
|
+
if (!picked.length)
|
|
191
|
+
return { output: "No matching tools.", isError: false, metadata: { count: 0 } };
|
|
192
|
+
return {
|
|
193
|
+
output: "Loaded tools (call by name, or with call_tool):\n" + goHtmlEscape(JSON.stringify(sortKeys(picked.map(toolDef)), null, 2)),
|
|
194
|
+
isError: false,
|
|
195
|
+
metadata: { count: picked.length, tools: picked.map((t) => t.name) },
|
|
196
|
+
};
|
|
197
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -27,6 +27,22 @@ export interface ToolContext {
|
|
|
27
27
|
timeout?: number;
|
|
28
28
|
/** Present ONLY on a post-waitFor retry (§10): the resolution of a prior suspension. */
|
|
29
29
|
answer?: Answer;
|
|
30
|
+
/** The model's id for this tool call, set by the client loop (§4C). A message's calls run
|
|
31
|
+
* concurrently, so this is how a tool tells its sibling calls apart. Absent outside the loop. */
|
|
32
|
+
callId?: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Advisory behavior hints (named after MCP tool annotations, §4C), for host permission gates
|
|
36
|
+
* built on `hooks.beforeTool`. Absent on a tool = unknown; treat it as mutating. Never sent to
|
|
37
|
+
* the model: no adapter or wire output carries them.
|
|
38
|
+
*/
|
|
39
|
+
export interface ToolHints {
|
|
40
|
+
/** The tool does not change its environment. */
|
|
41
|
+
readOnly: boolean;
|
|
42
|
+
/** The tool may overwrite or delete things (meaningful when not readOnly). */
|
|
43
|
+
destructive: boolean;
|
|
44
|
+
/** The tool reaches outside the host (network, external systems). */
|
|
45
|
+
openWorld: boolean;
|
|
30
46
|
}
|
|
31
47
|
/**
|
|
32
48
|
* §10 Suspension. A tool that needs an out-of-band, async resolution (login, approval,
|
|
@@ -77,6 +93,9 @@ export interface Tool {
|
|
|
77
93
|
inputSchema: JSONSchema;
|
|
78
94
|
source: ToolSource;
|
|
79
95
|
execute(args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult>;
|
|
96
|
+
/** What the tool may do (§4C). Builtins always carry hints; MCP tools carry them when the
|
|
97
|
+
* server sends annotations. Advisory: a server can mislabel its tools. */
|
|
98
|
+
hints?: ToolHints;
|
|
80
99
|
}
|
|
81
100
|
export type McpStatus = "connected" | "disabled" | "failed";
|
|
82
101
|
/** Replace anything outside [a-zA-Z0-9_-] with "_" (same as opencode). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolnexus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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 test/agents.test.ts test/agent-hooks.test.ts test/agent-inprocess.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts test/translate.test.ts test/multimodal.test.ts test/classifier.test.ts test/acp.test.ts test/issues.test.ts test/host-boundary.test.ts test/judge.test.ts test/batteries.test.ts",
|
|
20
|
+
"test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/agent-inprocess.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts test/translate.test.ts test/multimodal.test.ts test/classifier.test.ts test/acp.test.ts test/issues.test.ts test/host-boundary.test.ts test/judge.test.ts test/batteries.test.ts test/host-engine.test.ts test/host-turn-durability.test.ts test/host-engine-asks.test.ts",
|
|
21
21
|
"example": "node --experimental-strip-types examples/basic.ts",
|
|
22
22
|
"example:judge": "node --experimental-strip-types examples/judge.ts",
|
|
23
23
|
"example:multimodal": "node --experimental-strip-types examples/multimodal.ts"
|
package/src/agents/agent.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import type { PromptInput } from "../content.js"
|
|
11
11
|
import fs from "node:fs"
|
|
12
12
|
import { defineTool } from "../native.js"
|
|
13
|
-
import type { Answer, Request, Tool } from "../types.js"
|
|
13
|
+
import type { Answer, Request, Tool, ToolContext } from "../types.js"
|
|
14
14
|
import { AgentRuntime, isVerbError, S, type AgentDef, type Budget, type Handle, type RuntimeOptions, type TaskResult } from "./runtime.js"
|
|
15
15
|
import { Loop, guardedHooks, type Completion, type Guardrail } from "./loop.js"
|
|
16
16
|
import type { ClientOptions } from "../client.js"
|
|
@@ -141,6 +141,27 @@ export class Agent {
|
|
|
141
141
|
return { ...r, runtime }
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/** `run` that interrupts the child when `signal` aborts. */
|
|
145
|
+
private async runSignal(prompt: string, opts: AgentRunOptions, signal?: AbortSignal): Promise<{ text: string; isError: boolean; turns: number; totalTokens: number; cancelled?: boolean }> {
|
|
146
|
+
if (!signal) return this.run(prompt, opts)
|
|
147
|
+
const reason = () => (signal.reason instanceof Error ? signal.reason.message : String(signal.reason ?? "aborted"))
|
|
148
|
+
if (signal.aborted) return { text: reason(), isError: true, turns: 0, totalTokens: 0, cancelled: true }
|
|
149
|
+
const runtime = this.createRuntime(opts)
|
|
150
|
+
const h = runtime.spawn(runtime.root, this.name)
|
|
151
|
+
if (isVerbError(h)) return { text: h.error, isError: true, turns: 0, totalTokens: 0 }
|
|
152
|
+
const onAbort = () => void runtime.close(runtime.root, { force: true }).catch(() => {})
|
|
153
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
154
|
+
try {
|
|
155
|
+
const woke = runtime.wake(h, prompt)
|
|
156
|
+
const r = await runtime.wait(h as Handle)
|
|
157
|
+
if (signal.aborted) return { text: reason(), isError: true, turns: r.turns, totalTokens: r.totalTokens, cancelled: true }
|
|
158
|
+
if (woke.ok && r.status !== "pending") await runtime.close(runtime.root)
|
|
159
|
+
return r
|
|
160
|
+
} finally {
|
|
161
|
+
signal.removeEventListener("abort", onAbort)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
144
165
|
/**
|
|
145
166
|
* The bridge: an Agent IS a Tool. Drop the result into the classic API's
|
|
146
167
|
* `extraTools`; the agent's own loop runs in isolation and the classic run
|
|
@@ -148,6 +169,18 @@ export class Agent {
|
|
|
148
169
|
* `{agent, turns, totalTokens}`.
|
|
149
170
|
*/
|
|
150
171
|
asTool(opts: AgentRunOptions = {}): Tool {
|
|
172
|
+
return this.asToolWith(() => opts)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* `asTool` with options built per call from the calling tool's context (SPEC §4C), so the
|
|
177
|
+
* child runs under the parent turn's gates: set `hooks` (the same beforeTool permission gate —
|
|
178
|
+
* it also sees the child's tool calls), `waitFor` (the same ask bridge) and `onMetric`. A
|
|
179
|
+
* `hooks` on the agent's own spec replaces `opts.hooks` for that agent, so leave it unset when
|
|
180
|
+
* the gate must hold. When the calling turn is cancelled (`ctx.signal`), the child is
|
|
181
|
+
* interrupted and the tool returns a `cancelled:` error result.
|
|
182
|
+
*/
|
|
183
|
+
asToolWith(per: (ctx?: ToolContext) => AgentRunOptions): Tool {
|
|
151
184
|
return defineTool({
|
|
152
185
|
name: this.name,
|
|
153
186
|
description: this.spec.does,
|
|
@@ -156,13 +189,11 @@ export class Agent {
|
|
|
156
189
|
properties: { prompt: { type: "string", description: "The task for this agent" } },
|
|
157
190
|
required: ["prompt"],
|
|
158
191
|
},
|
|
159
|
-
run: async (args: Record<string, unknown
|
|
160
|
-
const r = await this.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
metadata: { agent: this.name, turns: r.turns, totalTokens: r.totalTokens },
|
|
165
|
-
}
|
|
192
|
+
run: async (args: Record<string, unknown>, ctx?: ToolContext) => {
|
|
193
|
+
const r = await this.runSignal(String(args.prompt), per(ctx), ctx?.signal)
|
|
194
|
+
const metadata = { agent: this.name, turns: r.turns, totalTokens: r.totalTokens }
|
|
195
|
+
if (r.cancelled) return { output: `cancelled: ${r.text}`, isError: true, metadata }
|
|
196
|
+
return { output: r.text, isError: r.isError, metadata }
|
|
166
197
|
},
|
|
167
198
|
})
|
|
168
199
|
}
|
package/src/agents/compaction.ts
CHANGED
|
@@ -102,6 +102,26 @@ export interface CompactorOptions {
|
|
|
102
102
|
* default. Composes with the memory builtin.
|
|
103
103
|
*/
|
|
104
104
|
flushToMemory?: boolean
|
|
105
|
+
/**
|
|
106
|
+
* Called after each compaction with what changed, so a host can tell its UI or log (SPEC §4C).
|
|
107
|
+
* Never called on a no-op. Awaited before the next model call.
|
|
108
|
+
*/
|
|
109
|
+
onCompact?: (info: CompactInfo) => void | Promise<void>
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** One compaction, for {@link CompactorOptions.onCompact}. Token counts come from `countTokens`. */
|
|
113
|
+
export interface CompactInfo {
|
|
114
|
+
tokensBefore: number
|
|
115
|
+
tokensAfter: number
|
|
116
|
+
messagesBefore: number
|
|
117
|
+
messagesAfter: number
|
|
118
|
+
/** How many messages were folded into the summary. */
|
|
119
|
+
summarized: number
|
|
120
|
+
/** The text `summarize` returned (without the header line). */
|
|
121
|
+
summary: string
|
|
122
|
+
/** The summarized messages, verbatim, so a host can persist them (the loop saves only the
|
|
123
|
+
* compacted transcript). */
|
|
124
|
+
folded: Message[]
|
|
105
125
|
}
|
|
106
126
|
|
|
107
127
|
/** The event a `beforeLLM` hook receives (§8). */
|
|
@@ -134,7 +154,8 @@ export function compactor(
|
|
|
134
154
|
|
|
135
155
|
return async (ev: BeforeLLMEvent): Promise<{ messages: Message[] } | undefined> => {
|
|
136
156
|
const msgs = ev.messages
|
|
137
|
-
|
|
157
|
+
const before = count(msgs)
|
|
158
|
+
if (before <= opts.maxTokens) return undefined // under budget → no-op
|
|
138
159
|
|
|
139
160
|
// Preserve a leading system prompt verbatim (identity / soul / skills — never summarized).
|
|
140
161
|
const headEnd = msgs[0]?.role === "system" ? 1 : 0
|
|
@@ -143,9 +164,17 @@ export function compactor(
|
|
|
143
164
|
const split = findTailStart(msgs, headEnd, keepTail, count)
|
|
144
165
|
if (split <= headEnd) return undefined // nothing safely compactible
|
|
145
166
|
|
|
146
|
-
|
|
147
|
-
if (older.length === 0) return undefined
|
|
167
|
+
let older = msgs.slice(headEnd, split)
|
|
148
168
|
const tail = msgs.slice(split)
|
|
169
|
+
// The latest user prompt is never summarized away. When the only clean boundary is the first
|
|
170
|
+
// message after the system prompt (one prompt followed by a long agentic run), keep that
|
|
171
|
+
// prompt verbatim and summarize the work after it: [system, prompt, summary].
|
|
172
|
+
let pinned: Message[] = []
|
|
173
|
+
if (split === msgs.length && headEnd < msgs.length && isBoundary(msgs[headEnd])) {
|
|
174
|
+
pinned = msgs.slice(headEnd, headEnd + 1)
|
|
175
|
+
older = msgs.slice(headEnd + 1)
|
|
176
|
+
}
|
|
177
|
+
if (older.length === 0) return undefined
|
|
149
178
|
|
|
150
179
|
const flushNote: Message[] = opts.flushToMemory
|
|
151
180
|
? [{ role: "system", content: FLUSH_TO_MEMORY_REMINDER }]
|
|
@@ -154,7 +183,19 @@ export function compactor(
|
|
|
154
183
|
const summary = await opts.summarize(older)
|
|
155
184
|
const summaryMsg: Message = { role: "system", content: `${SUMMARY_PREFIX}${summary}` }
|
|
156
185
|
|
|
157
|
-
|
|
186
|
+
const out = [...system, ...pinned, summaryMsg, ...flushNote, ...tail]
|
|
187
|
+
const after = count(out)
|
|
188
|
+
if (after >= before) return undefined // compaction would not shrink the transcript → no-op
|
|
189
|
+
await opts.onCompact?.({
|
|
190
|
+
tokensBefore: before,
|
|
191
|
+
tokensAfter: after,
|
|
192
|
+
messagesBefore: msgs.length,
|
|
193
|
+
messagesAfter: out.length,
|
|
194
|
+
summarized: older.length,
|
|
195
|
+
summary,
|
|
196
|
+
folded: older,
|
|
197
|
+
})
|
|
198
|
+
return { messages: out }
|
|
158
199
|
}
|
|
159
200
|
}
|
|
160
201
|
|
package/src/agents/runtime.ts
CHANGED
|
@@ -390,6 +390,11 @@ export interface RuntimeOptions {
|
|
|
390
390
|
/** §8 observability sink applied to EVERY agent's client, unless that agent's def
|
|
391
391
|
* sets its own `onMetric`. Resolves independently of `hooks`. Forwarded verbatim. */
|
|
392
392
|
onMetric?: (ev: MetricEvent) => void
|
|
393
|
+
/** The runtime-level §10 interpreter (SPEC §4C): answers a suspension when no agent on the
|
|
394
|
+
* handle's chain has its own `waitFor`. Absent ⇒ unchanged (the suspension goes durable). A
|
|
395
|
+
* host running an agent as a tool (`asToolWith`) sets it so the child's asks reach the same
|
|
396
|
+
* bridge as the parent turn. */
|
|
397
|
+
waitFor?: (request: Request) => Promise<Answer>
|
|
393
398
|
}
|
|
394
399
|
|
|
395
400
|
interface GateWaiter {
|
|
@@ -855,11 +860,20 @@ export class AgentRuntime {
|
|
|
855
860
|
* escalation traced (nearest interpreter wins; the Request's `data.path` names
|
|
856
861
|
* the suspended subtree that is asking). */
|
|
857
862
|
private wrapWaitFor(h: Handle): ((req: Request) => Promise<Answer>) | undefined {
|
|
858
|
-
const
|
|
863
|
+
const own = h.def.waitFor
|
|
864
|
+
let waitFor = own
|
|
865
|
+
if (!waitFor && this.opts.waitFor) {
|
|
866
|
+
// The runtime-level bridge answers only when NO agent on the chain has its own waitFor;
|
|
867
|
+
// otherwise the ask escalates to that nearest interpreter, as before.
|
|
868
|
+
let a: Handle | null = h.parent
|
|
869
|
+
while (a && !a.def.waitFor) a = a.parent
|
|
870
|
+
if (!a) waitFor = this.opts.waitFor
|
|
871
|
+
}
|
|
859
872
|
if (!waitFor) return undefined
|
|
873
|
+
const answerer = own ? h.id : this.root.id
|
|
860
874
|
return async (req: Request): Promise<Answer> => {
|
|
861
875
|
const path = Array.isArray(req.data?.path) ? (req.data!.path as string[]).join("/") : h.id
|
|
862
|
-
this.t(`${path}: escalate → ${
|
|
876
|
+
this.t(`${path}: escalate → ${answerer} answers ("nearest interpreter")`)
|
|
863
877
|
return waitFor(req)
|
|
864
878
|
}
|
|
865
879
|
}
|