@juno-ai/bind 9.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 +229 -15
- 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 +117 -12
- package/loop/tool-loop.js +242 -67
- 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/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.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type OpenAI from "openai";
|
|
2
|
+
import { type RunStats, type StopReason } from "../contracts/turn.js";
|
|
2
3
|
/**
|
|
3
4
|
* The agent iteration engine: call the model, run the tools it asked for,
|
|
4
5
|
* repeat until it stops asking. Everything that *happens* as a result — status
|
|
@@ -19,11 +20,13 @@ import type OpenAI from "openai";
|
|
|
19
20
|
* than thrown. Without it they are invisible — the model sees them, your
|
|
20
21
|
* logs do not.
|
|
21
22
|
*
|
|
22
|
-
* Turn accounting is
|
|
23
|
-
* (`ToolLoopTurn`), not the richer `ModelTurnResult` in
|
|
24
|
-
* with its timings. The
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* Turn accounting is the flat usage a host's `callModel` returns
|
|
24
|
+
* (`ToolLoopTurn`), not the richer `ModelTurnResult` in
|
|
25
|
+
* `@juno-ai/bind/contracts` with its timings. The loop bridges them: it
|
|
26
|
+
* measures each `callModel` with an injectable `now` and folds the pair
|
|
27
|
+
* through `accumulateTurn`, so `runToolLoop` returns a full `RunStats`
|
|
28
|
+
* without a host changing the shape it already returns. `ttftMs` is the one
|
|
29
|
+
* field that cannot cross — only the transport sees the first byte.
|
|
27
30
|
*/
|
|
28
31
|
/** One model completion's message + the provider usage the loop accounts for. */
|
|
29
32
|
export interface ToolLoopTurn {
|
|
@@ -31,6 +34,12 @@ export interface ToolLoopTurn {
|
|
|
31
34
|
inputTokens: number;
|
|
32
35
|
outputTokens: number;
|
|
33
36
|
costCents: number;
|
|
37
|
+
/**
|
|
38
|
+
* Provider-reported cached input tokens, when the transport can report them.
|
|
39
|
+
* Optional — omit it (or pass `null`) and the run's `cachedInputTokens` total
|
|
40
|
+
* simply does not count this turn, rather than counting it as a zero.
|
|
41
|
+
*/
|
|
42
|
+
cachedInputTokens?: number | null;
|
|
34
43
|
}
|
|
35
44
|
/**
|
|
36
45
|
* Outcome of running one tool call inside an assistant `tool_calls` batch.
|
|
@@ -82,6 +91,8 @@ export interface CompactionApplied {
|
|
|
82
91
|
inputTokens: number;
|
|
83
92
|
outputTokens: number;
|
|
84
93
|
costCents: number;
|
|
94
|
+
/** Provider-reported cached input tokens for the compaction call, if known. */
|
|
95
|
+
cachedInputTokens?: number | null;
|
|
85
96
|
/** Persist the compacted session + activity row. Runs after accounting. */
|
|
86
97
|
persist: () => Promise<void>;
|
|
87
98
|
}
|
|
@@ -123,10 +134,79 @@ export interface ToolLoopState {
|
|
|
123
134
|
};
|
|
124
135
|
}
|
|
125
136
|
export type RunStatus = "thinking" | "thinking_with_tools" | "executing_tools";
|
|
137
|
+
/**
|
|
138
|
+
* A tool asked the loop to activate a plugin or an instruction module, and the
|
|
139
|
+
* port that would do it was not wired.
|
|
140
|
+
*
|
|
141
|
+
* Reported through `onToolCallRejected` rather than thrown: the call itself
|
|
142
|
+
* succeeded and its tool message is already correct, so failing the run would
|
|
143
|
+
* be worse than the missing activation. But it must not be silent — before
|
|
144
|
+
* these ports were optional this was a compile error, and the runtime symptom
|
|
145
|
+
* (an agent that keeps loading a plugin it never receives) points nowhere near
|
|
146
|
+
* the cause.
|
|
147
|
+
*
|
|
148
|
+
* Match on `error.name === "MissingActivationPortError"` rather than
|
|
149
|
+
* `instanceof` if you consume this package from a projected or re-bundled copy
|
|
150
|
+
* — two copies of a class in one module graph make `instanceof` silently
|
|
151
|
+
* false, and this package is Copybara-projected and republished.
|
|
152
|
+
*/
|
|
153
|
+
export declare class MissingActivationPortError extends Error {
|
|
154
|
+
readonly port: "activatePlugins" | "activateSkills";
|
|
155
|
+
readonly name = "MissingActivationPortError";
|
|
156
|
+
constructor(port: "activatePlugins" | "activateSkills");
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* What a completed loop reports back.
|
|
160
|
+
*
|
|
161
|
+
* Returned rather than folded into `ToolLoopState` because these are the run's
|
|
162
|
+
* *conclusion*, not its live progress: a caller reads `state` mid-run for a
|
|
163
|
+
* heartbeat, and reads this once, after. Before it existed every host
|
|
164
|
+
* re-derived the outcome from three flags and an iteration count, and each got
|
|
165
|
+
* a slightly different answer.
|
|
166
|
+
*/
|
|
167
|
+
export interface ToolLoopResult {
|
|
168
|
+
/**
|
|
169
|
+
* Why the loop stopped. See {@link StopReason} — `deadline` arrives as a
|
|
170
|
+
* thrown error rather than a value, and is never returned here.
|
|
171
|
+
*
|
|
172
|
+
* `aborted` means only "the batch signal was set, or `shouldStop` said so".
|
|
173
|
+
* It does not say *which*, because a combined signal cannot. A host that
|
|
174
|
+
* needs timeout-vs-cancellation reaches for its own `RunDeadline` (and
|
|
175
|
+
* `classifyRunFailure`), not for this value.
|
|
176
|
+
*/
|
|
177
|
+
readonly stopReason: StopReason;
|
|
178
|
+
/**
|
|
179
|
+
* Cumulative accounting for the run: turns, dispatched tool calls, tokens
|
|
180
|
+
* (including provider-reported cached input), cost, and the model-time vs
|
|
181
|
+
* tool-time split with a per-tool breakdown.
|
|
182
|
+
*
|
|
183
|
+
* **`stats` is this invocation's contribution alone**; `state` is whatever
|
|
184
|
+
* the caller seeded plus that. They match only when the caller seeded zeros
|
|
185
|
+
* — a host resuming a run seeds `state` from the stored totals, and then
|
|
186
|
+
* `state.costCents` is the run's lifetime cost while `stats.costCents` is
|
|
187
|
+
* this leg's. Bill from whichever you mean, and do not substitute one for
|
|
188
|
+
* the other.
|
|
189
|
+
*
|
|
190
|
+
* They also differ on tool calls on purpose: `state.toolCalls` counts what
|
|
191
|
+
* the model *requested* (it drives a live progress indicator, so it has to
|
|
192
|
+
* rise the moment a batch is dispatched), while `stats.toolCalls` counts
|
|
193
|
+
* what actually *ran*. An aborted batch is exactly the gap between them.
|
|
194
|
+
*
|
|
195
|
+
* Both are lost if the loop throws — a deadline, a cancellation, or a fatal
|
|
196
|
+
* tool error leaves no return value, so `state` (which is mutated in place)
|
|
197
|
+
* is the only accounting that survives those exits.
|
|
198
|
+
*/
|
|
199
|
+
readonly stats: RunStats;
|
|
200
|
+
}
|
|
126
201
|
export interface ToolLoopParams {
|
|
127
202
|
state: ToolLoopState;
|
|
128
|
-
/**
|
|
129
|
-
|
|
203
|
+
/**
|
|
204
|
+
* Active plugin set. The loop does not read it — `buildTools` and
|
|
205
|
+
* `activatePlugins` are the host's own closures over it — so it is optional
|
|
206
|
+
* and passing one is purely a convenience for a host that likes threading it
|
|
207
|
+
* through explicitly.
|
|
208
|
+
*/
|
|
209
|
+
activePlugins?: Set<string>;
|
|
130
210
|
maxIterations: number;
|
|
131
211
|
/** Call the model with the current transcript + tool defs. `onOutputProgress`
|
|
132
212
|
* (optional) receives a running estimate of THIS call's output tokens as the
|
|
@@ -137,15 +217,38 @@ export interface ToolLoopParams {
|
|
|
137
217
|
buildTools: () => OpenAI.ChatCompletionTool[];
|
|
138
218
|
/** Execute one tool call → the `tool` message + control signals. */
|
|
139
219
|
runToolCall: (toolCall: OpenAI.ChatCompletionMessageToolCall) => Promise<ToolCallOutcome>;
|
|
140
|
-
/**
|
|
141
|
-
|
|
220
|
+
/**
|
|
221
|
+
* Activate newly loaded plugins (mutate the catalog/active set). Optional:
|
|
222
|
+
* a host with a fixed tool surface has nothing to activate, and requiring an
|
|
223
|
+
* empty function from it bought nothing.
|
|
224
|
+
*/
|
|
225
|
+
activatePlugins?: (pluginNames: string[]) => void;
|
|
142
226
|
/**
|
|
143
227
|
* Activate newly loaded skills: inject their bodies into the system prompt's
|
|
144
228
|
* instructions section and refresh the catalog. Async because a host may
|
|
145
229
|
* re-read the module body from storage. Expected to no-op for a ref the agent
|
|
146
|
-
* cannot access — the loop does not pre-validate them.
|
|
230
|
+
* cannot access — the loop does not pre-validate them. Optional, as above.
|
|
231
|
+
*/
|
|
232
|
+
activateSkills?: (skillRefs: string[]) => Promise<void> | void;
|
|
233
|
+
/**
|
|
234
|
+
* Clock for the model-time and tool-time measurements in
|
|
235
|
+
* {@link ToolLoopResult.stats}. Defaults to `Date.now`, the package's one
|
|
236
|
+
* sanctioned ambient-clock exception; inject a fake to make timing
|
|
237
|
+
* assertions deterministic.
|
|
238
|
+
*
|
|
239
|
+
* **Must be a real monotonic clock when tools can run concurrently.** Each
|
|
240
|
+
* call records `now()` at dispatch and again when it settles, so a shared
|
|
241
|
+
* counter that only advances when some *other* call asks it to will charge
|
|
242
|
+
* one tool for another's time. A cooperatively-advanced fake is fine for a
|
|
243
|
+
* serial batch (`runsSerially`), and fine for the model-time figures always.
|
|
244
|
+
*
|
|
245
|
+
* Measured *around* `callModel`, so the number includes whatever that
|
|
246
|
+
* function does internally — a defect retry, a fallback provider, a routing
|
|
247
|
+
* hop. That is the honest figure for cost-and-latency accounting: it is what
|
|
248
|
+
* the turn actually took. A host that wants the successful attempt's
|
|
249
|
+
* generation time alone already has it, inside its own transport.
|
|
147
250
|
*/
|
|
148
|
-
|
|
251
|
+
now?: () => number;
|
|
149
252
|
/**
|
|
150
253
|
* Must this call run on its own, before the rest of its batch?
|
|
151
254
|
*
|
|
@@ -270,5 +373,7 @@ export interface ToolLoopParams {
|
|
|
270
373
|
* Mutates `state` (messages + token accumulators) in place. That is deliberate
|
|
271
374
|
* rather than a return value: a caller's heartbeat reads live totals off it
|
|
272
375
|
* mid-loop, which a returned result could not provide until the run ended.
|
|
376
|
+
* The {@link ToolLoopResult} it *returns* is the complementary half — the
|
|
377
|
+
* run's conclusion, which only exists once the loop is over.
|
|
273
378
|
*/
|
|
274
|
-
export declare function runToolLoop(params: ToolLoopParams): Promise<
|
|
379
|
+
export declare function runToolLoop(params: ToolLoopParams): Promise<ToolLoopResult>;
|
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).
|
|
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");
|
|
@@ -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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
314
|
+
const outcome = await dispatchToolCall(tc);
|
|
161
315
|
outcomes[i] = outcome;
|
|
162
316
|
if (outcome.loadedPluginName) {
|
|
163
|
-
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
|
+
}
|
|
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
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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,
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,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": {
|