import type OpenAI from "openai"; import { type LoopControl } from "./boundary.js"; import { type RunStats, type StopReason } from "../contracts/turn.js"; /** * The agent iteration engine: call the model, run the tools it asked for, * repeat until it stops asking. Everything that *happens* as a result — status * updates, heartbeats, activity rows, persistence, cancellation — is injected, * so the loop itself does no I/O and holds no host vocabulary. * * Three ports are worth understanding before wiring this up, because each * replaced something the loop previously hardcoded: * * - **`runsSerially`** decides which calls in a batch must run one at a time, * ahead of the rest. Not a performance knob: a call that changes what tools * exist has to take effect before a later call in the same batch tries to * use them. * - **`isFatalToolError`** decides which thrown errors abort the run instead * of becoming a tool error the model can read. Cancellation and "we failed * to record the result" belong here; a tool that simply failed does not. * - **`onToolCallRejected`** observes the errors that were synthesized rather * than thrown. Without it they are invisible — the model sees them, your * logs do not. * * Turn accounting is the flat usage a host's `callModel` returns * (`ToolLoopTurn`), not the richer `ModelTurnResult` in * `@juno-ai/bind/contracts` with its timings. The loop bridges them: it * measures each `callModel` with an injectable `now` and folds the pair * through `accumulateTurn`, so `runToolLoop` returns a full `RunStats` * without a host changing the shape it already returns. `ttftMs` is the one * field that cannot cross — only the transport sees the first byte. */ /** One model completion's message + the provider usage the loop accounts for. */ export interface ToolLoopTurn { message: OpenAI.ChatCompletionMessage; inputTokens: number; outputTokens: number; costCents: number; /** * Provider-reported cached input tokens, when the transport can report them. * Optional — omit it (or pass `null`) and the run's `cachedInputTokens` total * simply does not count this turn, rather than counting it as a zero. */ cachedInputTokens?: number | null; /** * Set `false` to keep this completion OUT of the transcript while still * accounting for what it cost. The turn happened — it is folded into * `stats` and `state` either way — but `state.messages` never grows an * assistant entry for it. * * The case it exists for is a **tolerated empty completion**: a provider * answered with no text and no tool calls, the host decided that is a * nothing rather than a failure, and the host had already reserved a * transcript slot for the message it never got. Appending the empty * assistant message would persist a turn saying nothing and re-send it on * every later call; dropping it leaves the transcript exactly as the model * found it, so the next turn is a clean retry of the same question. * * **Only a turn with no tool calls may be dropped.** Withholding a * tool-calling assistant message orphans every `tool` result the batch is * about to append, which is an invalid transcript the next model call * rejects. The loop refuses that rather than building it — it reports the * misuse through `onToolCallRejected` (one report per orphaned call, the * same channel a missing activation port uses) and keeps the message. * * Defaults to accepting: omit it, or pass `true`, and nothing changes. */ acceptMessage?: boolean; } /** * Outcome of running one tool call inside an assistant `tool_calls` batch. * * Every optional field is a **control signal**: the loop branches on it. They * are not host payload passing through — `loadedPluginName` grows the active * set, `requestCompaction` triggers a compaction at the batch boundary, and * `suspend` ends the run. A host's own per-call data belongs inside * `toolMessage`, which the loop only appends. */ export type ToolCallOutcome = { toolMessage: OpenAI.ChatCompletionToolMessageParam; /** * A plugin this call activated; the loop adds it to the active set at once — * but ONLY from the serial phase. A call that reaches the concurrent phase * has already missed its window (a later call in the same batch could * already be running), so this field is ignored there rather than applied * late. Return it from a call your `runsSerially` selects, or it is dropped. */ loadedPluginName?: string; /** An instruction module this call loaded, passed to `activateSkills`. Serial phase only, as above. */ loadedSkillRef?: string; /** Plugins the loaded module needs, activated before it. Serial phase only, as above. */ autoLoadedPlugins?: string[]; /** Compact the transcript at this batch's boundary. */ requestCompaction?: boolean; /** * End the run. `answer` records the open call and WITHHOLDS its tool message * — the result is a human's future answer, threaded back on resume — so at * most one may be open at a time; a second in the same batch is answered with * an error rather than left unpaired. `wake` keeps its tool message and * re-enters through a fresh prompt. */ suspend?: { toolCallId: string; reason: string; resumeKind: "answer" | "wake"; request?: unknown; }; }; /** * Result of compacting: the new transcript + the compaction call's own usage, * plus a deferred `persist` step. The loop applies accounting + swaps the * transcript BEFORE calling `persist`, so a persistence failure can't drop the * tokens the compaction LLM call already consumed. */ export interface CompactionApplied { messages: OpenAI.ChatCompletionMessageParam[]; inputTokens: number; outputTokens: number; costCents: number; /** Provider-reported cached input tokens for the compaction call, if known. */ cachedInputTokens?: number | null; /** Persist the compacted session + activity row. Runs after accounting. */ persist: () => Promise; } /** * Mutable accumulator threaded through the loop. The caller seeds it and reads * it back after; a shared object (rather than return values) lets the caller's * coalesced heartbeat read live token totals mid-loop. `messages` is mutated in * place (assistant + tool turns appended; replaced wholesale on compaction). */ export interface ToolLoopState { messages: OpenAI.ChatCompletionMessageParam[]; inputTokens: number; outputTokens: number; costCents: number; /** Provider prompt/completion tokens from the most recent turn (0 right after a compaction). */ lastPromptTokens: number; lastOutputTokens: number; /** True once any turn has produced a real provider count this run. */ hasFreshTokenCount: boolean; /** Cumulative tool calls dispatched this run — the count only, never names or * results. Feeds a live progress indicator via `onProgressUpdate`. */ toolCalls: number; /** True when the loop ended because a tool suspended the run (asked a human a * question, or scheduled its own resume). Distinguishes an intentional pause * from an iteration-limit cutoff — without it a caller reports "couldn't * finish" over a run that stopped exactly where it meant to. */ endedTurnViaTool?: boolean; /** Set when a tool suspended the run awaiting an answer: the open tool-call is * waiting on a human or an external system. The loop withholds that call's * `tool` message — its result IS the future answer — and ends the run; a * resume threads the answer back as the matching `role:"tool"` result. At * most one may be open at a time; extras in the same batch are answered with * a synthesized error so no second slot is left unpaired. */ suspended?: { toolCallId: string; reason: string; resumeKind: "answer"; request?: unknown; }; } export type RunStatus = "thinking" | "thinking_with_tools" | "executing_tools"; /** * A tool asked the loop to activate a plugin or an instruction module, and the * port that would do it was not wired. * * Reported through `onToolCallRejected` rather than thrown: the call itself * succeeded and its tool message is already correct, so failing the run would * be worse than the missing activation. But it must not be silent — before * these ports were optional this was a compile error, and the runtime symptom * (an agent that keeps loading a plugin it never receives) points nowhere near * the cause. * * Match on `error.name === "MissingActivationPortError"` rather than * `instanceof` if you consume this package from a projected or re-bundled copy * — two copies of a class in one module graph make `instanceof` silently * false, and this package is Copybara-projected and republished. */ export declare class MissingActivationPortError extends Error { readonly port: "activatePlugins" | "activateSkills"; readonly name = "MissingActivationPortError"; constructor(port: "activatePlugins" | "activateSkills"); } /** * A turn returned `acceptMessage: false` while also requesting tools. * * Reported through `onToolCallRejected` rather than thrown, and the discard is * refused rather than honoured: the tool calls are about to run, and a * transcript carrying their results without the assistant message that asked * for them is one the next provider call rejects outright. Keeping the message * costs an assistant entry the host did not want; dropping it costs the run. * * Match on `error.name === "DiscardedTurnWithToolCallsError"` rather than * `instanceof` — see {@link MissingActivationPortError} for why. */ export declare class DiscardedTurnWithToolCallsError extends Error { readonly toolCallCount: number; readonly name = "DiscardedTurnWithToolCallsError"; constructor(toolCallCount: number); } /** * A settled step delivered a message that cannot be appended yet: a call is * still awaiting its answer, and the provider rule is that nothing comes * between an assistant message and the `tool` results it is waiting on. * * Appending it anyway builds `assistant(tool_calls) → user → tool` the moment * the answer is threaded back on resume, which providers reject outright — so * the message is refused and reported (through `onToolCallRejected`, against * the open call) rather than silently corrupting the transcript. Deliver the * answering `tool` result first, in the same settlement, and everything after * it is accepted normally. * * Match on `error.name === "InputBlockedBySuspendError"` rather than * `instanceof` — see {@link MissingActivationPortError} for why. */ export declare class InputBlockedBySuspendError extends Error { readonly openToolCallId: string; readonly refusedRole: string; readonly name = "InputBlockedBySuspendError"; constructor(openToolCallId: string, refusedRole: string); } /** * What a completed loop reports back. * * Returned rather than folded into `ToolLoopState` because these are the run's * *conclusion*, not its live progress: a caller reads `state` mid-run for a * heartbeat, and reads this once, after. Before it existed every host * re-derived the outcome from three flags and an iteration count, and each got * a slightly different answer. */ export interface ToolLoopResult { /** * Why the loop stopped. See {@link StopReason} — `deadline` arrives as a * thrown error rather than a value, and is never returned here. * * `aborted` means only "the batch signal was set, or `shouldStop` said so". * It does not say *which*, because a combined signal cannot. A host that * needs timeout-vs-cancellation reaches for its own `RunDeadline` (and * `classifyRunFailure`), not for this value. */ readonly stopReason: StopReason; /** * Cumulative accounting for the run: turns, dispatched tool calls, tokens * (including provider-reported cached input), cost, and the model-time vs * tool-time split with a per-tool breakdown. * * **`stats` is this invocation's contribution alone**; `state` is whatever * the caller seeded plus that. They match only when the caller seeded zeros * — a host resuming a run seeds `state` from the stored totals, and then * `state.costCents` is the run's lifetime cost while `stats.costCents` is * this leg's. Bill from whichever you mean, and do not substitute one for * the other. * * They also differ on tool calls on purpose: `state.toolCalls` counts what * the model *requested* (it drives a live progress indicator, so it has to * rise the moment a batch is dispatched), while `stats.toolCalls` counts * what actually *ran*. An aborted batch is exactly the gap between them. * * Both are lost if the loop throws — a deadline, a cancellation, or a fatal * tool error leaves no return value, so `state` (which is mutated in place) * is the only accounting that survives those exits. */ readonly stats: RunStats; } /** * What a host hands back from {@link ToolLoopParams.onStepSettled}: the * messages to append at this settled step, and whether the run should end * after appending them. */ export interface StepSettlement { /** Appended to the transcript in this order. Empty means "nothing queued". */ messages: OpenAI.ChatCompletionMessageParam[]; /** End the run as `aborted` once the messages above are appended. */ stop: boolean; } export interface ToolLoopParams { state: ToolLoopState; /** * Active plugin set. The loop does not read it — `buildTools` and * `activatePlugins` are the host's own closures over it — so it is optional * and passing one is purely a convenience for a host that likes threading it * through explicitly. */ activePlugins?: Set; maxIterations: number; /** Call the model with the current transcript + tool defs. `onOutputProgress` * (optional) receives a running estimate of THIS call's output tokens as the * stream flows (throttled ~1 Hz inside the model call); the loop adds the * prior cumulative before forwarding to `onProgressUpdate`. */ callModel: (messages: OpenAI.ChatCompletionMessageParam[], tools: OpenAI.ChatCompletionTool[] | undefined, onOutputProgress?: (estimatedOutputTokens: number) => void) => Promise; /** Build the tool definitions for the current active-plugin set (+ MCP). */ buildTools: () => OpenAI.ChatCompletionTool[]; /** Execute one tool call → the `tool` message + control signals. */ runToolCall: (toolCall: OpenAI.ChatCompletionMessageToolCall) => Promise; /** * Record the exact `tool` message the loop is about to append, immediately * before it appends it. For a host that persists the transcript as it is * built, this is the only point where "what was accepted" and "what was * recorded" cannot drift: a batch's outcomes are produced concurrently and * an `answer`-suspend withholds one of them, so a host reconstructing the * accepted set afterwards has to re-derive a decision the loop already made. * * `resultOrdinal` counts **accepted results in this batch, densely from 0** * — not the call's index in `tool_calls`. A withheld suspend leaves no * ordinal behind, so the results that follow it close the gap, and the * answer that arrives later takes the ordinal it is accepted at rather than * the one its call was issued at. Ordering within the batch is otherwise * the model's original call order. * * **A throw aborts acceptance**: the message is not appended and the error * propagates out of `runToolLoop`, unconverted. That is deliberate — this * port exists for hosts that must record a result durably before the model * may see it, and a host that cannot record one must not be handed a * transcript claiming it did. Wire `isFatalToolError` the same way for the * dispatch half of that rule. * * **Scope: results this loop produced.** A `tool` message the host itself * returns from `onStepSettled` does not fire this port and takes no ordinal * — the host committed that message before handing it over, so reporting it * back would ask for a second record of the same thing. `resultOrdinal` is * therefore a position within the batch, and a host that journals both * sources keys them by where they came from rather than by one counter. */ beforeToolMessageAccepted?: (message: OpenAI.ChatCompletionToolMessageParam, resultOrdinal: number) => Promise | void; /** * Deliver host-committed messages at a **settled step** — a point where the * model turn and its whole tool batch are complete and the transcript is * coherent, so appending a `user` (or a withheld `tool`) message cannot * split an assistant message from the results it is waiting on. * * Called **at most once per iteration** — the two points below are the two * shapes an iteration can take, not two calls in one — and `wouldEnd` says * which one this is: * * - **`wouldEnd: true`** — the model asked for no tools and the loop is * about to finish. Returning messages CONTINUES the run with them * appended, ahead of `onTurnWouldEnd` (a host that has real input queued * delivers it rather than nudging a turn that was not actually stalled). * - **`wouldEnd: false`** — the batch ran and its results are in the * transcript. Returning messages appends them before the next model call. * * Return `stop: true` to end the run as `aborted`, with whatever messages * came back already appended. Returning `{ messages: [], stop: false }` — * or leaving the port unwired — changes nothing. * * **A delivered `tool` message answers an open suspend.** If the run is * waiting on an `answer`-suspend and a returned message carries that call's * `tool_call_id`, the loop clears the suspension and keeps going rather than * pausing for a reply it has just been handed. It still ends the run if some * *other* call in the same batch asked for a `wake`-suspend, which is a * separate request that a delivered answer says nothing about. * * **While a suspend is open, only `tool` messages are accepted.** Nothing * may come between an assistant message and the results it is waiting on, so * a `user` message delivered ahead of the answer is refused and reported as * an {@link InputBlockedBySuspendError} — appending it is how a resumed run * ends up sending `assistant(tool_calls) → user → tool`, which providers * reject. Return the answering result first and the rest is accepted after * it, in order. * * **A host that asked to stop still stops.** Delivered input says the turn * is not over; `shouldStop` says the run is, and the run wins — the messages * are kept and the loop ends as `aborted` rather than paying for another * model call. * * Messages are otherwise appended in the order returned, and the loop does * not validate them further: this is the host's transcript, committed on the * host's side, arriving at the one place the loop can take it. * * **A throw propagates out of `runToolLoop` unconverted**, like * `beforeToolMessageAccepted` and for the same reason: a host that cannot * say what it has committed cannot be told the run went on without it. */ onStepSettled?: (step: { assistantMessage: OpenAI.ChatCompletionMessage; cumulativeToolCalls: number; wouldEnd: boolean; }) => StepSettlement | Promise; /** * Activate newly loaded plugins (mutate the catalog/active set). Optional: * a host with a fixed tool surface has nothing to activate, and requiring an * empty function from it bought nothing. */ activatePlugins?: (pluginNames: string[]) => void; /** * Activate newly loaded skills: inject their bodies into the system prompt's * instructions section and refresh the catalog. Async because a host may * re-read the module body from storage. Expected to no-op for a ref the agent * cannot access — the loop does not pre-validate them. Optional, as above. */ activateSkills?: (skillRefs: string[]) => Promise | void; /** * Clock for the model-time and tool-time measurements in * {@link ToolLoopResult.stats}. Defaults to `Date.now`, the package's one * sanctioned ambient-clock exception; inject a fake to make timing * assertions deterministic. * * **Must be a real monotonic clock when tools can run concurrently.** Each * call records `now()` at dispatch and again when it settles, so a shared * counter that only advances when some *other* call asks it to will charge * one tool for another's time. A cooperatively-advanced fake is fine for a * serial batch (`runsSerially`), and fine for the model-time figures always. * * Measured *around* `callModel`, so the number includes whatever that * function does internally — a defect retry, a fallback provider, a routing * hop. That is the honest figure for cost-and-latency accounting: it is what * the turn actually took. A host that wants the successful attempt's * generation time alone already has it, inside its own transport. */ now?: () => number; /** * Must this call run on its own, before the rest of its batch? * * True for any call that changes what the later calls can do — activating a * plugin, loading an instruction module. The loop runs those one at a time * and applies each outcome immediately, so a dependent call in the SAME batch * sees the effect. Everything else fans out concurrently, pooled per tool * name. * * Unwired, nothing is serial: every call runs in the concurrent phase, which * is correct for a host whose tools do not reshape the tool surface. */ runsSerially?: (toolCall: OpenAI.ChatCompletionMessageToolCall) => boolean; /** * Should this thrown error abort the run rather than become a tool error the * model reads? * * Two things belong here: **cancellation**, which must propagate even when no * `ensureNotCancelled` observer is wired, and a failure to *record* an * outcome — synthesizing "the tool failed" over a persistence failure tells * the model a lie about work that may well have happened. * * Unwired, nothing is fatal: every failure is synthesized into a tool error * and the run continues. That is the safe default for an isolated tool, and * the wrong one as soon as your tools write anything. */ isFatalToolError?: (error: unknown) => boolean; /** * A tool call failed and was answered with a synthesized error instead of * throwing. The model sees it either way; without this observer nothing else * does. */ onToolCallRejected?: (toolCallId: string, error: unknown) => void; /** Throw to abort (run cancelled). Checked at the top of each iteration and after each batch. */ ensureNotCancelled?: () => Promise | void; /** Throw a timeout error if the wall-clock budget is exhausted (checked first each iteration). */ throwIfTimedOut?: () => void; /** Phase status for rich client feedback. */ onStatus?: (status: RunStatus) => void; /** Intermediate assistant text emitted alongside tool calls. NOTE: when * `onAssistantMessage` is also wired, a text-with-tools message fires to BOTH * observers (they have different contracts — see below). Wire only one per * output surface so the same text isn't delivered twice. */ onThinking?: (content: string) => void; /** * Every assistant message that carries non-empty text content, fired the * moment it's produced — whether or not it also requested tools, and including * the final tool-less reply. Unlike `onThinking` (text-with-tools only), this * sees ALL of a turn's assistant text, so a caller can stream each message to * the client as it lands rather than batching them at turn end. Receives the * already-trimmed content. */ onAssistantMessage?: (content: string) => void; /** Flush accumulated progress (heartbeat). Called force-true after each turn. */ flushProgress?: () => Promise | void; /** Cumulative live progress: the running output-token count (updated * mid-stream and after each model turn) and the number of tool calls made so * far this run (the count only — never a name or a result). Called with the * estimate during streaming and with real totals at each iteration boundary, * and again right after a batch is dispatched so the count rises promptly. */ onProgressUpdate?: (outputTokens: number, toolCalls: number) => void; /** Return true to stop the loop after the current turn (e.g. agent disabled mid-run). */ shouldStop?: () => Promise | boolean; /** * Called when the model produced a turn with NO tool calls — i.e. the loop is * about to stop. Return a non-empty string to inject it as a synthetic `user` * message and CONTINUE the loop instead of stopping; return null/empty to stop * as normal (the default behavior when unwired). Use it to push a stalled turn * forward — nudging an agent that ended a turn having made no progress at all. * Receives the cumulative tool-call count so the hook can detect exactly that. * MUST be self-bounding (eventually return null); `maxIterations` bounds it * regardless, and the injected `user` message is model-only — a caller that * persists a transcript should ignore `user` turns it didn't originate. * * `cumulativeToolCalls` is the running total across ALL iterations of this run * (not just the current one), so a hook can detect "made zero tool calls the * whole run" — a tool call in any earlier iteration makes it non-zero. */ onTurnWouldEnd?: (assistantMessage: OpenAI.ChatCompletionMessage, cumulativeToolCalls: number) => string | null | Promise; /** Drain queued human interrupts for this run. */ drainInterrupts?: () => Array<{ userId: string; content: string; }>; /** Report that an interrupt was received (activity row). */ onInterruptReceived?: (interrupt: { userId: string; content: string; }) => Promise | void; /** * Bounds the tool batch. Combine the run's deadline with any cancellation * signal (`deadline.withExternal(cancelSignal)`) and pass the result. * * Without it, `throwIfTimedOut` and `ensureNotCancelled` are only consulted * between iterations, so a deadline that fires while the model is being * called still lets the whole batch execute its side effects, and a hung tool * holds the run open for as long as it runs. Those are throw-based ports and * cannot express "stop claiming new work" to a pool already in flight — only * a signal can. * * Optional so an existing host is unchanged until it opts in. */ signal?: AbortSignal | undefined; /** Decide whether the live context (token count) needs auto-compaction. */ needsCompaction?: (currentTokens: number) => boolean; /** * Perform + persist a compaction and return the new transcript + usage. The * loop owns swapping `state.messages` and resetting the live counts; this * callback owns the LLM passes and persistence (session + activity row). */ applyCompaction?: (trigger: "manual" | "auto", messages: OpenAI.ChatCompletionMessageParam[]) => Promise; } /** * Repeatedly call the model and execute the tools it requests, until it stops * requesting them, a caller stops the loop, a tool suspends the run, or * `maxIterations` is reached. * * Intrinsic: plugin/module activation, two-phase tool batching, compaction * (manual + auto), interrupt draining, and the suspend protocol. Injected: * every side effect — status, heartbeat, activity, persistence, cancellation. * * Mutates `state` (messages + token accumulators) in place. That is deliberate * rather than a return value: a caller's heartbeat reads live totals off it * mid-loop, which a returned result could not provide until the run ended. * The {@link ToolLoopResult} it *returns* is the complementary half — the * run's conclusion, which only exists once the loop is over. */ export declare function runToolLoop(params: ToolLoopParams): Promise; export declare function runToolLoop(params: ToolLoopParams, control: LoopControl): Promise;