@juno-ai/bind 8.0.0 → 10.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 +405 -58
- package/completion/index.d.ts +1 -0
- package/completion/index.js +1 -0
- package/completion/text-stream.d.ts +311 -0
- package/completion/text-stream.js +273 -0
- package/contracts/index.d.ts +1 -1
- package/contracts/index.js +1 -1
- package/contracts/turn.d.ts +77 -2
- package/contracts/turn.js +35 -2
- package/loop/index.d.ts +2 -1
- package/loop/index.js +1 -1
- package/loop/tool-loop.d.ts +131 -12
- package/loop/tool-loop.js +285 -49
- package/package.json +5 -1
- package/plugins/dispatch.d.ts +130 -0
- package/plugins/dispatch.js +241 -0
- package/plugins/index.d.ts +2 -0
- package/plugins/index.js +2 -0
- package/plugins/tool-message.d.ts +23 -0
- package/plugins/tool-message.js +31 -0
- package/plugins/tool.d.ts +1 -1
- package/run/index.d.ts +1 -1
- package/run/index.js +1 -1
- package/run/tool-batch.d.ts +51 -1
- package/run/tool-batch.js +59 -1
- package/testing/index.d.ts +153 -0
- package/testing/index.js +188 -0
- package/tools/control-chars.d.ts +23 -0
- package/tools/control-chars.js +35 -0
package/loop/tool-loop.js
CHANGED
|
@@ -1,4 +1,56 @@
|
|
|
1
|
-
import { runToolCallsPooledByTool } from "../run/tool-batch.js";
|
|
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 } from "../run/tool-batch.js";
|
|
|
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, 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).
|
|
91
|
-
//
|
|
92
|
-
|
|
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");
|
|
@@ -128,22 +286,64 @@ export async function runToolLoop(params) {
|
|
|
128
286
|
for (let i = 0; i < toolCalls.length; i++) {
|
|
129
287
|
const tc = toolCalls[i];
|
|
130
288
|
if (runsSerially?.(tc)) {
|
|
289
|
+
// The serial phase runs before the pool and is a loop of its own, so it
|
|
290
|
+
// needs the same claim-time check — otherwise an aborted batch still
|
|
291
|
+
// executes every activation call ahead of the pool that refuses to.
|
|
292
|
+
if (signal?.aborted === true) {
|
|
293
|
+
const aborted = new AbortedToolCallError(tc.id);
|
|
294
|
+
// Routed through the same three seams as a pooled refusal — fatal
|
|
295
|
+
// classification, the rejection observer, and the synthesized answer.
|
|
296
|
+
// The serial phase used to do none of them, so one condition produced
|
|
297
|
+
// two behaviours depending on which half of the batch a call landed in.
|
|
298
|
+
if (isFatalToolError?.(aborted))
|
|
299
|
+
throw aborted;
|
|
300
|
+
reportRejection(tc.id, aborted);
|
|
301
|
+
outcomes[i] = {
|
|
302
|
+
toolMessage: toolResultMessage(tc.id, {
|
|
303
|
+
success: false,
|
|
304
|
+
kind: "not_run",
|
|
305
|
+
error: aborted.message,
|
|
306
|
+
}),
|
|
307
|
+
};
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
131
310
|
// Mirror the concurrent batch's graceful error synthesis so a failing
|
|
132
311
|
// activation call (transient network/DB/timeout) doesn't crash the
|
|
133
312
|
// run — but let a fatal error propagate for an immediate abort.
|
|
134
313
|
try {
|
|
135
|
-
const outcome = await
|
|
314
|
+
const outcome = await dispatchToolCall(tc);
|
|
136
315
|
outcomes[i] = outcome;
|
|
137
316
|
if (outcome.loadedPluginName) {
|
|
138
|
-
activatePlugins
|
|
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
|
+
}
|
|
139
329
|
}
|
|
140
330
|
if (outcome.loadedSkillRef) {
|
|
141
331
|
// Auto-load the module's owner plugin first so its tools are active
|
|
142
332
|
// by the time the agent follows the freshly-injected instructions.
|
|
143
333
|
if (outcome.autoLoadedPlugins?.length) {
|
|
144
|
-
activatePlugins
|
|
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"));
|
|
145
346
|
}
|
|
146
|
-
await activateSkills([outcome.loadedSkillRef]);
|
|
147
347
|
}
|
|
148
348
|
}
|
|
149
349
|
catch (err) {
|
|
@@ -151,14 +351,10 @@ export async function runToolLoop(params) {
|
|
|
151
351
|
throw err;
|
|
152
352
|
reportRejection(tc.id, err);
|
|
153
353
|
outcomes[i] = {
|
|
154
|
-
toolMessage: {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
success: false,
|
|
159
|
-
error: err instanceof Error ? err.message : String(err),
|
|
160
|
-
}),
|
|
161
|
-
},
|
|
354
|
+
toolMessage: toolResultMessage(tc.id, {
|
|
355
|
+
success: false,
|
|
356
|
+
error: err instanceof Error ? err.message : String(err),
|
|
357
|
+
}),
|
|
162
358
|
};
|
|
163
359
|
}
|
|
164
360
|
continue;
|
|
@@ -166,7 +362,7 @@ export async function runToolLoop(params) {
|
|
|
166
362
|
deferredIndices.push(i);
|
|
167
363
|
deferredCalls.push(tc);
|
|
168
364
|
}
|
|
169
|
-
const settled = await runToolCallsPooledByTool(deferredCalls,
|
|
365
|
+
const settled = await runToolCallsPooledByTool(deferredCalls, dispatchToolCall, { signal });
|
|
170
366
|
for (let j = 0; j < deferredCalls.length; j++) {
|
|
171
367
|
const origIndex = deferredIndices[j];
|
|
172
368
|
const call = deferredCalls[j];
|
|
@@ -183,16 +379,20 @@ export async function runToolLoop(params) {
|
|
|
183
379
|
throw settledResult.reason;
|
|
184
380
|
reportRejection(call.id, settledResult.reason);
|
|
185
381
|
outcomes[origIndex] = {
|
|
186
|
-
toolMessage: {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
+
}),
|
|
196
396
|
};
|
|
197
397
|
}
|
|
198
398
|
}
|
|
@@ -214,16 +414,20 @@ export async function runToolLoop(params) {
|
|
|
214
414
|
state.suspended = { ...outcome.suspend, resumeKind: "answer" };
|
|
215
415
|
continue; // withhold this call's tool message
|
|
216
416
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
+
}));
|
|
227
431
|
continue;
|
|
228
432
|
}
|
|
229
433
|
// `wake` (e.g. sleep_until): fall through and push the tool message, then
|
|
@@ -233,6 +437,38 @@ export async function runToolLoop(params) {
|
|
|
233
437
|
if (outcome.requestCompaction)
|
|
234
438
|
compactionRequested = true;
|
|
235
439
|
}
|
|
440
|
+
// An aborted batch ends the run HERE — after every refused call has been
|
|
441
|
+
// answered in the transcript (so nothing dangles), and before the suspend,
|
|
442
|
+
// compaction and persist arms below. Two reasons, and the second is the sharp one:
|
|
443
|
+
//
|
|
444
|
+
// - The transcript now contains a synthesized result for every refused
|
|
445
|
+
// call. Reaching `saveSession` durably records "these tools failed" for
|
|
446
|
+
// work that was never dispatched, and a resumed run reads that as fact —
|
|
447
|
+
// the same lie `isFatalToolError` already refuses to tell for a write it
|
|
448
|
+
// could not record. The suspend arm `break`s BEFORE the cancellation
|
|
449
|
+
// boundary below, so without this check that path persists it.
|
|
450
|
+
// - A host whose `signal` is not also reflected by `throwIfTimedOut` /
|
|
451
|
+
// `ensureNotCancelled` would otherwise refuse the batch, loop, and pay
|
|
452
|
+
// for another model call — every iteration to `maxIterations`.
|
|
453
|
+
//
|
|
454
|
+
// Delegating to the throw ports rather than throwing directly keeps the
|
|
455
|
+
// terminal error the host's to name (a timeout and a cancellation are
|
|
456
|
+
// different outcomes, and only the host knows which fired).
|
|
457
|
+
if (signal?.aborted === true) {
|
|
458
|
+
throwIfTimedOut?.();
|
|
459
|
+
await ensureNotCancelled?.();
|
|
460
|
+
// Neither port is required, and a host that wired only `signal` would
|
|
461
|
+
// otherwise refuse every batch and pay for a fresh model call each
|
|
462
|
+
// iteration until `maxIterations`. Break so the abort ends the run on its
|
|
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";
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
236
472
|
// A tool asked to end the run (it scheduled its own resume, or recorded an
|
|
237
473
|
// open call awaiting an answer). The tool results are already in
|
|
238
474
|
// state.messages above; stop now so the run doesn't keep going. Mark it as
|
|
@@ -243,11 +479,14 @@ export async function runToolLoop(params) {
|
|
|
243
479
|
// suspended call, and that pair must survive verbatim to be resumable.
|
|
244
480
|
if (suspendRequested) {
|
|
245
481
|
if (compactionRequested && applyCompaction && !state.suspended) {
|
|
246
|
-
|
|
247
|
-
applyCompactionResult(compacted);
|
|
248
|
-
await compacted.persist();
|
|
482
|
+
await compactNow(applyCompaction, "manual");
|
|
249
483
|
}
|
|
250
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";
|
|
251
490
|
break;
|
|
252
491
|
}
|
|
253
492
|
await ensureNotCancelled?.();
|
|
@@ -256,9 +495,7 @@ export async function runToolLoop(params) {
|
|
|
256
495
|
// compaction usage BEFORE persisting so a persist failure can't drop the
|
|
257
496
|
// tokens it already consumed.
|
|
258
497
|
if (compactionRequested && applyCompaction) {
|
|
259
|
-
|
|
260
|
-
applyCompactionResult(compacted);
|
|
261
|
-
await compacted.persist();
|
|
498
|
+
await compactNow(applyCompaction, "manual");
|
|
262
499
|
}
|
|
263
500
|
// Drain human interrupts queued while the agent was working.
|
|
264
501
|
for (const interrupt of drainInterrupts?.() ?? []) {
|
|
@@ -266,7 +503,7 @@ export async function runToolLoop(params) {
|
|
|
266
503
|
// `[Interrupt from user …]` header line and inject transcript structure.
|
|
267
504
|
// (Internal-only sources today, but a future external caller could surface
|
|
268
505
|
// user-supplied ids.)
|
|
269
|
-
const safeUserId = interrupt.userId
|
|
506
|
+
const safeUserId = stripControlChars(interrupt.userId);
|
|
270
507
|
state.messages.push({
|
|
271
508
|
role: "user",
|
|
272
509
|
content: `[Interrupt from user ${safeUserId}]\n${interrupt.content}`,
|
|
@@ -280,9 +517,8 @@ export async function runToolLoop(params) {
|
|
|
280
517
|
if (state.lastPromptTokens > 0 &&
|
|
281
518
|
needsCompaction?.(currentTokens) &&
|
|
282
519
|
applyCompaction) {
|
|
283
|
-
|
|
284
|
-
applyCompactionResult(compacted);
|
|
285
|
-
await compacted.persist();
|
|
520
|
+
await compactNow(applyCompaction, "auto");
|
|
286
521
|
}
|
|
287
522
|
}
|
|
523
|
+
return { stopReason, stats };
|
|
288
524
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juno-ai/bind",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "10.0.0",
|
|
4
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).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
"./plugins": {
|
|
43
43
|
"types": "./plugins/index.d.ts",
|
|
44
44
|
"import": "./plugins/index.js"
|
|
45
|
+
},
|
|
46
|
+
"./testing": {
|
|
47
|
+
"types": "./testing/index.d.ts",
|
|
48
|
+
"import": "./testing/index.js"
|
|
45
49
|
}
|
|
46
50
|
},
|
|
47
51
|
"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;
|