@juno-ai/bind 9.0.0 → 11.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.
Files changed (46) hide show
  1. package/README.md +375 -15
  2. package/contracts/index.d.ts +1 -1
  3. package/contracts/index.js +1 -1
  4. package/contracts/turn.d.ts +77 -2
  5. package/contracts/turn.js +35 -2
  6. package/index.d.ts +6 -2
  7. package/index.js +6 -2
  8. package/loop/index.d.ts +2 -1
  9. package/loop/index.js +1 -1
  10. package/loop/tool-loop.d.ts +117 -12
  11. package/loop/tool-loop.js +242 -67
  12. package/package.json +10 -2
  13. package/plugins/dispatch.d.ts +130 -0
  14. package/plugins/dispatch.js +241 -0
  15. package/plugins/index.d.ts +2 -0
  16. package/plugins/index.js +2 -0
  17. package/plugins/tool-message.d.ts +23 -0
  18. package/plugins/tool-message.js +31 -0
  19. package/skills/activation.d.ts +64 -0
  20. package/skills/activation.js +39 -0
  21. package/skills/admission.d.ts +61 -0
  22. package/skills/admission.js +41 -0
  23. package/skills/catalog.d.ts +54 -0
  24. package/skills/catalog.js +77 -0
  25. package/skills/discovery.d.ts +82 -0
  26. package/skills/discovery.js +91 -0
  27. package/skills/index.d.ts +19 -0
  28. package/skills/index.js +19 -0
  29. package/skills/refs.d.ts +21 -0
  30. package/skills/refs.js +27 -0
  31. package/skills/registry.d.ts +57 -0
  32. package/skills/registry.js +94 -0
  33. package/skills/resolve.d.ts +89 -0
  34. package/skills/resolve.js +124 -0
  35. package/skills/sha.d.ts +53 -0
  36. package/skills/sha.js +60 -0
  37. package/skills/sha256.d.ts +38 -0
  38. package/skills/sha256.js +122 -0
  39. package/skills/skill-md.d.ts +73 -0
  40. package/skills/skill-md.js +149 -0
  41. package/skills/types.d.ts +174 -0
  42. package/skills/types.js +55 -0
  43. package/testing/index.d.ts +153 -0
  44. package/testing/index.js +188 -0
  45. package/tools/control-chars.d.ts +23 -0
  46. package/tools/control-chars.js +35 -0
package/loop/tool-loop.js CHANGED
@@ -1,4 +1,56 @@
1
1
  import { runToolCallsPooledByTool, AbortedToolCallError, } from "../run/tool-batch.js";
2
+ import { accumulateAuxiliarySpend, accumulateToolCall, accumulateTurn, emptyRunStats, } from "../contracts/turn.js";
3
+ import { toolResultMessage } from "../plugins/tool-message.js";
4
+ // Imported from the module, not the `tools` barrel: this subpath's runtime
5
+ // graph is deliberately free of `zod`, which the barrel's siblings pull in.
6
+ import { stripControlChars } from "../tools/control-chars.js";
7
+ /**
8
+ * A tool asked the loop to activate a plugin or an instruction module, and the
9
+ * port that would do it was not wired.
10
+ *
11
+ * Reported through `onToolCallRejected` rather than thrown: the call itself
12
+ * succeeded and its tool message is already correct, so failing the run would
13
+ * be worse than the missing activation. But it must not be silent — before
14
+ * these ports were optional this was a compile error, and the runtime symptom
15
+ * (an agent that keeps loading a plugin it never receives) points nowhere near
16
+ * the cause.
17
+ *
18
+ * Match on `error.name === "MissingActivationPortError"` rather than
19
+ * `instanceof` if you consume this package from a projected or re-bundled copy
20
+ * — two copies of a class in one module graph make `instanceof` silently
21
+ * false, and this package is Copybara-projected and republished.
22
+ */
23
+ export class MissingActivationPortError extends Error {
24
+ port;
25
+ name = "MissingActivationPortError";
26
+ constructor(port) {
27
+ super(`A tool outcome asked the loop to activate, but no \`${port}\` was supplied. ` +
28
+ `Wire the port, or stop returning activation fields from \`runToolCall\`.`);
29
+ this.port = port;
30
+ }
31
+ }
32
+ /**
33
+ * The key a tool call is measured under in {@link RunStats.toolTimeBreakdownMs}.
34
+ *
35
+ * Mirrors the batch's pooling key rather than reading `tc.function.name`: the
36
+ * wire union has a `custom` member with its name on a different field, and a
37
+ * host may assemble a call with no `function` at all. Synthetic keys are
38
+ * fenced with `__` so they cannot collide with a real tool called `custom`.
39
+ */
40
+ function toolCallStatsKey(tc) {
41
+ switch (tc.type) {
42
+ case "function":
43
+ return tc.function?.name ?? "__unnamed__";
44
+ case "custom":
45
+ return `__custom__:${tc.custom?.name ?? "unnamed"}`;
46
+ default: {
47
+ // Not `never`: this union is a third party's, and a member it grows
48
+ // later must not become a type error in a consumer that never sees one.
49
+ const unknownCall = tc;
50
+ return `__${unknownCall.type ?? "unknown"}__`;
51
+ }
52
+ }
53
+ }
2
54
  /**
3
55
  * Repeatedly call the model and execute the tools it requests, until it stops
4
56
  * requesting them, a caller stops the loop, a tool suspends the run, or
@@ -11,9 +63,48 @@ import { runToolCallsPooledByTool, AbortedToolCallError, } from "../run/tool-bat
11
63
  * Mutates `state` (messages + token accumulators) in place. That is deliberate
12
64
  * rather than a return value: a caller's heartbeat reads live totals off it
13
65
  * mid-loop, which a returned result could not provide until the run ended.
66
+ * The {@link ToolLoopResult} it *returns* is the complementary half — the
67
+ * run's conclusion, which only exists once the loop is over.
14
68
  */
15
69
  export async function runToolLoop(params) {
16
- const { state, maxIterations, callModel, buildTools, runToolCall, activatePlugins, activateSkills, ensureNotCancelled, throwIfTimedOut, onStatus, signal, onThinking, onAssistantMessage, flushProgress, onProgressUpdate, shouldStop, onTurnWouldEnd, drainInterrupts, onInterruptReceived, needsCompaction, applyCompaction, runsSerially, isFatalToolError, onToolCallRejected, } = params;
70
+ const { state, maxIterations, callModel, buildTools, runToolCall, activatePlugins, activateSkills, now = Date.now, ensureNotCancelled, throwIfTimedOut, onStatus, signal, onThinking, onAssistantMessage, flushProgress, onProgressUpdate, shouldStop, onTurnWouldEnd, drainInterrupts, onInterruptReceived, needsCompaction, applyCompaction, runsSerially, isFatalToolError, onToolCallRejected, } = params;
71
+ let stats = emptyRunStats();
72
+ // The default is the outcome of falling out of the `for` — every other exit
73
+ // assigns before it breaks. Seeding it here rather than at each `break` means
74
+ // a future exit path that forgets to set one reports "we ran out of
75
+ // iterations", which is the conservative lie: it says the run did NOT finish.
76
+ let stopReason = "iteration_limit";
77
+ /**
78
+ * Run one tool call, recording its duration against its tool name.
79
+ *
80
+ * Wrapped rather than measured at the two call sites because the serial and
81
+ * pooled phases both dispatch, and a call that *throws* still consumed the
82
+ * time — a `finally` is the only way to catch both halves of that in one
83
+ * place. Refused calls never reach here (the pool rejects them at claim
84
+ * time), which is exactly why `stats.toolCalls` counts dispatches.
85
+ */
86
+ const dispatchToolCall = async (tc) => {
87
+ const startedAt = now();
88
+ try {
89
+ return await runToolCall(tc);
90
+ }
91
+ finally {
92
+ // A throw in a `finally` REPLACES the value the `try` produced, so
93
+ // measuring a call must never be able to fail. It once could: reading
94
+ // `tc.function.name` unguarded turned a tool that ran — side effect and
95
+ // all — into a synthesized "it failed" the model then acted on. The
96
+ // wire type says `function` is always there; this repo knows better
97
+ // (`completion/tool-calls.ts` types it optional, and the host's own
98
+ // dispatcher reads it with an `in` check for exactly this reason).
99
+ try {
100
+ stats = accumulateToolCall(stats, toolCallStatsKey(tc), now() - startedAt);
101
+ }
102
+ catch {
103
+ // Accounting is observability. Losing a measurement is survivable;
104
+ // losing the call's outcome is not.
105
+ }
106
+ }
107
+ };
17
108
  // An observer must not be able to change control flow: a host logger that
18
109
  // throws while reporting a tool failure would otherwise turn a *reported*
19
110
  // failure into a fatal one, which is the opposite of what the report is for.
@@ -29,15 +120,36 @@ export async function runToolLoop(params) {
29
120
  // provider count no longer reflects the compacted array, so drop it — the
30
121
  // auto-compaction check skips while it's 0 (preventing an immediate
31
122
  // re-trigger), and the next turn records a fresh real count.
32
- const applyCompactionResult = (result) => {
123
+ const applyCompactionResult = (result, modelTimeMs) => {
33
124
  state.inputTokens += result.inputTokens;
34
125
  state.outputTokens += result.outputTokens;
35
126
  state.costCents += result.costCents;
127
+ // Real spend, but not an agent turn — a compaction is the harness talking
128
+ // to itself. Counting it in `stats.turns` would make that number
129
+ // incomparable with `maxIterations`.
130
+ stats = accumulateAuxiliarySpend(stats, {
131
+ inputTokens: result.inputTokens,
132
+ outputTokens: result.outputTokens,
133
+ costCents: result.costCents,
134
+ cachedInputTokens: result.cachedInputTokens ?? null,
135
+ modelTimeMs,
136
+ });
36
137
  state.messages.length = 0;
37
138
  state.messages.push(...result.messages);
38
139
  state.lastPromptTokens = 0;
39
140
  state.lastOutputTokens = 0;
40
141
  };
142
+ // Compact, account, then persist — in that order, and timing only the model
143
+ // pass. `persist` is host I/O; folding it into `modelTimeMs` would inflate
144
+ // the one number that exists to isolate the provider's contribution.
145
+ // `compact` is passed in rather than read from the closure so each call site
146
+ // narrows the optional itself and no non-null assertion is needed.
147
+ const compactNow = async (compact, trigger) => {
148
+ const startedAt = now();
149
+ const compacted = await compact(trigger, state.messages);
150
+ applyCompactionResult(compacted, now() - startedAt);
151
+ await compacted.persist();
152
+ };
41
153
  for (let iteration = 0; iteration < maxIterations; iteration++) {
42
154
  throwIfTimedOut?.();
43
155
  await ensureNotCancelled?.();
@@ -57,6 +169,7 @@ export async function runToolLoop(params) {
57
169
  // the real total below is free to correct downward.
58
170
  const baseOutputTokens = state.outputTokens;
59
171
  let progressHighWater = baseOutputTokens;
172
+ const turnStartedAt = now();
60
173
  const result = await callModel(state.messages, tools.length > 0 ? tools : undefined,
61
174
  // Carry the cumulative tool count alongside the streamed token estimate so
62
175
  // the pill shows both; no tools run *during* a model call, so the count is
@@ -67,6 +180,28 @@ export async function runToolLoop(params) {
67
180
  onProgressUpdate(progressHighWater, state.toolCalls);
68
181
  }
69
182
  : undefined);
183
+ // Folded through the shared `accumulateTurn` rather than incremented
184
+ // field-by-field, so `stats` and every other producer of `RunStats` (child
185
+ // runs, a host's own transport) agree on what a turn contributes — notably
186
+ // the derived `outputTokensPerSecond`, which is recomputed from totals
187
+ // rather than averaged.
188
+ stats = accumulateTurn(stats, {
189
+ message: result.message,
190
+ usage: {
191
+ inputTokens: result.inputTokens,
192
+ outputTokens: result.outputTokens,
193
+ // `null` when the transport did not report one — `accumulateTurn`
194
+ // then contributes nothing for this turn rather than counting a zero,
195
+ // which is why the total reads as a floor.
196
+ cachedInputTokens: result.cachedInputTokens ?? null,
197
+ costCents: result.costCents,
198
+ },
199
+ timings: {
200
+ // Only the transport sees the first byte. The loop sees the call.
201
+ ttftMs: null,
202
+ generationMs: now() - turnStartedAt,
203
+ },
204
+ });
70
205
  state.inputTokens += result.inputTokens;
71
206
  state.outputTokens += result.outputTokens;
72
207
  state.costCents += result.costCents;
@@ -87,10 +222,16 @@ export async function runToolLoop(params) {
87
222
  }
88
223
  // Force-flush progress so each iteration bumps the heartbeat at least once.
89
224
  await flushProgress?.();
90
- // Mid-run stop (e.g. the agent was disabled while running). Emit the
91
- // iteration's final progress (the real cumulative token total) before bailing.
92
- if (await shouldStop?.()) {
225
+ // Mid-run stop (e.g. the agent was disabled while running). Asked once and
226
+ // held, because *whether* to stop and *what to call it* are answered at two
227
+ // different points below, and asking a host predicate twice could get two
228
+ // answers.
229
+ const stopRequested = (await shouldStop?.()) ?? false;
230
+ if (stopRequested && assistantMessage.tool_calls?.length) {
231
+ // Still mid-task, and the batch has not run. Emit the iteration's final
232
+ // progress (the real cumulative token total) before bailing.
93
233
  onProgressUpdate?.(state.outputTokens, state.toolCalls);
234
+ stopReason = "aborted";
94
235
  break;
95
236
  }
96
237
  // Count this iteration's tool batch (a single iteration can request several
@@ -110,9 +251,26 @@ export async function runToolLoop(params) {
110
251
  // spin forever.
111
252
  const nudge = await onTurnWouldEnd?.(assistantMessage, state.toolCalls);
112
253
  if (nudge && nudge.trim()) {
254
+ // The host's own hook says this turn was NOT a finished answer — it
255
+ // was a stall worth pushing past. If the host also asked to stop, that
256
+ // wins, but the outcome is an abort: calling it `done` would report a
257
+ // run the host itself judged unfinished as a successful answer.
258
+ if (stopRequested) {
259
+ onProgressUpdate?.(state.outputTokens, state.toolCalls);
260
+ stopReason = "aborted";
261
+ break;
262
+ }
113
263
  state.messages.push({ role: "user", content: nudge });
114
264
  continue;
115
265
  }
266
+ // A tool-less turn no hook wanted to push past is a finished answer: it
267
+ // is already in `state.messages` and has already gone out through
268
+ // `onAssistantMessage`. `done` even when the host asked to stop in the
269
+ // same breath — reporting `aborted` would have a host badge a delivered
270
+ // answer as cancelled, or suppress it.
271
+ if (stopRequested)
272
+ onProgressUpdate?.(state.outputTokens, state.toolCalls);
273
+ stopReason = "done";
116
274
  break;
117
275
  }
118
276
  onStatus?.("executing_tools");
@@ -141,15 +299,11 @@ export async function runToolLoop(params) {
141
299
  throw aborted;
142
300
  reportRejection(tc.id, aborted);
143
301
  outcomes[i] = {
144
- toolMessage: {
145
- role: "tool",
146
- tool_call_id: tc.id,
147
- content: JSON.stringify({
148
- success: false,
149
- kind: "not_run",
150
- error: aborted.message,
151
- }),
152
- },
302
+ toolMessage: toolResultMessage(tc.id, {
303
+ success: false,
304
+ kind: "not_run",
305
+ error: aborted.message,
306
+ }),
153
307
  };
154
308
  continue;
155
309
  }
@@ -157,18 +311,39 @@ export async function runToolLoop(params) {
157
311
  // activation call (transient network/DB/timeout) doesn't crash the
158
312
  // run — but let a fatal error propagate for an immediate abort.
159
313
  try {
160
- const outcome = await runToolCall(tc);
314
+ const outcome = await dispatchToolCall(tc);
161
315
  outcomes[i] = outcome;
162
316
  if (outcome.loadedPluginName) {
163
- activatePlugins([outcome.loadedPluginName]);
317
+ if (activatePlugins) {
318
+ activatePlugins([outcome.loadedPluginName]);
319
+ }
320
+ else {
321
+ // The port is optional, but silence here is not. An outcome
322
+ // carrying `loadedPluginName` is proof this host's tools DO
323
+ // reshape the tool surface, so the port's absence is a wiring
324
+ // bug, not a host that doesn't need it. Dropped quietly, the
325
+ // model gets a success for `load_plugin`, never sees the tools,
326
+ // and re-calls it every iteration until the budget is gone.
327
+ reportRejection(tc.id, new MissingActivationPortError("activatePlugins"));
328
+ }
164
329
  }
165
330
  if (outcome.loadedSkillRef) {
166
331
  // Auto-load the module's owner plugin first so its tools are active
167
332
  // by the time the agent follows the freshly-injected instructions.
168
333
  if (outcome.autoLoadedPlugins?.length) {
169
- activatePlugins(outcome.autoLoadedPlugins);
334
+ if (activatePlugins) {
335
+ activatePlugins(outcome.autoLoadedPlugins);
336
+ }
337
+ else {
338
+ reportRejection(tc.id, new MissingActivationPortError("activatePlugins"));
339
+ }
340
+ }
341
+ if (activateSkills) {
342
+ await activateSkills([outcome.loadedSkillRef]);
343
+ }
344
+ else {
345
+ reportRejection(tc.id, new MissingActivationPortError("activateSkills"));
170
346
  }
171
- await activateSkills([outcome.loadedSkillRef]);
172
347
  }
173
348
  }
174
349
  catch (err) {
@@ -176,14 +351,10 @@ export async function runToolLoop(params) {
176
351
  throw err;
177
352
  reportRejection(tc.id, err);
178
353
  outcomes[i] = {
179
- toolMessage: {
180
- role: "tool",
181
- tool_call_id: tc.id,
182
- content: JSON.stringify({
183
- success: false,
184
- error: err instanceof Error ? err.message : String(err),
185
- }),
186
- },
354
+ toolMessage: toolResultMessage(tc.id, {
355
+ success: false,
356
+ error: err instanceof Error ? err.message : String(err),
357
+ }),
187
358
  };
188
359
  }
189
360
  continue;
@@ -191,9 +362,7 @@ export async function runToolLoop(params) {
191
362
  deferredIndices.push(i);
192
363
  deferredCalls.push(tc);
193
364
  }
194
- const settled = await runToolCallsPooledByTool(deferredCalls, runToolCall, {
195
- signal,
196
- });
365
+ const settled = await runToolCallsPooledByTool(deferredCalls, dispatchToolCall, { signal });
197
366
  for (let j = 0; j < deferredCalls.length; j++) {
198
367
  const origIndex = deferredIndices[j];
199
368
  const call = deferredCalls[j];
@@ -210,24 +379,20 @@ export async function runToolLoop(params) {
210
379
  throw settledResult.reason;
211
380
  reportRejection(call.id, settledResult.reason);
212
381
  outcomes[origIndex] = {
213
- toolMessage: {
214
- role: "tool",
215
- tool_call_id: call.id,
216
- content: JSON.stringify({
217
- success: false,
218
- // A refused call is not a failed one, and the envelope alone
219
- // cannot say so — `success: false` plus a sentence is exactly what
220
- // a tool that ran and failed produces. The discriminant is what
221
- // lets a model (or a host) tell them apart without matching on
222
- // prose. See `ToolFailureKind`.
223
- ...(settledResult.reason instanceof AbortedToolCallError
224
- ? { kind: "not_run" }
225
- : {}),
226
- error: settledResult.reason instanceof Error
227
- ? settledResult.reason.message
228
- : String(settledResult.reason),
229
- }),
230
- },
382
+ toolMessage: toolResultMessage(call.id, {
383
+ success: false,
384
+ // A refused call is not a failed one, and the envelope alone
385
+ // cannot say so — `success: false` plus a sentence is exactly what
386
+ // a tool that ran and failed produces. The discriminant is what
387
+ // lets a model (or a host) tell them apart without matching on
388
+ // prose. See `ToolFailureKind`.
389
+ ...(settledResult.reason instanceof AbortedToolCallError
390
+ ? { kind: "not_run" }
391
+ : {}),
392
+ error: settledResult.reason instanceof Error
393
+ ? settledResult.reason.message
394
+ : String(settledResult.reason),
395
+ }),
231
396
  };
232
397
  }
233
398
  }
@@ -249,16 +414,20 @@ export async function runToolLoop(params) {
249
414
  state.suspended = { ...outcome.suspend, resumeKind: "answer" };
250
415
  continue; // withhold this call's tool message
251
416
  }
252
- state.messages.push({
253
- role: "tool",
254
- tool_call_id: outcome.suspend.toolCallId,
255
- content: JSON.stringify({
256
- success: false,
257
- error: "You already have one question waiting for an answer, so this " +
258
- "one was not asked. Wait for the pending answer and then ask " +
259
- "this, or finish the turn with what you have.",
260
- }),
261
- });
417
+ // Through the shared encoder, and carrying a `kind`, like every
418
+ // other synthesized failure. It was the last hand-rolled envelope in
419
+ // this file, which meant the harness itself emitted two error shapes
420
+ // in one transcript — the exact thing `toolResultMessage` exists to
421
+ // prevent. `conflict` because it is a concurrency loss: the call was
422
+ // refused only because another question is already open.
423
+ state.messages.push(toolResultMessage(outcome.suspend.toolCallId, {
424
+ success: false,
425
+ kind: "conflict",
426
+ error: "This question was not asked — you already have one waiting " +
427
+ "for an answer, and only one can be open at a time. When that " +
428
+ "answer arrives, ask this one then, or finish the turn with " +
429
+ "what you have.",
430
+ }));
262
431
  continue;
263
432
  }
264
433
  // `wake` (e.g. sleep_until): fall through and push the tool message, then
@@ -292,6 +461,12 @@ export async function runToolLoop(params) {
292
461
  // otherwise refuse every batch and pay for a fresh model call each
293
462
  // iteration until `maxIterations`. Break so the abort ends the run on its
294
463
  // own, rather than only when some other port happens to agree.
464
+ //
465
+ // `aborted`, not `deadline`, even when the signal IS a deadline: by the
466
+ // time a wall-clock budget and a cancellation are combined into one
467
+ // `AbortSignal` the loop cannot tell them apart, and `throwIfTimedOut`
468
+ // above has already had its chance to name a timeout by throwing.
469
+ stopReason = "aborted";
295
470
  break;
296
471
  }
297
472
  // A tool asked to end the run (it scheduled its own resume, or recorded an
@@ -304,11 +479,14 @@ export async function runToolLoop(params) {
304
479
  // suspended call, and that pair must survive verbatim to be resumable.
305
480
  if (suspendRequested) {
306
481
  if (compactionRequested && applyCompaction && !state.suspended) {
307
- const compacted = await applyCompaction("manual", state.messages);
308
- applyCompactionResult(compacted);
309
- await compacted.persist();
482
+ await compactNow(applyCompaction, "manual");
310
483
  }
311
484
  state.endedTurnViaTool = true;
485
+ // `state.suspended` is set only by the `answer` branch, so its presence
486
+ // IS the discriminant between "a person has to reply" and "this comes
487
+ // back on its own". Reading it here, once, is what saves every host
488
+ // from reading it themselves.
489
+ stopReason = state.suspended ? "waiting_for_reply" : "resuming_later";
312
490
  break;
313
491
  }
314
492
  await ensureNotCancelled?.();
@@ -317,9 +495,7 @@ export async function runToolLoop(params) {
317
495
  // compaction usage BEFORE persisting so a persist failure can't drop the
318
496
  // tokens it already consumed.
319
497
  if (compactionRequested && applyCompaction) {
320
- const compacted = await applyCompaction("manual", state.messages);
321
- applyCompactionResult(compacted);
322
- await compacted.persist();
498
+ await compactNow(applyCompaction, "manual");
323
499
  }
324
500
  // Drain human interrupts queued while the agent was working.
325
501
  for (const interrupt of drainInterrupts?.() ?? []) {
@@ -327,7 +503,7 @@ export async function runToolLoop(params) {
327
503
  // `[Interrupt from user …]` header line and inject transcript structure.
328
504
  // (Internal-only sources today, but a future external caller could surface
329
505
  // user-supplied ids.)
330
- const safeUserId = interrupt.userId.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, "");
506
+ const safeUserId = stripControlChars(interrupt.userId);
331
507
  state.messages.push({
332
508
  role: "user",
333
509
  content: `[Interrupt from user ${safeUserId}]\n${interrupt.content}`,
@@ -341,9 +517,8 @@ export async function runToolLoop(params) {
341
517
  if (state.lastPromptTokens > 0 &&
342
518
  needsCompaction?.(currentTokens) &&
343
519
  applyCompaction) {
344
- const compacted = await applyCompaction("auto", state.messages);
345
- applyCompactionResult(compacted);
346
- await compacted.persist();
520
+ await compactNow(applyCompaction, "auto");
347
521
  }
348
522
  }
523
+ return { stopReason, stats };
349
524
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@juno-ai/bind",
3
- "version": "9.0.0",
4
- "description": "Agent harness: the tool-calling turn kernel, deterministic LLM provider routing with transport-error classification, the streaming-completion watchdog, 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).",
3
+ "version": "11.0.0",
4
+ "description": "Agent harness: the tool-calling turn kernel, deterministic LLM provider routing with transport-error classification, the streaming-completion watchdog, run mechanics, sub-agent lineage and admission, transcript healing, tool-schema sanitization, the plugin/tool vocabulary, and the skill vocabulary (`./skills`) for progressive knowledge disclosure. 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",
@@ -42,6 +42,14 @@
42
42
  "./plugins": {
43
43
  "types": "./plugins/index.d.ts",
44
44
  "import": "./plugins/index.js"
45
+ },
46
+ "./skills": {
47
+ "types": "./skills/index.d.ts",
48
+ "import": "./skills/index.js"
49
+ },
50
+ "./testing": {
51
+ "types": "./testing/index.d.ts",
52
+ "import": "./testing/index.js"
45
53
  }
46
54
  },
47
55
  "peerDependencies": {
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Authoring and dispatching a tool: the mechanical half of writing one, which
3
+ * every host had been reimplementing.
4
+ *
5
+ * A `ToolPlugin` is a bundle whose `execute` dispatches by tool name, because
6
+ * that is the shape a plugin with shared setup wants. It is not the shape a
7
+ * *single* tool wants, and a host with a flat list of tools ends up writing the
8
+ * same four steps for each: switch on the name, parse the arguments, map a
9
+ * parse failure onto a result the model can read, and encode the result as a
10
+ * `role:"tool"` message. All four are mechanical, all four are easy to get
11
+ * subtly wrong (the usual bug is a parse failure thrown rather than returned,
12
+ * which turns a recoverable "you passed the wrong argument" into a dead run),
13
+ * and none of them are where a host's judgement belongs.
14
+ *
15
+ * {@link defineTool} and {@link pluginFromTools} do those steps. They are a
16
+ * convenience over the vocabulary in `./tool`, not a replacement for it: a
17
+ * plugin that needs shared setup across its tools, or whose dispatch is genuinely
18
+ * one decision, still writes `ToolPlugin` by hand and loses nothing.
19
+ */
20
+ import { z } from "zod";
21
+ import type OpenAI from "openai";
22
+ import type { ToolAnnotations, ToolDef, ToolPlugin, ToolResult } from "./tool.js";
23
+ /**
24
+ * A tool that carries its own implementation.
25
+ *
26
+ * `execute` takes `unknown` and does the parsing itself, which is what lets a
27
+ * heterogeneous array of tools — each with a different argument type — sit in
28
+ * one `tools: DefinedTool[]` without a cast anywhere. It also makes a defined
29
+ * tool independently useful: dispatch it directly and you still get argument
30
+ * validation, without going through {@link pluginFromTools}.
31
+ */
32
+ export interface DefinedTool<TCtx, TContentPart = unknown> extends ToolDef {
33
+ /**
34
+ * Run this tool against unvalidated arguments, parsing with `parameters`
35
+ * first. A parse failure comes back as a `validation` result rather than a
36
+ * throw, because the model can fix it on the next turn and a throw would end
37
+ * the run instead.
38
+ *
39
+ * Does **not** apply `normalizeArgs` — that is the dispatcher's step, run
40
+ * before the idempotency hash. Applying it here too would apply it twice.
41
+ */
42
+ execute(args: unknown, ctx: TCtx): Promise<ToolResult<TContentPart>> | ToolResult<TContentPart>;
43
+ }
44
+ /**
45
+ * The declaration side of {@link defineTool}. `schema` is the single source of
46
+ * truth: it is what the model is shown (converted to JSON Schema) and what the
47
+ * model's arguments are validated against, so the two can never drift.
48
+ */
49
+ export interface ToolSpec<TSchema extends z.ZodType, TCtx, TContentPart> {
50
+ readonly name: string;
51
+ readonly description: string;
52
+ readonly schema: TSchema;
53
+ execute(args: z.output<TSchema>, ctx: TCtx): Promise<ToolResult<TContentPart>> | ToolResult<TContentPart>;
54
+ readonly annotations?: ToolAnnotations;
55
+ readonly hidden?: boolean;
56
+ readonly supportsProgress?: boolean;
57
+ /**
58
+ * Pre-computed JSON Schema, for a tool authored as raw JSON Schema rather
59
+ * than zod. `schema` still validates the arguments.
60
+ *
61
+ * It is also the one way to author a tool whose `schema` is not
62
+ * parse-idempotent (a `.transform()`, which `z.toJSONSchema` refuses to
63
+ * convert). Doing so makes the schema's idempotence your responsibility:
64
+ * `execute` parses defensively, so a dispatcher that already parsed hands
65
+ * this a value the schema must still accept.
66
+ */
67
+ readonly rawJsonSchema?: Record<string, unknown>;
68
+ /**
69
+ * Pure canonicalization of already-validated arguments — sorting a set-like
70
+ * array, lower-casing a key. Runs **after** the schema parse, per
71
+ * `ToolDef.normalizeArgs`, so it receives defaults already applied and a
72
+ * shape it can rely on; a normalizer handed raw model output would have to
73
+ * re-check everything the schema just checked.
74
+ *
75
+ * Applied by the **dispatcher**, before the idempotency hash — not by
76
+ * `execute`. It must be idempotent anyway (`f(f(x)) === f(x)`), because
77
+ * nothing can stop a host applying it more than once.
78
+ */
79
+ readonly normalizeArgs?: (args: z.output<TSchema>) => z.output<TSchema>;
80
+ readonly summarizeActivity?: (args: unknown) => string | null;
81
+ }
82
+ /**
83
+ * Define one tool from its schema and implementation.
84
+ *
85
+ * The returned value is an ordinary {@link ToolDef} with an `execute` attached,
86
+ * so it drops into anything that already consumes `ToolDef` — a catalog
87
+ * renderer, a schema regression test — without an adapter.
88
+ */
89
+ export declare function defineTool<TSchema extends z.ZodType, TCtx = unknown, TContentPart = unknown>(spec: ToolSpec<TSchema, TCtx, TContentPart>): DefinedTool<TCtx, TContentPart>;
90
+ export interface PluginSpec<TCtx, TContentPart> {
91
+ readonly name: string;
92
+ readonly description: string;
93
+ readonly tools: readonly DefinedTool<TCtx, TContentPart>[];
94
+ readonly systemMessage?: string;
95
+ readonly icon?: string;
96
+ readonly isAvailable?: () => boolean;
97
+ }
98
+ /**
99
+ * Bundle self-contained tools into a {@link ToolPlugin}.
100
+ *
101
+ * The generated `execute` is only a name resolver — each tool already validates
102
+ * its own arguments (see {@link DefinedTool}). An unknown name is a *returned*
103
+ * `not_found` failure rather than a throw: it happens whenever a resumed
104
+ * session's history references a tool that has since been retired, and a run
105
+ * should survive that.
106
+ */
107
+ export declare function pluginFromTools<TCtx = unknown, TContentPart = unknown>(spec: PluginSpec<TCtx, TContentPart>): ToolPlugin<TCtx, TContentPart>;
108
+ /**
109
+ * Convert a tool to the wire definition a provider is shown.
110
+ *
111
+ * `rawJsonSchema` wins when present (an MCP tool forwards its server's schema
112
+ * verbatim); otherwise the zod schema is converted. Either way the result goes
113
+ * through {@link sanitizeToolSchema}, because a strict validator rejects the
114
+ * *entire* request on the first unsupported construct — one bad tool takes
115
+ * every other tool down with it.
116
+ *
117
+ * `wireName` exists because tool naming is host policy: Monad encodes
118
+ * `plugin__tool` so it can route a call back to its plugin, and a host with a
119
+ * flat namespace does not need to. Defaults to the tool's own name.
120
+ *
121
+ * Returns the narrow `ChatCompletionFunctionTool` rather than the
122
+ * `ChatCompletionTool` union — a tool built from a parameter schema is always
123
+ * the function variant, and returning the union would make every caller narrow
124
+ * past a `custom` case that cannot occur. It still assigns to the union.
125
+ *
126
+ * Converts whatever it is handed. **Skip `hidden` tools in the caller's catalog
127
+ * loop** — a hidden tool stays runnable so a resumed session's history still
128
+ * resolves, but advertising it puts a retired tool back in front of the model.
129
+ */
130
+ export declare function toolWireDefinition(tool: ToolDef, wireName?: string): OpenAI.ChatCompletionFunctionTool;