@juno-ai/bind 2.0.0 → 4.0.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 +1153 -60
- package/contracts/index.d.ts +1 -1
- package/contracts/index.js +1 -1
- package/contracts/turn.d.ts +31 -7
- package/contracts/turn.js +45 -0
- package/index.d.ts +16 -5
- package/index.js +16 -5
- package/loop/index.d.ts +1 -0
- package/loop/index.js +1 -0
- package/loop/tool-loop.d.ts +260 -0
- package/loop/tool-loop.js +276 -0
- package/package.json +22 -2
- package/plugins/activation.d.ts +67 -0
- package/plugins/activation.js +61 -0
- package/plugins/index.d.ts +3 -0
- package/plugins/index.js +3 -0
- package/plugins/registry.d.ts +52 -0
- package/plugins/registry.js +54 -0
- package/plugins/tool.d.ts +164 -0
- package/plugins/tool.js +9 -0
- package/routing/billing-basis.d.ts +48 -0
- package/routing/billing-basis.js +67 -0
- package/routing/circuit-breaker.d.ts +2 -2
- package/routing/errors.d.ts +1 -1
- package/routing/executor.d.ts +3 -3
- package/routing/executor.js +1 -1
- package/routing/index.d.ts +11 -9
- package/routing/index.js +11 -9
- package/routing/plan-degradation.d.ts +34 -0
- package/routing/plan-degradation.js +38 -0
- package/routing/plan.d.ts +2 -2
- package/routing/planner.d.ts +4 -4
- package/routing/planner.js +1 -1
- package/routing/policy.d.ts +1 -1
- package/routing/policy.js +1 -1
- package/routing/transport.d.ts +2 -2
- package/run/children.d.ts +204 -0
- package/run/children.js +226 -0
- package/run/harness.d.ts +94 -0
- package/run/harness.js +140 -0
- package/run/index.d.ts +3 -0
- package/run/index.js +3 -0
- package/run/tool-batch.d.ts +16 -0
- package/run/tool-batch.js +83 -0
- package/tools/index.d.ts +1 -0
- package/tools/index.js +1 -0
- package/tools/sanitize-schema.d.ts +150 -0
- package/tools/sanitize-schema.js +683 -0
- package/transcript/index.d.ts +1 -0
- package/transcript/index.js +1 -0
- package/transcript/validate.d.ts +54 -0
- package/transcript/validate.js +226 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { runToolCallsPooledByTool } from "../run/tool-batch.js";
|
|
2
|
+
/**
|
|
3
|
+
* Repeatedly call the model and execute the tools it requests, until it stops
|
|
4
|
+
* requesting them, a caller stops the loop, a tool suspends the run, or
|
|
5
|
+
* `maxIterations` is reached.
|
|
6
|
+
*
|
|
7
|
+
* Intrinsic: plugin/module activation, two-phase tool batching, compaction
|
|
8
|
+
* (manual + auto), interrupt draining, and the suspend protocol. Injected:
|
|
9
|
+
* every side effect — status, heartbeat, activity, persistence, cancellation.
|
|
10
|
+
*
|
|
11
|
+
* Mutates `state` (messages + token accumulators) in place. That is deliberate
|
|
12
|
+
* rather than a return value: a caller's heartbeat reads live totals off it
|
|
13
|
+
* mid-loop, which a returned result could not provide until the run ended.
|
|
14
|
+
*/
|
|
15
|
+
export async function runToolLoop(params) {
|
|
16
|
+
const { state, maxIterations, callModel, buildTools, runToolCall, activatePlugins, activateSkills, ensureNotCancelled, throwIfTimedOut, onStatus, onThinking, onAssistantMessage, flushProgress, onProgressUpdate, shouldStop, onTurnWouldEnd, drainInterrupts, onInterruptReceived, needsCompaction, applyCompaction, runsSerially, isFatalToolError, onToolCallRejected, } = params;
|
|
17
|
+
// An observer must not be able to change control flow: a host logger that
|
|
18
|
+
// throws while reporting a tool failure would otherwise turn a *reported*
|
|
19
|
+
// failure into a fatal one, which is the opposite of what the report is for.
|
|
20
|
+
const reportRejection = (toolCallId, error) => {
|
|
21
|
+
try {
|
|
22
|
+
onToolCallRejected?.(toolCallId, error);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Nothing useful to do — the reporting channel is the thing that broke.
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
// Swap in a freshly-compacted transcript and reset the live counts. The
|
|
29
|
+
// provider count no longer reflects the compacted array, so drop it — the
|
|
30
|
+
// auto-compaction check skips while it's 0 (preventing an immediate
|
|
31
|
+
// re-trigger), and the next turn records a fresh real count.
|
|
32
|
+
const applyCompactionResult = (result) => {
|
|
33
|
+
state.inputTokens += result.inputTokens;
|
|
34
|
+
state.outputTokens += result.outputTokens;
|
|
35
|
+
state.costCents += result.costCents;
|
|
36
|
+
state.messages.length = 0;
|
|
37
|
+
state.messages.push(...result.messages);
|
|
38
|
+
state.lastPromptTokens = 0;
|
|
39
|
+
state.lastOutputTokens = 0;
|
|
40
|
+
};
|
|
41
|
+
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
42
|
+
throwIfTimedOut?.();
|
|
43
|
+
await ensureNotCancelled?.();
|
|
44
|
+
const tools = buildTools();
|
|
45
|
+
onStatus?.(iteration === 0 ? "thinking" : "thinking_with_tools");
|
|
46
|
+
// Stream live token progress: the per-call estimate is added to the cumulative
|
|
47
|
+
// from prior iterations so a caller's counter rises monotonically across a
|
|
48
|
+
// multi-iteration run. The real cumulative is published right after the call
|
|
49
|
+
// returns (below), reconciling any estimate drift.
|
|
50
|
+
const baseOutputTokens = state.outputTokens;
|
|
51
|
+
const result = await callModel(state.messages, tools.length > 0 ? tools : undefined,
|
|
52
|
+
// Carry the cumulative tool count alongside the streamed token estimate so
|
|
53
|
+
// the pill shows both; no tools run *during* a model call, so the count is
|
|
54
|
+
// whatever has accumulated from prior iterations.
|
|
55
|
+
onProgressUpdate
|
|
56
|
+
? (estCallTokens) => onProgressUpdate(baseOutputTokens + estCallTokens, state.toolCalls)
|
|
57
|
+
: undefined);
|
|
58
|
+
state.inputTokens += result.inputTokens;
|
|
59
|
+
state.outputTokens += result.outputTokens;
|
|
60
|
+
state.costCents += result.costCents;
|
|
61
|
+
state.lastPromptTokens = result.inputTokens;
|
|
62
|
+
state.lastOutputTokens = result.outputTokens;
|
|
63
|
+
state.hasFreshTokenCount = true;
|
|
64
|
+
const assistantMessage = result.message;
|
|
65
|
+
state.messages.push(assistantMessage);
|
|
66
|
+
if (assistantMessage.content && assistantMessage.tool_calls?.length) {
|
|
67
|
+
onThinking?.(assistantMessage.content);
|
|
68
|
+
}
|
|
69
|
+
// Stream every assistant text message (with or without tool calls, including
|
|
70
|
+
// the final tool-less reply) so a caller can surface it the moment it lands.
|
|
71
|
+
if (typeof assistantMessage.content === "string") {
|
|
72
|
+
const trimmed = assistantMessage.content.trim();
|
|
73
|
+
if (trimmed)
|
|
74
|
+
onAssistantMessage?.(trimmed);
|
|
75
|
+
}
|
|
76
|
+
// Force-flush progress so each iteration bumps the heartbeat at least once.
|
|
77
|
+
await flushProgress?.();
|
|
78
|
+
// Mid-run stop (e.g. the agent was disabled while running). Emit the
|
|
79
|
+
// iteration's final progress (the real cumulative token total) before bailing.
|
|
80
|
+
if (await shouldStop?.()) {
|
|
81
|
+
onProgressUpdate?.(state.outputTokens, state.toolCalls);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
// Count this iteration's tool batch (a single iteration can request several
|
|
85
|
+
// tools at once) BEFORE the single progress emit, so the new tool count rides
|
|
86
|
+
// the SAME frame as the iteration's real token total. A separate
|
|
87
|
+
// post-increment emit would be coalesced by a throttled transport and lag
|
|
88
|
+
// the count behind the tokens it belongs with.
|
|
89
|
+
const toolCalls = assistantMessage.tool_calls;
|
|
90
|
+
if (toolCalls && toolCalls.length > 0)
|
|
91
|
+
state.toolCalls += toolCalls.length;
|
|
92
|
+
onProgressUpdate?.(state.outputTokens, state.toolCalls);
|
|
93
|
+
if (!toolCalls || toolCalls.length === 0) {
|
|
94
|
+
// The model stopped calling tools — normally the turn is done. Give a
|
|
95
|
+
// caller a chance to push it forward instead: if `onTurnWouldEnd` returns
|
|
96
|
+
// text, inject it as a synthetic user message and keep looping. The hook
|
|
97
|
+
// is self-bounding and `maxIterations` is the hard ceiling, so this cannot
|
|
98
|
+
// spin forever.
|
|
99
|
+
const nudge = await onTurnWouldEnd?.(assistantMessage, state.toolCalls);
|
|
100
|
+
if (nudge && nudge.trim()) {
|
|
101
|
+
state.messages.push({ role: "user", content: nudge });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
onStatus?.("executing_tools");
|
|
107
|
+
// Two-phase batch: run activation calls (`tool-discovery__load_plugin` and
|
|
108
|
+
// `skills__load_skill`) SERIALLY and activate each immediately (so a
|
|
109
|
+
// dependent tool from the same batch sees the plugin active / the skill's
|
|
110
|
+
// owner plugin loaded), then run the rest concurrently, pooled per tool
|
|
111
|
+
// name. Outcomes are collected by original index and applied in emission
|
|
112
|
+
// order.
|
|
113
|
+
const outcomes = new Array(toolCalls.length);
|
|
114
|
+
const deferredIndices = [];
|
|
115
|
+
const deferredCalls = [];
|
|
116
|
+
for (let i = 0; i < toolCalls.length; i++) {
|
|
117
|
+
const tc = toolCalls[i];
|
|
118
|
+
if (runsSerially?.(tc)) {
|
|
119
|
+
// Mirror the concurrent batch's graceful error synthesis so a failing
|
|
120
|
+
// activation call (transient network/DB/timeout) doesn't crash the
|
|
121
|
+
// run — but let a fatal error propagate for an immediate abort.
|
|
122
|
+
try {
|
|
123
|
+
const outcome = await runToolCall(tc);
|
|
124
|
+
outcomes[i] = outcome;
|
|
125
|
+
if (outcome.loadedPluginName) {
|
|
126
|
+
activatePlugins([outcome.loadedPluginName]);
|
|
127
|
+
}
|
|
128
|
+
if (outcome.loadedSkillRef) {
|
|
129
|
+
// Auto-load the module's owner plugin first so its tools are active
|
|
130
|
+
// by the time the agent follows the freshly-injected instructions.
|
|
131
|
+
if (outcome.autoLoadedPlugins?.length) {
|
|
132
|
+
activatePlugins(outcome.autoLoadedPlugins);
|
|
133
|
+
}
|
|
134
|
+
await activateSkills([outcome.loadedSkillRef]);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
if (isFatalToolError?.(err))
|
|
139
|
+
throw err;
|
|
140
|
+
reportRejection(tc.id, err);
|
|
141
|
+
outcomes[i] = {
|
|
142
|
+
toolMessage: {
|
|
143
|
+
role: "tool",
|
|
144
|
+
tool_call_id: tc.id,
|
|
145
|
+
content: JSON.stringify({
|
|
146
|
+
success: false,
|
|
147
|
+
error: err instanceof Error ? err.message : String(err),
|
|
148
|
+
}),
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
deferredIndices.push(i);
|
|
155
|
+
deferredCalls.push(tc);
|
|
156
|
+
}
|
|
157
|
+
const settled = await runToolCallsPooledByTool(deferredCalls, runToolCall);
|
|
158
|
+
for (let j = 0; j < deferredCalls.length; j++) {
|
|
159
|
+
const origIndex = deferredIndices[j];
|
|
160
|
+
const call = deferredCalls[j];
|
|
161
|
+
const settledResult = settled[j];
|
|
162
|
+
if (settledResult.status === "fulfilled") {
|
|
163
|
+
outcomes[origIndex] = settledResult.value;
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
// Let a fatal error abort the run immediately rather than be
|
|
167
|
+
// synthesized into a tool error — symmetric with the serial path, and
|
|
168
|
+
// so cancellation propagates even if no `ensureNotCancelled` boundary
|
|
169
|
+
// observer is wired.
|
|
170
|
+
if (isFatalToolError?.(settledResult.reason))
|
|
171
|
+
throw settledResult.reason;
|
|
172
|
+
reportRejection(call.id, settledResult.reason);
|
|
173
|
+
outcomes[origIndex] = {
|
|
174
|
+
toolMessage: {
|
|
175
|
+
role: "tool",
|
|
176
|
+
tool_call_id: call.id,
|
|
177
|
+
content: JSON.stringify({
|
|
178
|
+
success: false,
|
|
179
|
+
error: settledResult.reason instanceof Error
|
|
180
|
+
? settledResult.reason.message
|
|
181
|
+
: String(settledResult.reason),
|
|
182
|
+
}),
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
let compactionRequested = false;
|
|
188
|
+
let suspendRequested = false;
|
|
189
|
+
for (const outcome of outcomes) {
|
|
190
|
+
// An `answer`-suspend WITHHOLDS its tool message — the result is the
|
|
191
|
+
// future answer, threaded back on resume. At most one may be open: the
|
|
192
|
+
// first becomes `state.suspended`; any additional suspend in the same
|
|
193
|
+
// batch is answered with a synthesized error so no second slot is left
|
|
194
|
+
// unpaired. A `wake`-suspend keeps its tool message (it re-enters through
|
|
195
|
+
// a fresh prompt, not a threaded answer).
|
|
196
|
+
if (outcome.suspend) {
|
|
197
|
+
suspendRequested = true;
|
|
198
|
+
if (outcome.suspend.resumeKind === "answer") {
|
|
199
|
+
if (!state.suspended) {
|
|
200
|
+
// Rebuild with the literal "answer" kind so the assignment matches
|
|
201
|
+
// `ToolLoopState.suspended` (always answer — see its type).
|
|
202
|
+
state.suspended = { ...outcome.suspend, resumeKind: "answer" };
|
|
203
|
+
continue; // withhold this call's tool message
|
|
204
|
+
}
|
|
205
|
+
state.messages.push({
|
|
206
|
+
role: "tool",
|
|
207
|
+
tool_call_id: outcome.suspend.toolCallId,
|
|
208
|
+
content: JSON.stringify({
|
|
209
|
+
success: false,
|
|
210
|
+
error: "You already have one question waiting for an answer, so this " +
|
|
211
|
+
"one was not asked. Wait for the pending answer and then ask " +
|
|
212
|
+
"this, or finish the turn with what you have.",
|
|
213
|
+
}),
|
|
214
|
+
});
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
// `wake` (e.g. sleep_until): fall through and push the tool message, then
|
|
218
|
+
// end the run — it re-enters via a prompt, not a threaded answer.
|
|
219
|
+
}
|
|
220
|
+
state.messages.push(outcome.toolMessage);
|
|
221
|
+
if (outcome.requestCompaction)
|
|
222
|
+
compactionRequested = true;
|
|
223
|
+
}
|
|
224
|
+
// A tool asked to end the run (it scheduled its own resume, or recorded an
|
|
225
|
+
// open call awaiting an answer). The tool results are already in
|
|
226
|
+
// state.messages above; stop now so the run doesn't keep going. Mark it as
|
|
227
|
+
// an intentional pause so a caller doesn't read the trailing tool-calling
|
|
228
|
+
// turn as an iteration-limit cutoff. Honor an explicit
|
|
229
|
+
// compaction requested in the same batch so the saved transcript is
|
|
230
|
+
// compacted — UNLESS a suspend is pending: compacting would rewrite the open
|
|
231
|
+
// suspended call, and that pair must survive verbatim to be resumable.
|
|
232
|
+
if (suspendRequested) {
|
|
233
|
+
if (compactionRequested && applyCompaction && !state.suspended) {
|
|
234
|
+
const compacted = await applyCompaction("manual", state.messages);
|
|
235
|
+
applyCompactionResult(compacted);
|
|
236
|
+
await compacted.persist();
|
|
237
|
+
}
|
|
238
|
+
state.endedTurnViaTool = true;
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
await ensureNotCancelled?.();
|
|
242
|
+
// Explicit (tool-requested) compaction, flushed at the batch boundary so
|
|
243
|
+
// the summary sees the full batch of tool responses. Account for the
|
|
244
|
+
// compaction usage BEFORE persisting so a persist failure can't drop the
|
|
245
|
+
// tokens it already consumed.
|
|
246
|
+
if (compactionRequested && applyCompaction) {
|
|
247
|
+
const compacted = await applyCompaction("manual", state.messages);
|
|
248
|
+
applyCompactionResult(compacted);
|
|
249
|
+
await compacted.persist();
|
|
250
|
+
}
|
|
251
|
+
// Drain human interrupts queued while the agent was working.
|
|
252
|
+
for (const interrupt of drainInterrupts?.() ?? []) {
|
|
253
|
+
// Strip control chars/newlines from the id so it can't break out of the
|
|
254
|
+
// `[Interrupt from user …]` header line and inject transcript structure.
|
|
255
|
+
// (Internal-only sources today, but a future external caller could surface
|
|
256
|
+
// user-supplied ids.)
|
|
257
|
+
const safeUserId = interrupt.userId.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, "");
|
|
258
|
+
state.messages.push({
|
|
259
|
+
role: "user",
|
|
260
|
+
content: `[Interrupt from user ${safeUserId}]\n${interrupt.content}`,
|
|
261
|
+
});
|
|
262
|
+
await onInterruptReceived?.(interrupt);
|
|
263
|
+
}
|
|
264
|
+
// Auto-compaction using the provider's real token count. Skip when there's
|
|
265
|
+
// no fresh count (before the first call, or right after a compaction reset
|
|
266
|
+
// it to 0) so we don't immediately re-trigger.
|
|
267
|
+
const currentTokens = state.lastPromptTokens + state.lastOutputTokens;
|
|
268
|
+
if (state.lastPromptTokens > 0 &&
|
|
269
|
+
needsCompaction?.(currentTokens) &&
|
|
270
|
+
applyCompaction) {
|
|
271
|
+
const compacted = await applyCompaction("auto", state.messages);
|
|
272
|
+
applyCompactionResult(compacted);
|
|
273
|
+
await compacted.persist();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juno-ai/bind",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Agent harness: deterministic LLM provider routing
|
|
3
|
+
"version": "4.0.0",
|
|
4
|
+
"description": "Agent harness: the tool-calling turn kernel, deterministic LLM provider routing, run mechanics, sub-agent lineage and admission, transcript healing, tool-schema sanitization, and the plugin/tool vocabulary. MIT-licensed; published to npm from the canonical repo via scripts/publish-bind.ts (docs/bind.md).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./index.js",
|
|
@@ -18,6 +18,26 @@
|
|
|
18
18
|
"./contracts": {
|
|
19
19
|
"types": "./contracts/index.d.ts",
|
|
20
20
|
"import": "./contracts/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./loop": {
|
|
23
|
+
"types": "./loop/index.d.ts",
|
|
24
|
+
"import": "./loop/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./run": {
|
|
27
|
+
"types": "./run/index.d.ts",
|
|
28
|
+
"import": "./run/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./transcript": {
|
|
31
|
+
"types": "./transcript/index.d.ts",
|
|
32
|
+
"import": "./transcript/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./tools": {
|
|
35
|
+
"types": "./tools/index.d.ts",
|
|
36
|
+
"import": "./tools/index.js"
|
|
37
|
+
},
|
|
38
|
+
"./plugins": {
|
|
39
|
+
"types": "./plugins/index.d.ts",
|
|
40
|
+
"import": "./plugins/index.js"
|
|
21
41
|
}
|
|
22
42
|
},
|
|
23
43
|
"peerDependencies": {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { PluginSummary } from "./registry.js";
|
|
2
|
+
/**
|
|
3
|
+
* Progressive tool disclosure: which plugins are loaded right now, and how a
|
|
4
|
+
* persisted set is restored across a run boundary.
|
|
5
|
+
*
|
|
6
|
+
* Two-tier disclosure exists because tool-selection accuracy degrades once a
|
|
7
|
+
* model sees more than a few dozen tools, and because every tool's JSON Schema
|
|
8
|
+
* is resent on every turn. Core plugins load unconditionally; the rest are
|
|
9
|
+
* announced as a compact catalog and activated on demand.
|
|
10
|
+
*
|
|
11
|
+
* The governing rule for restoring a persisted set is that **it is a hint, not
|
|
12
|
+
* a fact**. Names are persisted; implementations are resolved at load time, and
|
|
13
|
+
* anything that no longer resolves is dropped rather than reported as active —
|
|
14
|
+
* a plugin can be renamed, gated off, or (for a dynamically connected one) fail
|
|
15
|
+
* to reconnect between runs.
|
|
16
|
+
*/
|
|
17
|
+
/** Why a persisted activation could not be restored. */
|
|
18
|
+
export type ActivationDropReason = "unknown" | "unavailable" | "unreachable" | "error";
|
|
19
|
+
export interface DroppedActivation {
|
|
20
|
+
/** The canonicalized name that was dropped. */
|
|
21
|
+
name: string;
|
|
22
|
+
reason: ActivationDropReason;
|
|
23
|
+
}
|
|
24
|
+
export interface RehydrateResult {
|
|
25
|
+
/** Canonicalized, de-duplicated names that resolved and stay active. */
|
|
26
|
+
active: string[];
|
|
27
|
+
/** Everything that did not survive, for the host to log. */
|
|
28
|
+
dropped: DroppedActivation[];
|
|
29
|
+
}
|
|
30
|
+
export interface RehydrateOptions {
|
|
31
|
+
/** Map a persisted (possibly legacy) name to its current canonical name. */
|
|
32
|
+
canonicalizeName: (name: string) => string;
|
|
33
|
+
/**
|
|
34
|
+
* Can this name be activated on THIS run? Async because restoring a
|
|
35
|
+
* dynamically connected plugin may require re-establishing a connection —
|
|
36
|
+
* the harness owns the policy, the host owns the transport.
|
|
37
|
+
*
|
|
38
|
+
* Return `true` to keep, or a drop reason to discard.
|
|
39
|
+
*/
|
|
40
|
+
resolve: (name: string) => ActivationDropReason | true | Promise<ActivationDropReason | true>;
|
|
41
|
+
/**
|
|
42
|
+
* Observe a resolver rejection. The entry is dropped as `"error"` either way,
|
|
43
|
+
* and an observer that throws is swallowed — reporting a failure must not
|
|
44
|
+
* turn one bad name into a failed restore.
|
|
45
|
+
*/
|
|
46
|
+
onResolveError?: (name: string, error: unknown) => void;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Restore a persisted activation set, re-validating every entry against
|
|
50
|
+
* current reality. Order is preserved; duplicates (including two legacy names
|
|
51
|
+
* that canonicalize to the same plugin) collapse to the first occurrence.
|
|
52
|
+
*/
|
|
53
|
+
export declare function rehydrateActivation(persisted: readonly string[], options: RehydrateOptions): Promise<RehydrateResult>;
|
|
54
|
+
/** The activation set a fresh run starts from. */
|
|
55
|
+
export declare function initialActivePlugins(corePlugins: readonly string[]): string[];
|
|
56
|
+
export interface CatalogPartition {
|
|
57
|
+
active: PluginSummary[];
|
|
58
|
+
loadable: PluginSummary[];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Split the catalog into what is already active and what the model may load.
|
|
62
|
+
*
|
|
63
|
+
* This returns **data, not prose**: the wording of a catalog belongs to the
|
|
64
|
+
* host's system prompt, which is a product surface with its own voice and
|
|
65
|
+
* (in Monad's case) its own byte-for-byte prompt-cache concerns.
|
|
66
|
+
*/
|
|
67
|
+
export declare function partitionPluginCatalog(activePlugins: ReadonlySet<string>, allSummaries: readonly PluginSummary[]): CatalogPartition;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restore a persisted activation set, re-validating every entry against
|
|
3
|
+
* current reality. Order is preserved; duplicates (including two legacy names
|
|
4
|
+
* that canonicalize to the same plugin) collapse to the first occurrence.
|
|
5
|
+
*/
|
|
6
|
+
export async function rehydrateActivation(persisted, options) {
|
|
7
|
+
const active = [];
|
|
8
|
+
const dropped = [];
|
|
9
|
+
const seen = new Set();
|
|
10
|
+
for (const rawName of persisted) {
|
|
11
|
+
const name = options.canonicalizeName(rawName);
|
|
12
|
+
if (seen.has(name))
|
|
13
|
+
continue;
|
|
14
|
+
seen.add(name);
|
|
15
|
+
// A throwing resolver drops its entry rather than failing the walk. The
|
|
16
|
+
// contract is best-effort restoration, and a host resolver that reaches a
|
|
17
|
+
// database or decrypts a credential can reject transiently — one bad name
|
|
18
|
+
// must not cost the caller every other restored plugin.
|
|
19
|
+
let verdict;
|
|
20
|
+
try {
|
|
21
|
+
verdict = await options.resolve(name);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
try {
|
|
25
|
+
options.onResolveError?.(name, error);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// An observer that throws (a logger choking on a circular error, a
|
|
29
|
+
// metrics client rejecting) would otherwise defeat the very guarantee
|
|
30
|
+
// this catch exists to provide.
|
|
31
|
+
}
|
|
32
|
+
// Not `unreachable`: that reason means a dynamic plugin failed to
|
|
33
|
+
// reconnect, and a resolver can just as well throw while checking a
|
|
34
|
+
// static plugin. Reporting the failure as its own kind keeps the host's
|
|
35
|
+
// drop logging honest about what it knows.
|
|
36
|
+
verdict = "error";
|
|
37
|
+
}
|
|
38
|
+
if (verdict === true)
|
|
39
|
+
active.push(name);
|
|
40
|
+
else
|
|
41
|
+
dropped.push({ name, reason: verdict });
|
|
42
|
+
}
|
|
43
|
+
return { active, dropped };
|
|
44
|
+
}
|
|
45
|
+
/** The activation set a fresh run starts from. */
|
|
46
|
+
export function initialActivePlugins(corePlugins) {
|
|
47
|
+
return [...new Set(corePlugins)];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Split the catalog into what is already active and what the model may load.
|
|
51
|
+
*
|
|
52
|
+
* This returns **data, not prose**: the wording of a catalog belongs to the
|
|
53
|
+
* host's system prompt, which is a product surface with its own voice and
|
|
54
|
+
* (in Monad's case) its own byte-for-byte prompt-cache concerns.
|
|
55
|
+
*/
|
|
56
|
+
export function partitionPluginCatalog(activePlugins, allSummaries) {
|
|
57
|
+
return {
|
|
58
|
+
active: allSummaries.filter((summary) => activePlugins.has(summary.name)),
|
|
59
|
+
loadable: allSummaries.filter((summary) => !activePlugins.has(summary.name)),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { hasContentParts, type ToolAnnotations, type ToolDef, type ToolFailureKind, type ToolPlugin, type ToolResult, type RegistrablePlugin, type SuspendDirective, } from "./tool.js";
|
|
2
|
+
export { createToolRegistry, type PluginSummary, type ToolRegistry, type ToolRegistryOptions, } from "./registry.js";
|
|
3
|
+
export { rehydrateActivation, initialActivePlugins, partitionPluginCatalog, type ActivationDropReason, type CatalogPartition, type DroppedActivation, type RehydrateOptions, type RehydrateResult, } from "./activation.js";
|
package/plugins/index.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { RegistrablePlugin } from "./tool.js";
|
|
2
|
+
/**
|
|
3
|
+
* A plugin registry, created as a **factory rather than a module singleton**.
|
|
4
|
+
*
|
|
5
|
+
* Monad's original registry was a module-level `Map` populated by
|
|
6
|
+
* self-registration at import time. That is workable in a long-lived Node
|
|
7
|
+
* process but wrong for a package targeting workerd isolates (module state is
|
|
8
|
+
* per-isolate and its lifetime is not the host's) and it makes tests share
|
|
9
|
+
* state implicitly. A host that wants the singleton ergonomics can still wrap
|
|
10
|
+
* one instance in a module — the choice moves to the host, which is where it
|
|
11
|
+
* belongs.
|
|
12
|
+
*/
|
|
13
|
+
export interface PluginSummary {
|
|
14
|
+
name: string;
|
|
15
|
+
description: string;
|
|
16
|
+
isCorePlugin: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface ToolRegistryOptions<TPlugin extends RegistrablePlugin> {
|
|
19
|
+
/**
|
|
20
|
+
* Always-active plugins, loaded on every run without explicit activation.
|
|
21
|
+
*/
|
|
22
|
+
corePlugins: readonly string[];
|
|
23
|
+
/**
|
|
24
|
+
* Compatibility aliases for historical plugin names, mapping an old name to
|
|
25
|
+
* the current canonical one. Persisted activation state stores names, so an
|
|
26
|
+
* alias is how a rename avoids silently stripping capabilities from live
|
|
27
|
+
* sessions without a data migration. Entries are permanent.
|
|
28
|
+
*/
|
|
29
|
+
aliases?: Readonly<Record<string, string>>;
|
|
30
|
+
/**
|
|
31
|
+
* Called after a plugin is registered. The seam for host-side side effects
|
|
32
|
+
* of registration (e.g. contributing a plugin's skills into a separate
|
|
33
|
+
* registry) without the harness knowing what those are.
|
|
34
|
+
*/
|
|
35
|
+
onRegister?: (plugin: TPlugin) => void;
|
|
36
|
+
}
|
|
37
|
+
export interface ToolRegistry<TPlugin extends RegistrablePlugin> {
|
|
38
|
+
register(plugin: TPlugin): void;
|
|
39
|
+
/** Resolve by name (following aliases); `undefined` when absent or unavailable. */
|
|
40
|
+
get(name: string): TPlugin | undefined;
|
|
41
|
+
/** The canonical registered name for `name`, following any alias. */
|
|
42
|
+
canonicalizeName(name: string): string;
|
|
43
|
+
corePlugins(): string[];
|
|
44
|
+
isCoreName(name: string): boolean;
|
|
45
|
+
/** Every registered plugin, including currently-unavailable ones. */
|
|
46
|
+
all(): TPlugin[];
|
|
47
|
+
/** Summaries of available plugins only. */
|
|
48
|
+
summaries(): PluginSummary[];
|
|
49
|
+
/** Names of available plugins — the single source of truth for "what exists". */
|
|
50
|
+
availableNames(): string[];
|
|
51
|
+
}
|
|
52
|
+
export declare function createToolRegistry<TPlugin extends RegistrablePlugin>(options: ToolRegistryOptions<TPlugin>): ToolRegistry<TPlugin>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export function createToolRegistry(options) {
|
|
2
|
+
const plugins = new Map();
|
|
3
|
+
const core = [...new Set(options.corePlugins)];
|
|
4
|
+
const coreSet = new Set(core);
|
|
5
|
+
const aliases = options.aliases ?? {};
|
|
6
|
+
// Own-property check, not `aliases[name] ?? name`: a bare index reads
|
|
7
|
+
// through Object.prototype, so a lookup of "constructor" or "toString"
|
|
8
|
+
// returns an inherited function instead of the name. Hosts supply arbitrary
|
|
9
|
+
// alias maps, and a plugin may legitimately be named either.
|
|
10
|
+
//
|
|
11
|
+
// The `typeof` check is not redundant with the declared type: a host may
|
|
12
|
+
// build its alias map from JSON or another untyped source, and this function
|
|
13
|
+
// is a hard invariant — every caller downstream treats the result as a
|
|
14
|
+
// string. Falling back to the input beats returning a lie.
|
|
15
|
+
const canonicalizeName = (name) => {
|
|
16
|
+
if (!Object.prototype.hasOwnProperty.call(aliases, name))
|
|
17
|
+
return name;
|
|
18
|
+
const alias = aliases[name];
|
|
19
|
+
return typeof alias === "string" ? alias : name;
|
|
20
|
+
};
|
|
21
|
+
const isAvailable = (plugin) => plugin.isAvailable?.() ?? true;
|
|
22
|
+
return {
|
|
23
|
+
register(plugin) {
|
|
24
|
+
plugins.set(plugin.name, plugin);
|
|
25
|
+
options.onRegister?.(plugin);
|
|
26
|
+
},
|
|
27
|
+
get(name) {
|
|
28
|
+
const plugin = plugins.get(name) ?? plugins.get(canonicalizeName(name));
|
|
29
|
+
return plugin && isAvailable(plugin) ? plugin : undefined;
|
|
30
|
+
},
|
|
31
|
+
canonicalizeName,
|
|
32
|
+
corePlugins() {
|
|
33
|
+
return [...core];
|
|
34
|
+
},
|
|
35
|
+
isCoreName(name) {
|
|
36
|
+
return coreSet.has(name);
|
|
37
|
+
},
|
|
38
|
+
all() {
|
|
39
|
+
return [...plugins.values()];
|
|
40
|
+
},
|
|
41
|
+
summaries() {
|
|
42
|
+
return [...plugins.values()].filter(isAvailable).map((plugin) => ({
|
|
43
|
+
name: plugin.name,
|
|
44
|
+
description: plugin.description,
|
|
45
|
+
isCorePlugin: coreSet.has(plugin.name),
|
|
46
|
+
}));
|
|
47
|
+
},
|
|
48
|
+
availableNames() {
|
|
49
|
+
return [...plugins.values()]
|
|
50
|
+
.filter(isAvailable)
|
|
51
|
+
.map((plugin) => plugin.name);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|