@juno-ai/bind 14.0.0 → 15.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 CHANGED
@@ -35,6 +35,15 @@ constraints that will fail CI if you break them.
35
35
 
36
36
  **Added**
37
37
 
38
+ - **Three loop ports for a host whose transcript is durable** —
39
+ `ToolLoopTurn.acceptMessage`, `ToolLoopParams.beforeToolMessageAccepted` and
40
+ `ToolLoopParams.onStepSettled` (plus `StepSettlement`,
41
+ `DiscardedTurnWithToolCallsError` and `InputBlockedBySuspendError`).
42
+ Together they let a host record each accepted message as the loop accepts
43
+ it, in the order it accepts it, and hand back messages it has committed.
44
+ All three are optional and unwired behaviour is unchanged. See "How to
45
+ commit a transcript as the loop builds it".
46
+
38
47
  - **`@juno-ai/bind/skills`** — progressive disclosure for *instructions*, the
39
48
  mirror of `@juno-ai/bind/plugins`. `createSkillRegistry` for what your source
40
49
  ships, `partitionSkillCatalog` for the Tier-1 catalog and its token budget,
@@ -831,6 +840,84 @@ represent faithfully — a `Map`, a `Set`, a class instance, `NaN`, a cycle —
831
840
  because a silent collision here reads as "same call" and skips a write that
832
841
  never happened.
833
842
 
843
+ ### How to commit a transcript as the loop builds it
844
+
845
+ `state.messages` is an in-memory array. If your host persists each message as it
846
+ lands — a Durable Object writing transitions, an event-sourced run that replays
847
+ after eviction — you need three things the array alone cannot give you: the
848
+ exact message at the moment it is accepted, its position among the results the
849
+ batch actually produced, and a point where you can hand back input your users
850
+ committed while the agent was working.
851
+
852
+ ```ts
853
+ await runToolLoop({
854
+ // …
855
+ callModel: async (messages, tools) => {
856
+ const completion = await transport(messages, tools);
857
+ const empty = !completion.content.trim() && completion.toolCalls.length === 0;
858
+ if (empty) await settleReservation(turnId, "failed");
859
+ else await commit({ kind: "assistant_accepted", turnId, message: completion.message });
860
+ return { ...completion, acceptMessage: !empty };
861
+ },
862
+ beforeToolMessageAccepted: async (message, resultOrdinal) => {
863
+ // Throws → the message is NOT appended and the run fails.
864
+ await commit({ kind: "tool_result_accepted", message, resultOrdinal });
865
+ },
866
+ onStepSettled: async ({ wouldEnd }) => {
867
+ const pending = await claimCommittedInput({ wouldEnd });
868
+ return { messages: pending.messages, stop: pending.runWasCancelled };
869
+ },
870
+ });
871
+ ```
872
+
873
+ **`acceptMessage: false` keeps a turn out of the transcript without hiding what
874
+ it cost.** The case is a tolerated empty completion: the provider returned
875
+ nothing, your host has already settled the slot it reserved, and appending an
876
+ empty assistant message would persist a turn that said nothing and re-send it on
877
+ every later call. Tokens and cost are still folded into `stats` and `state`. The
878
+ loop **refuses** the discard for a turn that requested tools — those results are
879
+ about to be appended and would have no request to pair with — and reports the
880
+ misuse through `onToolCallRejected` as a `DiscardedTurnWithToolCallsError`.
881
+
882
+ **`resultOrdinal` is dense over accepted results.** It is not the call's index
883
+ in `tool_calls`: an `answer`-suspend withholds its result, so the calls after it
884
+ close the gap, and the answer takes the ordinal it is accepted at when it
885
+ arrives. Reconstructing that order from `state.messages` afterwards is the one
886
+ thing that goes wrong for exactly the batch that suspended.
887
+
888
+ **`beforeToolMessageAccepted` covers results this loop produced, not messages
889
+ you hand back.** A `tool` message you return from `onStepSettled` skips the
890
+ port and takes no ordinal — you committed it before handing it over, so
891
+ reporting it back would ask you to record the same thing twice.
892
+
893
+ **A settled step is where input can join.** `onStepSettled` fires **at most
894
+ once per iteration**, and `wouldEnd` says which of the two shapes that
895
+ iteration took: `true` when the model asked for no tools (the run is about to
896
+ end), `false` when the batch's results are all in the transcript. Anywhere
897
+ else, an appended message lands between an assistant message and the results
898
+ it is waiting on, which is a transcript providers reject. Returned messages
899
+ are appended in order and the run continues; `stop` ends it as `aborted` with
900
+ them already appended, and a throw propagates out of `runToolLoop`
901
+ unconverted.
902
+
903
+ Two behaviours worth knowing before you wire it:
904
+
905
+ - At `wouldEnd: true`, `onStepSettled` runs **before** `onTurnWouldEnd`. Real
906
+ input outranks a nudge — a turn with more to answer was never stalled.
907
+ - A returned `role:"tool"` message carrying an **open suspend's `tool_call_id`
908
+ clears the suspension**, so a host that already has the answer keeps going
909
+ instead of pausing for a reply it has been handed. A `wake`-suspend from
910
+ another call in the same batch still ends the run: the answer says nothing
911
+ about it.
912
+ - **While a suspend is open, only `tool` messages are accepted.** A `user`
913
+ message delivered ahead of the answer is refused and reported as an
914
+ `InputBlockedBySuspendError`: appending it is how a resumed run ends up
915
+ sending `assistant(tool_calls) → user → tool`, which providers reject. Put
916
+ the answering result first in the array and the rest is accepted after it.
917
+ - **`shouldStop` outranks delivered input.** The messages are kept — your host
918
+ committed them — and the run ends as `aborted` rather than making another
919
+ model call.
920
+
834
921
  ### How to stream tokens to a user without breaking fallback
835
922
 
836
923
  Three granularities reach you, and the third is the one with a retry problem.
@@ -1464,6 +1551,12 @@ what happens to the tool message:
1464
1551
  `request` is an opaque render/route payload, validated by the consumer and never
1465
1552
  inspected by the loop.
1466
1553
 
1554
+ An open `"answer"` suspend can also be resolved **within the same step**: a
1555
+ `role:"tool"` message returned from `onStepSettled` that carries the open call's
1556
+ `tool_call_id` clears the suspension and the run continues, for a host that
1557
+ already holds the reply. A `"wake"` suspend from another call in the same batch
1558
+ still ends the run — see "How to commit a transcript as the loop builds it".
1559
+
1467
1560
  ---
1468
1561
 
1469
1562
  ## Usage scenarios
package/loop/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export type { StopReason, RunStats } from "../contracts/turn.js";
2
- export { runToolLoop, MissingActivationPortError, type ToolLoopParams, type ToolLoopResult, type ToolLoopState, type ToolLoopTurn, type ToolCallOutcome, type CompactionApplied, type RunStatus, } from "./tool-loop.js";
2
+ export { runToolLoop, MissingActivationPortError, DiscardedTurnWithToolCallsError, InputBlockedBySuspendError, type ToolLoopParams, type ToolLoopResult, type ToolLoopState, type ToolLoopTurn, type ToolCallOutcome, type StepSettlement, type CompactionApplied, type RunStatus, } from "./tool-loop.js";
package/loop/index.js CHANGED
@@ -1 +1 @@
1
- export { runToolLoop, MissingActivationPortError, } from "./tool-loop.js";
1
+ export { runToolLoop, MissingActivationPortError, DiscardedTurnWithToolCallsError, InputBlockedBySuspendError, } from "./tool-loop.js";
@@ -40,6 +40,30 @@ export interface ToolLoopTurn {
40
40
  * simply does not count this turn, rather than counting it as a zero.
41
41
  */
42
42
  cachedInputTokens?: number | null;
43
+ /**
44
+ * Set `false` to keep this completion OUT of the transcript while still
45
+ * accounting for what it cost. The turn happened — it is folded into
46
+ * `stats` and `state` either way — but `state.messages` never grows an
47
+ * assistant entry for it.
48
+ *
49
+ * The case it exists for is a **tolerated empty completion**: a provider
50
+ * answered with no text and no tool calls, the host decided that is a
51
+ * nothing rather than a failure, and the host had already reserved a
52
+ * transcript slot for the message it never got. Appending the empty
53
+ * assistant message would persist a turn saying nothing and re-send it on
54
+ * every later call; dropping it leaves the transcript exactly as the model
55
+ * found it, so the next turn is a clean retry of the same question.
56
+ *
57
+ * **Only a turn with no tool calls may be dropped.** Withholding a
58
+ * tool-calling assistant message orphans every `tool` result the batch is
59
+ * about to append, which is an invalid transcript the next model call
60
+ * rejects. The loop refuses that rather than building it — it reports the
61
+ * misuse through `onToolCallRejected` (one report per orphaned call, the
62
+ * same channel a missing activation port uses) and keeps the message.
63
+ *
64
+ * Defaults to accepting: omit it, or pass `true`, and nothing changes.
65
+ */
66
+ acceptMessage?: boolean;
43
67
  }
44
68
  /**
45
69
  * Outcome of running one tool call inside an assistant `tool_calls` batch.
@@ -155,6 +179,44 @@ export declare class MissingActivationPortError extends Error {
155
179
  readonly name = "MissingActivationPortError";
156
180
  constructor(port: "activatePlugins" | "activateSkills");
157
181
  }
182
+ /**
183
+ * A turn returned `acceptMessage: false` while also requesting tools.
184
+ *
185
+ * Reported through `onToolCallRejected` rather than thrown, and the discard is
186
+ * refused rather than honoured: the tool calls are about to run, and a
187
+ * transcript carrying their results without the assistant message that asked
188
+ * for them is one the next provider call rejects outright. Keeping the message
189
+ * costs an assistant entry the host did not want; dropping it costs the run.
190
+ *
191
+ * Match on `error.name === "DiscardedTurnWithToolCallsError"` rather than
192
+ * `instanceof` — see {@link MissingActivationPortError} for why.
193
+ */
194
+ export declare class DiscardedTurnWithToolCallsError extends Error {
195
+ readonly toolCallCount: number;
196
+ readonly name = "DiscardedTurnWithToolCallsError";
197
+ constructor(toolCallCount: number);
198
+ }
199
+ /**
200
+ * A settled step delivered a message that cannot be appended yet: a call is
201
+ * still awaiting its answer, and the provider rule is that nothing comes
202
+ * between an assistant message and the `tool` results it is waiting on.
203
+ *
204
+ * Appending it anyway builds `assistant(tool_calls) → user → tool` the moment
205
+ * the answer is threaded back on resume, which providers reject outright — so
206
+ * the message is refused and reported (through `onToolCallRejected`, against
207
+ * the open call) rather than silently corrupting the transcript. Deliver the
208
+ * answering `tool` result first, in the same settlement, and everything after
209
+ * it is accepted normally.
210
+ *
211
+ * Match on `error.name === "InputBlockedBySuspendError"` rather than
212
+ * `instanceof` — see {@link MissingActivationPortError} for why.
213
+ */
214
+ export declare class InputBlockedBySuspendError extends Error {
215
+ readonly openToolCallId: string;
216
+ readonly refusedRole: string;
217
+ readonly name = "InputBlockedBySuspendError";
218
+ constructor(openToolCallId: string, refusedRole: string);
219
+ }
158
220
  /**
159
221
  * What a completed loop reports back.
160
222
  *
@@ -198,6 +260,17 @@ export interface ToolLoopResult {
198
260
  */
199
261
  readonly stats: RunStats;
200
262
  }
263
+ /**
264
+ * What a host hands back from {@link ToolLoopParams.onStepSettled}: the
265
+ * messages to append at this settled step, and whether the run should end
266
+ * after appending them.
267
+ */
268
+ export interface StepSettlement {
269
+ /** Appended to the transcript in this order. Empty means "nothing queued". */
270
+ messages: OpenAI.ChatCompletionMessageParam[];
271
+ /** End the run as `aborted` once the messages above are appended. */
272
+ stop: boolean;
273
+ }
201
274
  export interface ToolLoopParams {
202
275
  state: ToolLoopState;
203
276
  /**
@@ -217,6 +290,90 @@ export interface ToolLoopParams {
217
290
  buildTools: () => OpenAI.ChatCompletionTool[];
218
291
  /** Execute one tool call → the `tool` message + control signals. */
219
292
  runToolCall: (toolCall: OpenAI.ChatCompletionMessageToolCall) => Promise<ToolCallOutcome>;
293
+ /**
294
+ * Record the exact `tool` message the loop is about to append, immediately
295
+ * before it appends it. For a host that persists the transcript as it is
296
+ * built, this is the only point where "what was accepted" and "what was
297
+ * recorded" cannot drift: a batch's outcomes are produced concurrently and
298
+ * an `answer`-suspend withholds one of them, so a host reconstructing the
299
+ * accepted set afterwards has to re-derive a decision the loop already made.
300
+ *
301
+ * `resultOrdinal` counts **accepted results in this batch, densely from 0**
302
+ * — not the call's index in `tool_calls`. A withheld suspend leaves no
303
+ * ordinal behind, so the results that follow it close the gap, and the
304
+ * answer that arrives later takes the ordinal it is accepted at rather than
305
+ * the one its call was issued at. Ordering within the batch is otherwise
306
+ * the model's original call order.
307
+ *
308
+ * **A throw aborts acceptance**: the message is not appended and the error
309
+ * propagates out of `runToolLoop`, unconverted. That is deliberate — this
310
+ * port exists for hosts that must record a result durably before the model
311
+ * may see it, and a host that cannot record one must not be handed a
312
+ * transcript claiming it did. Wire `isFatalToolError` the same way for the
313
+ * dispatch half of that rule.
314
+ *
315
+ * **Scope: results this loop produced.** A `tool` message the host itself
316
+ * returns from `onStepSettled` does not fire this port and takes no ordinal
317
+ * — the host committed that message before handing it over, so reporting it
318
+ * back would ask for a second record of the same thing. `resultOrdinal` is
319
+ * therefore a position within the batch, and a host that journals both
320
+ * sources keys them by where they came from rather than by one counter.
321
+ */
322
+ beforeToolMessageAccepted?: (message: OpenAI.ChatCompletionToolMessageParam, resultOrdinal: number) => Promise<void> | void;
323
+ /**
324
+ * Deliver host-committed messages at a **settled step** — a point where the
325
+ * model turn and its whole tool batch are complete and the transcript is
326
+ * coherent, so appending a `user` (or a withheld `tool`) message cannot
327
+ * split an assistant message from the results it is waiting on.
328
+ *
329
+ * Called **at most once per iteration** — the two points below are the two
330
+ * shapes an iteration can take, not two calls in one — and `wouldEnd` says
331
+ * which one this is:
332
+ *
333
+ * - **`wouldEnd: true`** — the model asked for no tools and the loop is
334
+ * about to finish. Returning messages CONTINUES the run with them
335
+ * appended, ahead of `onTurnWouldEnd` (a host that has real input queued
336
+ * delivers it rather than nudging a turn that was not actually stalled).
337
+ * - **`wouldEnd: false`** — the batch ran and its results are in the
338
+ * transcript. Returning messages appends them before the next model call.
339
+ *
340
+ * Return `stop: true` to end the run as `aborted`, with whatever messages
341
+ * came back already appended. Returning `{ messages: [], stop: false }` —
342
+ * or leaving the port unwired — changes nothing.
343
+ *
344
+ * **A delivered `tool` message answers an open suspend.** If the run is
345
+ * waiting on an `answer`-suspend and a returned message carries that call's
346
+ * `tool_call_id`, the loop clears the suspension and keeps going rather than
347
+ * pausing for a reply it has just been handed. It still ends the run if some
348
+ * *other* call in the same batch asked for a `wake`-suspend, which is a
349
+ * separate request that a delivered answer says nothing about.
350
+ *
351
+ * **While a suspend is open, only `tool` messages are accepted.** Nothing
352
+ * may come between an assistant message and the results it is waiting on, so
353
+ * a `user` message delivered ahead of the answer is refused and reported as
354
+ * an {@link InputBlockedBySuspendError} — appending it is how a resumed run
355
+ * ends up sending `assistant(tool_calls) → user → tool`, which providers
356
+ * reject. Return the answering result first and the rest is accepted after
357
+ * it, in order.
358
+ *
359
+ * **A host that asked to stop still stops.** Delivered input says the turn
360
+ * is not over; `shouldStop` says the run is, and the run wins — the messages
361
+ * are kept and the loop ends as `aborted` rather than paying for another
362
+ * model call.
363
+ *
364
+ * Messages are otherwise appended in the order returned, and the loop does
365
+ * not validate them further: this is the host's transcript, committed on the
366
+ * host's side, arriving at the one place the loop can take it.
367
+ *
368
+ * **A throw propagates out of `runToolLoop` unconverted**, like
369
+ * `beforeToolMessageAccepted` and for the same reason: a host that cannot
370
+ * say what it has committed cannot be told the run went on without it.
371
+ */
372
+ onStepSettled?: (step: {
373
+ assistantMessage: OpenAI.ChatCompletionMessage;
374
+ cumulativeToolCalls: number;
375
+ wouldEnd: boolean;
376
+ }) => StepSettlement | Promise<StepSettlement>;
220
377
  /**
221
378
  * Activate newly loaded plugins (mutate the catalog/active set). Optional:
222
379
  * a host with a fixed tool surface has nothing to activate, and requiring an
package/loop/tool-loop.js CHANGED
@@ -29,6 +29,56 @@ export class MissingActivationPortError extends Error {
29
29
  this.port = port;
30
30
  }
31
31
  }
32
+ /**
33
+ * A turn returned `acceptMessage: false` while also requesting tools.
34
+ *
35
+ * Reported through `onToolCallRejected` rather than thrown, and the discard is
36
+ * refused rather than honoured: the tool calls are about to run, and a
37
+ * transcript carrying their results without the assistant message that asked
38
+ * for them is one the next provider call rejects outright. Keeping the message
39
+ * costs an assistant entry the host did not want; dropping it costs the run.
40
+ *
41
+ * Match on `error.name === "DiscardedTurnWithToolCallsError"` rather than
42
+ * `instanceof` — see {@link MissingActivationPortError} for why.
43
+ */
44
+ export class DiscardedTurnWithToolCallsError extends Error {
45
+ toolCallCount;
46
+ name = "DiscardedTurnWithToolCallsError";
47
+ constructor(toolCallCount) {
48
+ super(`A turn asked the loop to drop its assistant message, but the message ` +
49
+ `requests ${toolCallCount} tool call(s) whose results would be left ` +
50
+ `unpaired. The message was kept. Only a tool-less turn may be dropped.`);
51
+ this.toolCallCount = toolCallCount;
52
+ }
53
+ }
54
+ /**
55
+ * A settled step delivered a message that cannot be appended yet: a call is
56
+ * still awaiting its answer, and the provider rule is that nothing comes
57
+ * between an assistant message and the `tool` results it is waiting on.
58
+ *
59
+ * Appending it anyway builds `assistant(tool_calls) → user → tool` the moment
60
+ * the answer is threaded back on resume, which providers reject outright — so
61
+ * the message is refused and reported (through `onToolCallRejected`, against
62
+ * the open call) rather than silently corrupting the transcript. Deliver the
63
+ * answering `tool` result first, in the same settlement, and everything after
64
+ * it is accepted normally.
65
+ *
66
+ * Match on `error.name === "InputBlockedBySuspendError"` rather than
67
+ * `instanceof` — see {@link MissingActivationPortError} for why.
68
+ */
69
+ export class InputBlockedBySuspendError extends Error {
70
+ openToolCallId;
71
+ refusedRole;
72
+ name = "InputBlockedBySuspendError";
73
+ constructor(openToolCallId, refusedRole) {
74
+ super(`A settled step delivered a \`${refusedRole}\` message while tool call ` +
75
+ `${openToolCallId} is still awaiting its answer. It was refused: the ` +
76
+ `answer has to reach the transcript first, or the resumed run sends ` +
77
+ `a \`tool\` result the message has already split from its call.`);
78
+ this.openToolCallId = openToolCallId;
79
+ this.refusedRole = refusedRole;
80
+ }
81
+ }
32
82
  /**
33
83
  * The key a tool call is measured under in {@link RunStats.toolTimeBreakdownMs}.
34
84
  *
@@ -67,7 +117,7 @@ function toolCallStatsKey(tc) {
67
117
  * run's conclusion, which only exists once the loop is over.
68
118
  */
69
119
  export async function runToolLoop(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;
120
+ 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, beforeToolMessageAccepted, onStepSettled, } = params;
71
121
  let stats = emptyRunStats();
72
122
  // The default is the outcome of falling out of the `for` — every other exit
73
123
  // assigns before it breaks. Seeding it here rather than at each `break` means
@@ -116,6 +166,55 @@ export async function runToolLoop(params) {
116
166
  // Nothing useful to do — the reporting channel is the thing that broke.
117
167
  }
118
168
  };
169
+ // Ask the host what it has committed for this settled step. Unwired, the
170
+ // answer is "nothing, keep going" — the one shape every call site below
171
+ // reads, so neither of them branches on whether the port exists.
172
+ const settleStep = async (assistantMessage, wouldEnd) => {
173
+ const settlement = await onStepSettled?.({
174
+ assistantMessage,
175
+ cumulativeToolCalls: state.toolCalls,
176
+ wouldEnd,
177
+ });
178
+ // Read field by field rather than trusting the object whole: the types say
179
+ // both are present, and a JavaScript host (this package is published, and
180
+ // was reimplemented once already as a patch) that returns `{ stop: true }`
181
+ // would otherwise crash the run inside the loop rather than stop it.
182
+ return {
183
+ messages: settlement?.messages ?? [],
184
+ stop: settlement?.stop === true,
185
+ };
186
+ };
187
+ // Append a settlement's messages, honouring the one ordering rule an open
188
+ // `answer`-suspend imposes: until that call has its result, nothing else may
189
+ // come between it and the assistant message that asked for it. A delivered
190
+ // result for the open call clears the pause (the answer arrived in the same
191
+ // breath as the question); anything that is not a `tool` message is refused
192
+ // while the pause is open, because appending it is how a resumed run ends up
193
+ // sending `assistant(tool_calls) → user → tool`.
194
+ //
195
+ // Shared by both settle sites rather than living in the batch arm alone: a
196
+ // caller may seed `state.suspended` when it resumes a run, and the rule is
197
+ // about the transcript, not about which branch produced it.
198
+ const applySettledMessages = (messages) => {
199
+ let appended = 0;
200
+ let answeredSuspend = false;
201
+ for (const message of messages) {
202
+ const openSuspension = state.suspended;
203
+ if (openSuspension) {
204
+ if (message.role !== "tool") {
205
+ reportRejection(openSuspension.toolCallId, new InputBlockedBySuspendError(openSuspension.toolCallId, message.role));
206
+ continue;
207
+ }
208
+ if (message.tool_call_id === openSuspension.toolCallId) {
209
+ state.suspended = undefined;
210
+ answeredSuspend = true;
211
+ }
212
+ }
213
+ state.messages.push(message);
214
+ appended += 1;
215
+ }
216
+ return { appended, answeredSuspend };
217
+ };
119
218
  // Swap in a freshly-compacted transcript and reset the live counts. The
120
219
  // provider count no longer reflects the compacted array, so drop it — the
121
220
  // auto-compaction check skips while it's 0 (preventing an immediate
@@ -209,7 +308,20 @@ export async function runToolLoop(params) {
209
308
  state.lastOutputTokens = result.outputTokens;
210
309
  state.hasFreshTokenCount = true;
211
310
  const assistantMessage = result.message;
212
- state.messages.push(assistantMessage);
311
+ // `acceptMessage: false` keeps a tolerated empty completion out of the
312
+ // transcript — the turn is still accounted for above. Refused for a
313
+ // tool-calling turn: the batch below appends results, and results whose
314
+ // request is missing are a transcript the next provider call rejects.
315
+ const requestedToolCalls = assistantMessage.tool_calls ?? [];
316
+ if (result.acceptMessage === false && requestedToolCalls.length > 0) {
317
+ const misuse = new DiscardedTurnWithToolCallsError(requestedToolCalls.length);
318
+ for (const tc of requestedToolCalls) {
319
+ reportRejection(tc.id, misuse);
320
+ }
321
+ }
322
+ if (result.acceptMessage !== false || requestedToolCalls.length > 0) {
323
+ state.messages.push(assistantMessage);
324
+ }
213
325
  if (assistantMessage.content && assistantMessage.tool_calls?.length) {
214
326
  onThinking?.(assistantMessage.content);
215
327
  }
@@ -244,6 +356,32 @@ export async function runToolLoop(params) {
244
356
  state.toolCalls += toolCalls.length;
245
357
  onProgressUpdate?.(state.outputTokens, state.toolCalls);
246
358
  if (!toolCalls || toolCalls.length === 0) {
359
+ // The step is settled: no batch to run, so the transcript is coherent
360
+ // right here. Ask the host for anything it has committed since the turn
361
+ // began — real input outranks a nudge, so this runs BEFORE
362
+ // `onTurnWouldEnd`. A host that delivers a message is saying the turn is
363
+ // not over, so the loop continues with it appended rather than asking
364
+ // whether a turn that has more to answer looked stalled.
365
+ const settlement = await settleStep(assistantMessage, true);
366
+ const settledInput = applySettledMessages(settlement.messages);
367
+ if (settlement.stop) {
368
+ stopReason = "aborted";
369
+ break;
370
+ }
371
+ if (settledInput.appended > 0) {
372
+ // Delivered input says the turn is not over; a host that asked to stop
373
+ // says the RUN is. The stop wins, exactly as it does over a nudge
374
+ // below — otherwise `shouldStop` ("stop after the current turn") pays
375
+ // for another model call and answers after it was cancelled. The
376
+ // messages stay: the host committed them, and dropping them here would
377
+ // leave its ledger and this transcript disagreeing.
378
+ if (stopRequested) {
379
+ onProgressUpdate?.(state.outputTokens, state.toolCalls);
380
+ stopReason = "aborted";
381
+ break;
382
+ }
383
+ continue;
384
+ }
247
385
  // The model stopped calling tools — normally the turn is done. Give a
248
386
  // caller a chance to push it forward instead: if `onTurnWouldEnd` returns
249
387
  // text, inject it as a synthetic user message and keep looping. The hook
@@ -398,6 +536,17 @@ export async function runToolLoop(params) {
398
536
  }
399
537
  let compactionRequested = false;
400
538
  let suspendRequested = false;
539
+ // Dense over ACCEPTED results, so a withheld suspend leaves no gap — see
540
+ // `beforeToolMessageAccepted`. Reset per batch: it is a position within
541
+ // this step, not a running total.
542
+ let acceptedResultOrdinal = 0;
543
+ const acceptToolMessage = async (message) => {
544
+ // A throw propagates: a host that could not record the result must not
545
+ // be handed a transcript that says the model already saw it.
546
+ await beforeToolMessageAccepted?.(message, acceptedResultOrdinal);
547
+ state.messages.push(message);
548
+ acceptedResultOrdinal += 1;
549
+ };
401
550
  for (const outcome of outcomes) {
402
551
  // An `answer`-suspend WITHHOLDS its tool message — the result is the
403
552
  // future answer, threaded back on resume. At most one may be open: the
@@ -412,6 +561,14 @@ export async function runToolLoop(params) {
412
561
  // Rebuild with the literal "answer" kind so the assignment matches
413
562
  // `ToolLoopState.suspended` (always answer — see its type).
414
563
  state.suspended = { ...outcome.suspend, resumeKind: "answer" };
564
+ // The message is withheld; the call's OTHER signals are not. A
565
+ // compaction asked for by the same outcome is skipped while the
566
+ // pause is open (the arm below checks `state.suspended`) and runs
567
+ // if a same-step answer clears it — whereas dropping the flag here
568
+ // loses it for good, which is only invisible while an answered
569
+ // suspend could not continue the run.
570
+ if (outcome.requestCompaction)
571
+ compactionRequested = true;
415
572
  continue; // withhold this call's tool message
416
573
  }
417
574
  // Through the shared encoder, and carrying a `kind`, like every
@@ -420,7 +577,7 @@ export async function runToolLoop(params) {
420
577
  // in one transcript — the exact thing `toolResultMessage` exists to
421
578
  // prevent. `conflict` because it is a concurrency loss: the call was
422
579
  // refused only because another question is already open.
423
- state.messages.push(toolResultMessage(outcome.suspend.toolCallId, {
580
+ await acceptToolMessage(toolResultMessage(outcome.suspend.toolCallId, {
424
581
  success: false,
425
582
  kind: "conflict",
426
583
  error: "This question was not asked — you already have one waiting " +
@@ -433,7 +590,7 @@ export async function runToolLoop(params) {
433
590
  // `wake` (e.g. sleep_until): fall through and push the tool message, then
434
591
  // end the run — it re-enters via a prompt, not a threaded answer.
435
592
  }
436
- state.messages.push(outcome.toolMessage);
593
+ await acceptToolMessage(outcome.toolMessage);
437
594
  if (outcome.requestCompaction)
438
595
  compactionRequested = true;
439
596
  }
@@ -469,6 +626,23 @@ export async function runToolLoop(params) {
469
626
  stopReason = "aborted";
470
627
  break;
471
628
  }
629
+ // The step is settled: every result the batch produced is in the
630
+ // transcript and nothing is half-applied, so this is where host-committed
631
+ // input can join without splitting a call from its result.
632
+ const settlement = await settleStep(assistantMessage, false);
633
+ const settledInput = applySettledMessages(settlement.messages);
634
+ // The answer arrived in the same breath as the question, so the run is no
635
+ // longer waiting on a person. A `wake`-suspend from another call in the
636
+ // same batch is a separate request the answer says nothing about, so it
637
+ // still ends the run — recomputed from the outcomes rather than left set,
638
+ // because `suspendRequested` is true for the answered call too.
639
+ if (settledInput.answeredSuspend) {
640
+ suspendRequested = outcomes.some((outcome) => outcome.suspend?.resumeKind === "wake");
641
+ }
642
+ if (settlement.stop) {
643
+ stopReason = "aborted";
644
+ break;
645
+ }
472
646
  // A tool asked to end the run (it scheduled its own resume, or recorded an
473
647
  // open call awaiting an answer). The tool results are already in
474
648
  // state.messages above; stop now so the run doesn't keep going. Mark it as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juno-ai/bind",
3
- "version": "14.0.0",
3
+ "version": "15.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, the plugin/tool vocabulary, and the skill vocabulary (`./skills`) for progressive knowledge disclosure. MIT-licensed; published to npm from the canonical repo via scripts/publish-bind.ts (docs/bind.md).",
5
5
  "license": "MIT",
6
6
  "type": "module",