@juno-ai/bind 3.0.0 → 5.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 +314 -81
- package/completion/defects.d.ts +138 -0
- package/completion/defects.js +128 -0
- package/completion/index.d.ts +2 -0
- package/completion/index.js +2 -0
- package/completion/watchdog.d.ts +165 -0
- package/completion/watchdog.js +211 -0
- package/contracts/index.d.ts +1 -1
- package/contracts/index.js +1 -1
- package/contracts/turn.d.ts +26 -2
- package/contracts/turn.js +45 -0
- package/index.d.ts +13 -7
- package/index.js +13 -7
- package/loop/index.d.ts +1 -0
- package/loop/index.js +1 -0
- package/loop/tool-loop.d.ts +260 -0
- package/loop/tool-loop.js +288 -0
- package/package.json +10 -2
- package/routing/attempt-errors.d.ts +122 -0
- package/routing/attempt-errors.js +176 -0
- package/routing/executor.js +16 -1
- package/routing/index.d.ts +1 -0
- package/routing/index.js +1 -0
- package/run/children.d.ts +204 -0
- package/run/children.js +226 -0
- package/run/index.d.ts +1 -0
- package/run/index.js +1 -0
package/index.js
CHANGED
|
@@ -5,17 +5,23 @@
|
|
|
5
5
|
* completion into tool effects into the next turn's context. This package is
|
|
6
6
|
* the harness that runs that chain.
|
|
7
7
|
*
|
|
8
|
-
* Current surface: the
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* and
|
|
13
|
-
*
|
|
14
|
-
*
|
|
8
|
+
* Current surface: the tool-calling turn kernel (`src/loop/` — the iteration
|
|
9
|
+
* engine itself), the deterministic LLM provider-routing core with its
|
|
10
|
+
* transport-error classifier, the turn vocabulary, the run mechanics (deadline,
|
|
11
|
+
* coalesced heartbeat, failure classification, tool-batch pooling, child-run
|
|
12
|
+
* lineage and admission), the streaming-completion watchdog and completion
|
|
13
|
+
* defect detection (`src/completion/`), transcript validation/healing, provider
|
|
14
|
+
* tool-schema sanitization, and the plugin/tool vocabulary with its registry and
|
|
15
|
+
* progressive-disclosure activation — generic over the host's invocation
|
|
16
|
+
* context. What is NOT here is
|
|
17
|
+
* the run driver: starting a run, recording what it did, and delivering its
|
|
18
|
+
* output. See the README for the rest of what is deliberately absent.
|
|
15
19
|
*/
|
|
16
20
|
export * from "./routing/index.js";
|
|
21
|
+
export * from "./completion/index.js";
|
|
17
22
|
export * from "./contracts/index.js";
|
|
18
23
|
export * from "./run/index.js";
|
|
19
24
|
export * from "./transcript/index.js";
|
|
20
25
|
export * from "./tools/index.js";
|
|
21
26
|
export * from "./plugins/index.js";
|
|
27
|
+
export * from "./loop/index.js";
|
package/loop/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { runToolLoop, type ToolLoopParams, type ToolLoopState, type ToolLoopTurn, type ToolCallOutcome, type CompactionApplied, type RunStatus, } from "./tool-loop.js";
|
package/loop/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { runToolLoop, } from "./tool-loop.js";
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import type OpenAI from "openai";
|
|
2
|
+
/**
|
|
3
|
+
* The agent iteration engine: call the model, run the tools it asked for,
|
|
4
|
+
* repeat until it stops asking. Everything that *happens* as a result — status
|
|
5
|
+
* updates, heartbeats, activity rows, persistence, cancellation — is injected,
|
|
6
|
+
* so the loop itself does no I/O and holds no host vocabulary.
|
|
7
|
+
*
|
|
8
|
+
* Three ports are worth understanding before wiring this up, because each
|
|
9
|
+
* replaced something the loop previously hardcoded:
|
|
10
|
+
*
|
|
11
|
+
* - **`runsSerially`** decides which calls in a batch must run one at a time,
|
|
12
|
+
* ahead of the rest. Not a performance knob: a call that changes what tools
|
|
13
|
+
* exist has to take effect before a later call in the same batch tries to
|
|
14
|
+
* use them.
|
|
15
|
+
* - **`isFatalToolError`** decides which thrown errors abort the run instead
|
|
16
|
+
* of becoming a tool error the model can read. Cancellation and "we failed
|
|
17
|
+
* to record the result" belong here; a tool that simply failed does not.
|
|
18
|
+
* - **`onToolCallRejected`** observes the errors that were synthesized rather
|
|
19
|
+
* than thrown. Without it they are invisible — the model sees them, your
|
|
20
|
+
* logs do not.
|
|
21
|
+
*
|
|
22
|
+
* Turn accounting is deliberately the flat usage the loop needs to run
|
|
23
|
+
* (`ToolLoopTurn`), not the richer `ModelTurnResult` in `@juno-ai/bind/contracts`
|
|
24
|
+
* with its timings. The two describe the same event at different resolutions
|
|
25
|
+
* and converge when the loop learns to accumulate `RunStats` directly; until
|
|
26
|
+
* then a host that wants throughput metrics folds them alongside.
|
|
27
|
+
*/
|
|
28
|
+
/** One model completion's message + the provider usage the loop accounts for. */
|
|
29
|
+
export interface ToolLoopTurn {
|
|
30
|
+
message: OpenAI.ChatCompletionMessage;
|
|
31
|
+
inputTokens: number;
|
|
32
|
+
outputTokens: number;
|
|
33
|
+
costCents: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Outcome of running one tool call inside an assistant `tool_calls` batch.
|
|
37
|
+
*
|
|
38
|
+
* Every optional field is a **control signal**: the loop branches on it. They
|
|
39
|
+
* are not host payload passing through — `loadedPluginName` grows the active
|
|
40
|
+
* set, `requestCompaction` triggers a compaction at the batch boundary, and
|
|
41
|
+
* `suspend` ends the run. A host's own per-call data belongs inside
|
|
42
|
+
* `toolMessage`, which the loop only appends.
|
|
43
|
+
*/
|
|
44
|
+
export type ToolCallOutcome = {
|
|
45
|
+
toolMessage: OpenAI.ChatCompletionToolMessageParam;
|
|
46
|
+
/**
|
|
47
|
+
* A plugin this call activated; the loop adds it to the active set at once —
|
|
48
|
+
* but ONLY from the serial phase. A call that reaches the concurrent phase
|
|
49
|
+
* has already missed its window (a later call in the same batch could
|
|
50
|
+
* already be running), so this field is ignored there rather than applied
|
|
51
|
+
* late. Return it from a call your `runsSerially` selects, or it is dropped.
|
|
52
|
+
*/
|
|
53
|
+
loadedPluginName?: string;
|
|
54
|
+
/** An instruction module this call loaded, passed to `activateSkills`. Serial phase only, as above. */
|
|
55
|
+
loadedSkillRef?: string;
|
|
56
|
+
/** Plugins the loaded module needs, activated before it. Serial phase only, as above. */
|
|
57
|
+
autoLoadedPlugins?: string[];
|
|
58
|
+
/** Compact the transcript at this batch's boundary. */
|
|
59
|
+
requestCompaction?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* End the run. `answer` records the open call and WITHHOLDS its tool message
|
|
62
|
+
* — the result is a human's future answer, threaded back on resume — so at
|
|
63
|
+
* most one may be open at a time; a second in the same batch is answered with
|
|
64
|
+
* an error rather than left unpaired. `wake` keeps its tool message and
|
|
65
|
+
* re-enters through a fresh prompt.
|
|
66
|
+
*/
|
|
67
|
+
suspend?: {
|
|
68
|
+
toolCallId: string;
|
|
69
|
+
reason: string;
|
|
70
|
+
resumeKind: "answer" | "wake";
|
|
71
|
+
request?: unknown;
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Result of compacting: the new transcript + the compaction call's own usage,
|
|
76
|
+
* plus a deferred `persist` step. The loop applies accounting + swaps the
|
|
77
|
+
* transcript BEFORE calling `persist`, so a persistence failure can't drop the
|
|
78
|
+
* tokens the compaction LLM call already consumed.
|
|
79
|
+
*/
|
|
80
|
+
export interface CompactionApplied {
|
|
81
|
+
messages: OpenAI.ChatCompletionMessageParam[];
|
|
82
|
+
inputTokens: number;
|
|
83
|
+
outputTokens: number;
|
|
84
|
+
costCents: number;
|
|
85
|
+
/** Persist the compacted session + activity row. Runs after accounting. */
|
|
86
|
+
persist: () => Promise<void>;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Mutable accumulator threaded through the loop. The caller seeds it and reads
|
|
90
|
+
* it back after; a shared object (rather than return values) lets the caller's
|
|
91
|
+
* coalesced heartbeat read live token totals mid-loop. `messages` is mutated in
|
|
92
|
+
* place (assistant + tool turns appended; replaced wholesale on compaction).
|
|
93
|
+
*/
|
|
94
|
+
export interface ToolLoopState {
|
|
95
|
+
messages: OpenAI.ChatCompletionMessageParam[];
|
|
96
|
+
inputTokens: number;
|
|
97
|
+
outputTokens: number;
|
|
98
|
+
costCents: number;
|
|
99
|
+
/** Provider prompt/completion tokens from the most recent turn (0 right after a compaction). */
|
|
100
|
+
lastPromptTokens: number;
|
|
101
|
+
lastOutputTokens: number;
|
|
102
|
+
/** True once any turn has produced a real provider count this run. */
|
|
103
|
+
hasFreshTokenCount: boolean;
|
|
104
|
+
/** Cumulative tool calls dispatched this run — the count only, never names or
|
|
105
|
+
* results. Feeds a live progress indicator via `onProgressUpdate`. */
|
|
106
|
+
toolCalls: number;
|
|
107
|
+
/** True when the loop ended because a tool suspended the run (asked a human a
|
|
108
|
+
* question, or scheduled its own resume). Distinguishes an intentional pause
|
|
109
|
+
* from an iteration-limit cutoff — without it a caller reports "couldn't
|
|
110
|
+
* finish" over a run that stopped exactly where it meant to. */
|
|
111
|
+
endedTurnViaTool?: boolean;
|
|
112
|
+
/** Set when a tool suspended the run awaiting an answer: the open tool-call is
|
|
113
|
+
* waiting on a human or an external system. The loop withholds that call's
|
|
114
|
+
* `tool` message — its result IS the future answer — and ends the run; a
|
|
115
|
+
* resume threads the answer back as the matching `role:"tool"` result. At
|
|
116
|
+
* most one may be open at a time; extras in the same batch are answered with
|
|
117
|
+
* a synthesized error so no second slot is left unpaired. */
|
|
118
|
+
suspended?: {
|
|
119
|
+
toolCallId: string;
|
|
120
|
+
reason: string;
|
|
121
|
+
resumeKind: "answer";
|
|
122
|
+
request?: unknown;
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
export type RunStatus = "thinking" | "thinking_with_tools" | "executing_tools";
|
|
126
|
+
export interface ToolLoopParams {
|
|
127
|
+
state: ToolLoopState;
|
|
128
|
+
/** Active plugin set — read to build tool defs, grown by `activatePlugins`. */
|
|
129
|
+
activePlugins: Set<string>;
|
|
130
|
+
maxIterations: number;
|
|
131
|
+
/** Call the model with the current transcript + tool defs. `onOutputProgress`
|
|
132
|
+
* (optional) receives a running estimate of THIS call's output tokens as the
|
|
133
|
+
* stream flows (throttled ~1 Hz inside the model call); the loop adds the
|
|
134
|
+
* prior cumulative before forwarding to `onProgressUpdate`. */
|
|
135
|
+
callModel: (messages: OpenAI.ChatCompletionMessageParam[], tools: OpenAI.ChatCompletionTool[] | undefined, onOutputProgress?: (estimatedOutputTokens: number) => void) => Promise<ToolLoopTurn>;
|
|
136
|
+
/** Build the tool definitions for the current active-plugin set (+ MCP). */
|
|
137
|
+
buildTools: () => OpenAI.ChatCompletionTool[];
|
|
138
|
+
/** Execute one tool call → the `tool` message + control signals. */
|
|
139
|
+
runToolCall: (toolCall: OpenAI.ChatCompletionMessageToolCall) => Promise<ToolCallOutcome>;
|
|
140
|
+
/** Activate newly loaded plugins (mutate the catalog/active set). */
|
|
141
|
+
activatePlugins: (pluginNames: string[]) => void;
|
|
142
|
+
/**
|
|
143
|
+
* Activate newly loaded skills: inject their bodies into the system prompt's
|
|
144
|
+
* instructions section and refresh the catalog. Async because a host may
|
|
145
|
+
* 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.
|
|
147
|
+
*/
|
|
148
|
+
activateSkills: (skillRefs: string[]) => Promise<void> | void;
|
|
149
|
+
/**
|
|
150
|
+
* Must this call run on its own, before the rest of its batch?
|
|
151
|
+
*
|
|
152
|
+
* True for any call that changes what the later calls can do — activating a
|
|
153
|
+
* plugin, loading an instruction module. The loop runs those one at a time
|
|
154
|
+
* and applies each outcome immediately, so a dependent call in the SAME batch
|
|
155
|
+
* sees the effect. Everything else fans out concurrently, pooled per tool
|
|
156
|
+
* name.
|
|
157
|
+
*
|
|
158
|
+
* Unwired, nothing is serial: every call runs in the concurrent phase, which
|
|
159
|
+
* is correct for a host whose tools do not reshape the tool surface.
|
|
160
|
+
*/
|
|
161
|
+
runsSerially?: (toolCall: OpenAI.ChatCompletionMessageToolCall) => boolean;
|
|
162
|
+
/**
|
|
163
|
+
* Should this thrown error abort the run rather than become a tool error the
|
|
164
|
+
* model reads?
|
|
165
|
+
*
|
|
166
|
+
* Two things belong here: **cancellation**, which must propagate even when no
|
|
167
|
+
* `ensureNotCancelled` observer is wired, and a failure to *record* an
|
|
168
|
+
* outcome — synthesizing "the tool failed" over a persistence failure tells
|
|
169
|
+
* the model a lie about work that may well have happened.
|
|
170
|
+
*
|
|
171
|
+
* Unwired, nothing is fatal: every failure is synthesized into a tool error
|
|
172
|
+
* and the run continues. That is the safe default for an isolated tool, and
|
|
173
|
+
* the wrong one as soon as your tools write anything.
|
|
174
|
+
*/
|
|
175
|
+
isFatalToolError?: (error: unknown) => boolean;
|
|
176
|
+
/**
|
|
177
|
+
* A tool call failed and was answered with a synthesized error instead of
|
|
178
|
+
* throwing. The model sees it either way; without this observer nothing else
|
|
179
|
+
* does.
|
|
180
|
+
*/
|
|
181
|
+
onToolCallRejected?: (toolCallId: string, error: unknown) => void;
|
|
182
|
+
/** Throw to abort (run cancelled). Checked at the top of each iteration and after each batch. */
|
|
183
|
+
ensureNotCancelled?: () => Promise<void> | void;
|
|
184
|
+
/** Throw a timeout error if the wall-clock budget is exhausted (checked first each iteration). */
|
|
185
|
+
throwIfTimedOut?: () => void;
|
|
186
|
+
/** Phase status for rich client feedback. */
|
|
187
|
+
onStatus?: (status: RunStatus) => void;
|
|
188
|
+
/** Intermediate assistant text emitted alongside tool calls. NOTE: when
|
|
189
|
+
* `onAssistantMessage` is also wired, a text-with-tools message fires to BOTH
|
|
190
|
+
* observers (they have different contracts — see below). Wire only one per
|
|
191
|
+
* output surface so the same text isn't delivered twice. */
|
|
192
|
+
onThinking?: (content: string) => void;
|
|
193
|
+
/**
|
|
194
|
+
* Every assistant message that carries non-empty text content, fired the
|
|
195
|
+
* moment it's produced — whether or not it also requested tools, and including
|
|
196
|
+
* the final tool-less reply. Unlike `onThinking` (text-with-tools only), this
|
|
197
|
+
* sees ALL of a turn's assistant text, so a caller can stream each message to
|
|
198
|
+
* the client as it lands rather than batching them at turn end. Receives the
|
|
199
|
+
* already-trimmed content.
|
|
200
|
+
*/
|
|
201
|
+
onAssistantMessage?: (content: string) => void;
|
|
202
|
+
/** Flush accumulated progress (heartbeat). Called force-true after each turn. */
|
|
203
|
+
flushProgress?: () => Promise<void> | void;
|
|
204
|
+
/** Cumulative live progress: the running output-token count (updated
|
|
205
|
+
* mid-stream and after each model turn) and the number of tool calls made so
|
|
206
|
+
* far this run (the count only — never a name or a result). Called with the
|
|
207
|
+
* estimate during streaming and with real totals at each iteration boundary,
|
|
208
|
+
* and again right after a batch is dispatched so the count rises promptly. */
|
|
209
|
+
onProgressUpdate?: (outputTokens: number, toolCalls: number) => void;
|
|
210
|
+
/** Return true to stop the loop after the current turn (e.g. agent disabled mid-run). */
|
|
211
|
+
shouldStop?: () => Promise<boolean> | boolean;
|
|
212
|
+
/**
|
|
213
|
+
* Called when the model produced a turn with NO tool calls — i.e. the loop is
|
|
214
|
+
* about to stop. Return a non-empty string to inject it as a synthetic `user`
|
|
215
|
+
* message and CONTINUE the loop instead of stopping; return null/empty to stop
|
|
216
|
+
* as normal (the default behavior when unwired). Use it to push a stalled turn
|
|
217
|
+
* forward — nudging an agent that ended a turn having made no progress at all.
|
|
218
|
+
* Receives the cumulative tool-call count so the hook can detect exactly that.
|
|
219
|
+
* MUST be self-bounding (eventually return null); `maxIterations` bounds it
|
|
220
|
+
* regardless, and the injected `user` message is model-only — a caller that
|
|
221
|
+
* persists a transcript should ignore `user` turns it didn't originate.
|
|
222
|
+
*
|
|
223
|
+
* `cumulativeToolCalls` is the running total across ALL iterations of this run
|
|
224
|
+
* (not just the current one), so a hook can detect "made zero tool calls the
|
|
225
|
+
* whole run" — a tool call in any earlier iteration makes it non-zero.
|
|
226
|
+
*/
|
|
227
|
+
onTurnWouldEnd?: (assistantMessage: OpenAI.ChatCompletionMessage, cumulativeToolCalls: number) => string | null | Promise<string | null>;
|
|
228
|
+
/** Drain queued human interrupts for this run. */
|
|
229
|
+
drainInterrupts?: () => Array<{
|
|
230
|
+
userId: string;
|
|
231
|
+
content: string;
|
|
232
|
+
}>;
|
|
233
|
+
/** Report that an interrupt was received (activity row). */
|
|
234
|
+
onInterruptReceived?: (interrupt: {
|
|
235
|
+
userId: string;
|
|
236
|
+
content: string;
|
|
237
|
+
}) => Promise<void> | void;
|
|
238
|
+
/** Decide whether the live context (token count) needs auto-compaction. */
|
|
239
|
+
needsCompaction?: (currentTokens: number) => boolean;
|
|
240
|
+
/**
|
|
241
|
+
* Perform + persist a compaction and return the new transcript + usage. The
|
|
242
|
+
* loop owns swapping `state.messages` and resetting the live counts; this
|
|
243
|
+
* callback owns the LLM passes and persistence (session + activity row).
|
|
244
|
+
*/
|
|
245
|
+
applyCompaction?: (trigger: "manual" | "auto", messages: OpenAI.ChatCompletionMessageParam[]) => Promise<CompactionApplied>;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Repeatedly call the model and execute the tools it requests, until it stops
|
|
249
|
+
* requesting them, a caller stops the loop, a tool suspends the run, or
|
|
250
|
+
* `maxIterations` is reached.
|
|
251
|
+
*
|
|
252
|
+
* Intrinsic: plugin/module activation, two-phase tool batching, compaction
|
|
253
|
+
* (manual + auto), interrupt draining, and the suspend protocol. Injected:
|
|
254
|
+
* every side effect — status, heartbeat, activity, persistence, cancellation.
|
|
255
|
+
*
|
|
256
|
+
* Mutates `state` (messages + token accumulators) in place. That is deliberate
|
|
257
|
+
* rather than a return value: a caller's heartbeat reads live totals off it
|
|
258
|
+
* mid-loop, which a returned result could not provide until the run ended.
|
|
259
|
+
*/
|
|
260
|
+
export declare function runToolLoop(params: ToolLoopParams): Promise<void>;
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { runToolCallsPooledByTool } from "../run/tool-batch.js";
|
|
2
|
+
/**
|
|
3
|
+
* Repeatedly call the model and execute the tools it requests, until it stops
|
|
4
|
+
* requesting them, a caller stops the loop, a tool suspends the run, or
|
|
5
|
+
* `maxIterations` is reached.
|
|
6
|
+
*
|
|
7
|
+
* Intrinsic: plugin/module activation, two-phase tool batching, compaction
|
|
8
|
+
* (manual + auto), interrupt draining, and the suspend protocol. Injected:
|
|
9
|
+
* every side effect — status, heartbeat, activity, persistence, cancellation.
|
|
10
|
+
*
|
|
11
|
+
* Mutates `state` (messages + token accumulators) in place. That is deliberate
|
|
12
|
+
* rather than a return value: a caller's heartbeat reads live totals off it
|
|
13
|
+
* mid-loop, which a returned result could not provide until the run ended.
|
|
14
|
+
*/
|
|
15
|
+
export async function runToolLoop(params) {
|
|
16
|
+
const { state, maxIterations, callModel, buildTools, runToolCall, activatePlugins, activateSkills, ensureNotCancelled, throwIfTimedOut, onStatus, onThinking, onAssistantMessage, flushProgress, onProgressUpdate, shouldStop, onTurnWouldEnd, drainInterrupts, onInterruptReceived, needsCompaction, applyCompaction, runsSerially, isFatalToolError, onToolCallRejected, } = params;
|
|
17
|
+
// An observer must not be able to change control flow: a host logger that
|
|
18
|
+
// throws while reporting a tool failure would otherwise turn a *reported*
|
|
19
|
+
// failure into a fatal one, which is the opposite of what the report is for.
|
|
20
|
+
const reportRejection = (toolCallId, error) => {
|
|
21
|
+
try {
|
|
22
|
+
onToolCallRejected?.(toolCallId, error);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Nothing useful to do — the reporting channel is the thing that broke.
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
// Swap in a freshly-compacted transcript and reset the live counts. The
|
|
29
|
+
// provider count no longer reflects the compacted array, so drop it — the
|
|
30
|
+
// auto-compaction check skips while it's 0 (preventing an immediate
|
|
31
|
+
// re-trigger), and the next turn records a fresh real count.
|
|
32
|
+
const applyCompactionResult = (result) => {
|
|
33
|
+
state.inputTokens += result.inputTokens;
|
|
34
|
+
state.outputTokens += result.outputTokens;
|
|
35
|
+
state.costCents += result.costCents;
|
|
36
|
+
state.messages.length = 0;
|
|
37
|
+
state.messages.push(...result.messages);
|
|
38
|
+
state.lastPromptTokens = 0;
|
|
39
|
+
state.lastOutputTokens = 0;
|
|
40
|
+
};
|
|
41
|
+
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
42
|
+
throwIfTimedOut?.();
|
|
43
|
+
await ensureNotCancelled?.();
|
|
44
|
+
const tools = buildTools();
|
|
45
|
+
onStatus?.(iteration === 0 ? "thinking" : "thinking_with_tools");
|
|
46
|
+
// Stream live token progress: the per-call estimate is added to the
|
|
47
|
+
// cumulative from prior iterations so a caller's counter rises across a
|
|
48
|
+
// multi-iteration run. The real cumulative is published right after the
|
|
49
|
+
// call returns (below), reconciling any estimate drift.
|
|
50
|
+
//
|
|
51
|
+
// Clamped to a per-call high-water mark because `callModel` may internally
|
|
52
|
+
// retry — a defect retry, another provider, the fallback model — and each
|
|
53
|
+
// attempt restarts its own estimate at zero. Unclamped, a caller's counter
|
|
54
|
+
// visibly runs backwards mid-turn ("1.2k tokens" → blank → "120 tokens"),
|
|
55
|
+
// which reads as lost work at exactly the moment the system is recovering
|
|
56
|
+
// from a fault. The mark is per-call, so the post-call reconciliation to
|
|
57
|
+
// the real total below is free to correct downward.
|
|
58
|
+
const baseOutputTokens = state.outputTokens;
|
|
59
|
+
let progressHighWater = baseOutputTokens;
|
|
60
|
+
const result = await callModel(state.messages, tools.length > 0 ? tools : undefined,
|
|
61
|
+
// Carry the cumulative tool count alongside the streamed token estimate so
|
|
62
|
+
// the pill shows both; no tools run *during* a model call, so the count is
|
|
63
|
+
// whatever has accumulated from prior iterations.
|
|
64
|
+
onProgressUpdate
|
|
65
|
+
? (estCallTokens) => {
|
|
66
|
+
progressHighWater = Math.max(progressHighWater, baseOutputTokens + estCallTokens);
|
|
67
|
+
onProgressUpdate(progressHighWater, state.toolCalls);
|
|
68
|
+
}
|
|
69
|
+
: undefined);
|
|
70
|
+
state.inputTokens += result.inputTokens;
|
|
71
|
+
state.outputTokens += result.outputTokens;
|
|
72
|
+
state.costCents += result.costCents;
|
|
73
|
+
state.lastPromptTokens = result.inputTokens;
|
|
74
|
+
state.lastOutputTokens = result.outputTokens;
|
|
75
|
+
state.hasFreshTokenCount = true;
|
|
76
|
+
const assistantMessage = result.message;
|
|
77
|
+
state.messages.push(assistantMessage);
|
|
78
|
+
if (assistantMessage.content && assistantMessage.tool_calls?.length) {
|
|
79
|
+
onThinking?.(assistantMessage.content);
|
|
80
|
+
}
|
|
81
|
+
// Stream every assistant text message (with or without tool calls, including
|
|
82
|
+
// the final tool-less reply) so a caller can surface it the moment it lands.
|
|
83
|
+
if (typeof assistantMessage.content === "string") {
|
|
84
|
+
const trimmed = assistantMessage.content.trim();
|
|
85
|
+
if (trimmed)
|
|
86
|
+
onAssistantMessage?.(trimmed);
|
|
87
|
+
}
|
|
88
|
+
// Force-flush progress so each iteration bumps the heartbeat at least once.
|
|
89
|
+
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?.()) {
|
|
93
|
+
onProgressUpdate?.(state.outputTokens, state.toolCalls);
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
// Count this iteration's tool batch (a single iteration can request several
|
|
97
|
+
// tools at once) BEFORE the single progress emit, so the new tool count rides
|
|
98
|
+
// the SAME frame as the iteration's real token total. A separate
|
|
99
|
+
// post-increment emit would be coalesced by a throttled transport and lag
|
|
100
|
+
// the count behind the tokens it belongs with.
|
|
101
|
+
const toolCalls = assistantMessage.tool_calls;
|
|
102
|
+
if (toolCalls && toolCalls.length > 0)
|
|
103
|
+
state.toolCalls += toolCalls.length;
|
|
104
|
+
onProgressUpdate?.(state.outputTokens, state.toolCalls);
|
|
105
|
+
if (!toolCalls || toolCalls.length === 0) {
|
|
106
|
+
// The model stopped calling tools — normally the turn is done. Give a
|
|
107
|
+
// caller a chance to push it forward instead: if `onTurnWouldEnd` returns
|
|
108
|
+
// text, inject it as a synthetic user message and keep looping. The hook
|
|
109
|
+
// is self-bounding and `maxIterations` is the hard ceiling, so this cannot
|
|
110
|
+
// spin forever.
|
|
111
|
+
const nudge = await onTurnWouldEnd?.(assistantMessage, state.toolCalls);
|
|
112
|
+
if (nudge && nudge.trim()) {
|
|
113
|
+
state.messages.push({ role: "user", content: nudge });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
onStatus?.("executing_tools");
|
|
119
|
+
// Two-phase batch: run activation calls (`tool-discovery__load_plugin` and
|
|
120
|
+
// `skills__load_skill`) SERIALLY and activate each immediately (so a
|
|
121
|
+
// dependent tool from the same batch sees the plugin active / the skill's
|
|
122
|
+
// owner plugin loaded), then run the rest concurrently, pooled per tool
|
|
123
|
+
// name. Outcomes are collected by original index and applied in emission
|
|
124
|
+
// order.
|
|
125
|
+
const outcomes = new Array(toolCalls.length);
|
|
126
|
+
const deferredIndices = [];
|
|
127
|
+
const deferredCalls = [];
|
|
128
|
+
for (let i = 0; i < toolCalls.length; i++) {
|
|
129
|
+
const tc = toolCalls[i];
|
|
130
|
+
if (runsSerially?.(tc)) {
|
|
131
|
+
// Mirror the concurrent batch's graceful error synthesis so a failing
|
|
132
|
+
// activation call (transient network/DB/timeout) doesn't crash the
|
|
133
|
+
// run — but let a fatal error propagate for an immediate abort.
|
|
134
|
+
try {
|
|
135
|
+
const outcome = await runToolCall(tc);
|
|
136
|
+
outcomes[i] = outcome;
|
|
137
|
+
if (outcome.loadedPluginName) {
|
|
138
|
+
activatePlugins([outcome.loadedPluginName]);
|
|
139
|
+
}
|
|
140
|
+
if (outcome.loadedSkillRef) {
|
|
141
|
+
// Auto-load the module's owner plugin first so its tools are active
|
|
142
|
+
// by the time the agent follows the freshly-injected instructions.
|
|
143
|
+
if (outcome.autoLoadedPlugins?.length) {
|
|
144
|
+
activatePlugins(outcome.autoLoadedPlugins);
|
|
145
|
+
}
|
|
146
|
+
await activateSkills([outcome.loadedSkillRef]);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
if (isFatalToolError?.(err))
|
|
151
|
+
throw err;
|
|
152
|
+
reportRejection(tc.id, err);
|
|
153
|
+
outcomes[i] = {
|
|
154
|
+
toolMessage: {
|
|
155
|
+
role: "tool",
|
|
156
|
+
tool_call_id: tc.id,
|
|
157
|
+
content: JSON.stringify({
|
|
158
|
+
success: false,
|
|
159
|
+
error: err instanceof Error ? err.message : String(err),
|
|
160
|
+
}),
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
deferredIndices.push(i);
|
|
167
|
+
deferredCalls.push(tc);
|
|
168
|
+
}
|
|
169
|
+
const settled = await runToolCallsPooledByTool(deferredCalls, runToolCall);
|
|
170
|
+
for (let j = 0; j < deferredCalls.length; j++) {
|
|
171
|
+
const origIndex = deferredIndices[j];
|
|
172
|
+
const call = deferredCalls[j];
|
|
173
|
+
const settledResult = settled[j];
|
|
174
|
+
if (settledResult.status === "fulfilled") {
|
|
175
|
+
outcomes[origIndex] = settledResult.value;
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
// Let a fatal error abort the run immediately rather than be
|
|
179
|
+
// synthesized into a tool error — symmetric with the serial path, and
|
|
180
|
+
// so cancellation propagates even if no `ensureNotCancelled` boundary
|
|
181
|
+
// observer is wired.
|
|
182
|
+
if (isFatalToolError?.(settledResult.reason))
|
|
183
|
+
throw settledResult.reason;
|
|
184
|
+
reportRejection(call.id, settledResult.reason);
|
|
185
|
+
outcomes[origIndex] = {
|
|
186
|
+
toolMessage: {
|
|
187
|
+
role: "tool",
|
|
188
|
+
tool_call_id: call.id,
|
|
189
|
+
content: JSON.stringify({
|
|
190
|
+
success: false,
|
|
191
|
+
error: settledResult.reason instanceof Error
|
|
192
|
+
? settledResult.reason.message
|
|
193
|
+
: String(settledResult.reason),
|
|
194
|
+
}),
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
let compactionRequested = false;
|
|
200
|
+
let suspendRequested = false;
|
|
201
|
+
for (const outcome of outcomes) {
|
|
202
|
+
// An `answer`-suspend WITHHOLDS its tool message — the result is the
|
|
203
|
+
// future answer, threaded back on resume. At most one may be open: the
|
|
204
|
+
// first becomes `state.suspended`; any additional suspend in the same
|
|
205
|
+
// batch is answered with a synthesized error so no second slot is left
|
|
206
|
+
// unpaired. A `wake`-suspend keeps its tool message (it re-enters through
|
|
207
|
+
// a fresh prompt, not a threaded answer).
|
|
208
|
+
if (outcome.suspend) {
|
|
209
|
+
suspendRequested = true;
|
|
210
|
+
if (outcome.suspend.resumeKind === "answer") {
|
|
211
|
+
if (!state.suspended) {
|
|
212
|
+
// Rebuild with the literal "answer" kind so the assignment matches
|
|
213
|
+
// `ToolLoopState.suspended` (always answer — see its type).
|
|
214
|
+
state.suspended = { ...outcome.suspend, resumeKind: "answer" };
|
|
215
|
+
continue; // withhold this call's tool message
|
|
216
|
+
}
|
|
217
|
+
state.messages.push({
|
|
218
|
+
role: "tool",
|
|
219
|
+
tool_call_id: outcome.suspend.toolCallId,
|
|
220
|
+
content: JSON.stringify({
|
|
221
|
+
success: false,
|
|
222
|
+
error: "You already have one question waiting for an answer, so this " +
|
|
223
|
+
"one was not asked. Wait for the pending answer and then ask " +
|
|
224
|
+
"this, or finish the turn with what you have.",
|
|
225
|
+
}),
|
|
226
|
+
});
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
// `wake` (e.g. sleep_until): fall through and push the tool message, then
|
|
230
|
+
// end the run — it re-enters via a prompt, not a threaded answer.
|
|
231
|
+
}
|
|
232
|
+
state.messages.push(outcome.toolMessage);
|
|
233
|
+
if (outcome.requestCompaction)
|
|
234
|
+
compactionRequested = true;
|
|
235
|
+
}
|
|
236
|
+
// A tool asked to end the run (it scheduled its own resume, or recorded an
|
|
237
|
+
// open call awaiting an answer). The tool results are already in
|
|
238
|
+
// state.messages above; stop now so the run doesn't keep going. Mark it as
|
|
239
|
+
// an intentional pause so a caller doesn't read the trailing tool-calling
|
|
240
|
+
// turn as an iteration-limit cutoff. Honor an explicit
|
|
241
|
+
// compaction requested in the same batch so the saved transcript is
|
|
242
|
+
// compacted — UNLESS a suspend is pending: compacting would rewrite the open
|
|
243
|
+
// suspended call, and that pair must survive verbatim to be resumable.
|
|
244
|
+
if (suspendRequested) {
|
|
245
|
+
if (compactionRequested && applyCompaction && !state.suspended) {
|
|
246
|
+
const compacted = await applyCompaction("manual", state.messages);
|
|
247
|
+
applyCompactionResult(compacted);
|
|
248
|
+
await compacted.persist();
|
|
249
|
+
}
|
|
250
|
+
state.endedTurnViaTool = true;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
await ensureNotCancelled?.();
|
|
254
|
+
// Explicit (tool-requested) compaction, flushed at the batch boundary so
|
|
255
|
+
// the summary sees the full batch of tool responses. Account for the
|
|
256
|
+
// compaction usage BEFORE persisting so a persist failure can't drop the
|
|
257
|
+
// tokens it already consumed.
|
|
258
|
+
if (compactionRequested && applyCompaction) {
|
|
259
|
+
const compacted = await applyCompaction("manual", state.messages);
|
|
260
|
+
applyCompactionResult(compacted);
|
|
261
|
+
await compacted.persist();
|
|
262
|
+
}
|
|
263
|
+
// Drain human interrupts queued while the agent was working.
|
|
264
|
+
for (const interrupt of drainInterrupts?.() ?? []) {
|
|
265
|
+
// Strip control chars/newlines from the id so it can't break out of the
|
|
266
|
+
// `[Interrupt from user …]` header line and inject transcript structure.
|
|
267
|
+
// (Internal-only sources today, but a future external caller could surface
|
|
268
|
+
// user-supplied ids.)
|
|
269
|
+
const safeUserId = interrupt.userId.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, "");
|
|
270
|
+
state.messages.push({
|
|
271
|
+
role: "user",
|
|
272
|
+
content: `[Interrupt from user ${safeUserId}]\n${interrupt.content}`,
|
|
273
|
+
});
|
|
274
|
+
await onInterruptReceived?.(interrupt);
|
|
275
|
+
}
|
|
276
|
+
// Auto-compaction using the provider's real token count. Skip when there's
|
|
277
|
+
// no fresh count (before the first call, or right after a compaction reset
|
|
278
|
+
// it to 0) so we don't immediately re-trigger.
|
|
279
|
+
const currentTokens = state.lastPromptTokens + state.lastOutputTokens;
|
|
280
|
+
if (state.lastPromptTokens > 0 &&
|
|
281
|
+
needsCompaction?.(currentTokens) &&
|
|
282
|
+
applyCompaction) {
|
|
283
|
+
const compacted = await applyCompaction("auto", state.messages);
|
|
284
|
+
applyCompactionResult(compacted);
|
|
285
|
+
await compacted.persist();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juno-ai/bind",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Agent harness: deterministic LLM provider routing, run mechanics, transcript healing, tool-schema sanitization, and the plugin/tool vocabulary
|
|
3
|
+
"version": "5.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).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./index.js",
|
|
@@ -15,10 +15,18 @@
|
|
|
15
15
|
"types": "./routing/index.d.ts",
|
|
16
16
|
"import": "./routing/index.js"
|
|
17
17
|
},
|
|
18
|
+
"./completion": {
|
|
19
|
+
"types": "./completion/index.d.ts",
|
|
20
|
+
"import": "./completion/index.js"
|
|
21
|
+
},
|
|
18
22
|
"./contracts": {
|
|
19
23
|
"types": "./contracts/index.d.ts",
|
|
20
24
|
"import": "./contracts/index.js"
|
|
21
25
|
},
|
|
26
|
+
"./loop": {
|
|
27
|
+
"types": "./loop/index.d.ts",
|
|
28
|
+
"import": "./loop/index.js"
|
|
29
|
+
},
|
|
22
30
|
"./run": {
|
|
23
31
|
"types": "./run/index.d.ts",
|
|
24
32
|
"import": "./run/index.js"
|