@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/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 deterministic LLM provider-routing core, the turn
9
- * vocabulary, the run mechanics (deadline, coalesced heartbeat, failure
10
- * classification, tool-batch pooling), transcript validation/healing, provider
11
- * tool-schema sanitization, and the plugin/tool vocabulary with its registry
12
- * and progressive-disclosure activation (`src/plugins/`, generic over the
13
- * host's invocation context). The turn kernel arrives in a later phase; see
14
- * the README for what is deliberately not here yet.
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";
@@ -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": "3.0.0",
4
- "description": "Agent harness: deterministic LLM provider routing, run mechanics, transcript healing, tool-schema sanitization, and the plugin/tool vocabulary (turn kernel arrives in a later phase). MIT-licensed; published to npm from the canonical repo via scripts/publish-bind.ts (docs/bind.md).",
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"