@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
617 lines (616 loc) • 31.3 kB
TypeScript
import type Anthropic from "@anthropic-ai/sdk";
import type { Span } from "@opentelemetry/api";
import type { Tool } from "./tools.js";
import type { CollectedChunkResult, NativeFunctionCall, NativeToolDeclarationsResult } from "./providers.js";
import type { ExecutionControlDecision, ExecutionControlStepContext } from "./stream.js";
/**
* One chunk on the engine's stream.
*
* `reasoning` is carried alongside `content` rather than instead of it: the
* providers that emit extended thinking (direct Anthropic, Google AI Studio,
* Vertex) push a chunk with empty `content` and the thinking delta in
* `reasoning`, so a channel typed `{ content: string }` alone would drop
* every thinking delta the moment those providers move onto the engine —
* silently, since the text path would keep working.
*/
export type AgenticLoopChunk = {
content: string;
reasoning?: string;
};
export type AgenticLoopToolCall = {
id: string;
name: string;
args: Record<string, unknown>;
};
export type AgenticLoopUsage = {
inputTokens: number;
outputTokens: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
reasoningTokens?: number;
/**
* Cache writes split by time-to-live, which Anthropic reports separately
* from the total under `cache_creation.ephemeral_5m_input_tokens` and
* `ephemeral_1h_input_tokens`.
*
* Carried because the Claude-on-Vertex turn span reports both as
* `input_cache_creation_5m` / `_1h`, and `cacheWriteTokens` alone cannot
* reconstruct them — the two tiers are priced differently, so collapsing
* them loses the only signal that says which one a turn actually bought.
* Undefined for providers that never report the split.
*/
cacheWrite5mTokens?: number;
cacheWrite1hTokens?: number;
};
export type AgenticLoopStepResult<TRaw = unknown> = {
text: string;
reasoning?: string;
toolCalls: AgenticLoopToolCall[];
usage: AgenticLoopUsage;
/** Provider's own raw stop/finish-reason string, e.g. "tool_use", "MAX_TOKENS" */
rawStopReason: string | undefined;
/** Adapter-private accumulated response data needed by buildToolResultMessages
* (e.g. Anthropic's ordered content blocks, Gemini's rawResponseParts). */
raw: TRaw;
};
export type AgenticLoopToolCallResult = AgenticLoopToolCall & {
output: unknown;
error?: string;
permanentlyFailed?: boolean;
};
export type AgenticLoopStepRequest = {
raw: unknown;
/**
* Tools that became callable while this step's request was being built —
* mid-turn discovery hydrating a name the model had already tried.
*
* The engine clears each one's failure strikes before dispatching, because
* TOOL_NOT_FOUND strikes accrued while a tool was deferred are snapshot
* artifacts rather than real failures. Clearing them at the miss-resolution
* path instead would be too late: the breaker is consulted BEFORE the
* lookup, so a tool already at the strike limit is refused without the
* resolution path ever running.
*/
hydratedToolNames?: string[];
};
export type AgenticLoopReclaimResult<TConversation> = {
conversation?: TConversation;
/**
* End the turn now, BEFORE this step's request is issued.
*
* A context guard does two things, and only one of them is "reclaim". When
* dropping old exchanges buys enough room the turn continues; when it does
* not, the guard has to stop rather than step into a provider rejection
* that would lose every completed step. Nothing else can express that: the
* hook returns a conversation, and an adapter cannot break the engine's
* loop.
*
* Aborting the caller's own signal from inside this hook does NOT work as a
* substitute — the engine checks for an abort at the TOP of the step, above
* this call, so the request would still be issued and the stop would not
* take effect until the following step.
*/
stop?: boolean;
};
export type AgenticLoopToolFailureBreaker = {
maxRetries: number;
/**
* Count CONSECUTIVE failures rather than lifetime ones: a clean result
* clears the strike count for that tool.
*
* Off by default because the two behaviours diverge for a tool that fails
* intermittently, and the providers already on this engine accumulate. What
* it protects is the argument-dependent soft error — a file-not-found on one
* path, fine on the next — which under lifetime counting disables a working
* tool for the rest of the turn.
*/
consecutive?: boolean;
/**
* Decide whether a RESOLVED tool result is really a failure.
*
* Some tools report failure without throwing: MCP `isError` payloads, a
* proxy-blocked call returning `{ error }`. Counting only thrown errors lets
* the model grind on one of those for the entire step budget. Returning a
* non-empty string strikes the breaker exactly as a throw does; returning
* undefined leaves the result a success.
*
* Off by default — a provider whose loop never inspected results this way
* must not start doing so as a side effect of migrating.
*/
classifyResultFailure?: (output: unknown) => string | undefined;
};
/**
* DESIGN DECISION — mid-turn tool-discovery hydration (Plan 08 blocker 2,
* Task 7): resolved by the single optional `resolveToolOnMiss` field below,
* NOT by a broader `dispatchTools?` full-dispatch override. A full-dispatch
* override would let an adapter replace the engine's entire per-call
* dispatch — breaker bookkeeping, execution, toolExecutions aggregation — so
* every adapter needing hydration would have to reimplement that bookkeeping,
* and any later engine-level fix to dispatch would silently not apply to the
* adapters using the override. `resolveToolOnMiss` plugs into the existing
* dispatch at the one decision point that needs a second lookup, leaving
* breaker bookkeeping, retries and aggregation engine-owned for every
* provider, hydrated or not.
*
* DESIGN DECISION — originalNameMap propagation (blocker 3): needs ZERO
* engine or type change. Google's function-name sanitization is a translation
* concern between the wire (sanitized names out, sanitized names back on
* tool_call.name) and the engine's shape, which only ever sees plain string
* names. An adapter that needs the map threads it as a constructor-time
* closure and translates inside its own `executeStep` /
* `buildToolResultMessages`, before those names cross the engine boundary.
*
* DESIGN DECISION — reserved-step + forced finalization (blocker 1, part 2):
* stays OUTSIDE `runAgenticLoop`, in Vertex+Claude's own wrapper around
* `resultPromise`. The reserved step needs no engine change at all — an
* adapter declaring `maxSteps: requested - 1` means the engine's own loop
* never touches the reserved slot. The forced call is a one-shot action taken
* on the RESULT of a turn, not a repeatable step within one, so folding it in
* would teach the engine a family-specific concept (forced tool_choice, a
* distinguished terminal tool name) that every other adapter would then carry
* and never set.
*
* DESIGN DECISION — terminal tool-call marking (blocker 1, part 1): needs
* ZERO engine or type change. An adapter treats a detected terminal call as
* terminal by omitting it from `toolCalls` and putting its parsed payload in
* `text`. The engine already ends a turn the moment a step yields zero tool
* calls, so such a step is indistinguishable from an ordinary final text
* turn: never looked up in `options.tools`, never reaching TOOL_NOT_FOUND,
* never counted against the breaker. Proven by a case in the loop-engine
* suite rather than asserted here.
*/
export type AgenticLoopAdapter<TConversation = unknown, TRaw = unknown> = {
readonly providerLabel: string;
readonly maxSteps: number;
/** Set only for adapter instances whose client has the TOOL_NOT_FOUND strike breaker today: both Gemini adapters (AI Studio, Vertex+Gemini) AND the Vertex+Claude call to createAnthropicLoopAdapter — NOT the native-Anthropic call to that same factory, and not Bedrock. See Verified Fact 4. */
readonly toolFailureBreaker?: AgenticLoopToolFailureBreaker;
/**
* Append a planning nudge to the conversation, in the provider's own message
* type. Supplied only by adapters whose caller can pass a step-boundary
* callback; the engine skips a nudge it has no way to write.
*
* Provider-supplied for the same reason `buildToolResultMessages` is: the
* engine does not know what a valid user turn looks like on this wire, and
* on Anthropic a nudge appended after a tool-result turn has to merge into
* that turn rather than open a second consecutive user message.
*/
readonly appendPlanningNudge?: (conversation: TConversation, text: string) => TConversation;
/**
* Second lookup path, consulted when a tool call names nothing executable
* in the caller's `options.tools` — used by adapters supporting mid-turn
* discovery to hydrate a tool the model just found via `search_tools`, or a
* deferred-catalog tool called by its advertised name, before the engine
* falls through to TOOL_NOT_FOUND and the breaker strike. See the design
* decision above for why this is a narrow lookup and not a dispatch
* override.
*/
readonly resolveToolOnMiss?: (name: string) => {
execute: (args: Record<string, unknown>, opts: unknown) => Promise<unknown>;
} | undefined;
buildStepRequest(conversation: TConversation, step: number): AgenticLoopStepRequest;
executeStep(request: AgenticLoopStepRequest, channel: {
push(chunk: AgenticLoopChunk): void;
}, signal: AbortSignal): Promise<AgenticLoopStepResult<TRaw>>;
/**
* `step` is the engine's own zero-based step index, not a count of times
* this hook ran. Adapters persist tool activity keyed by it, and the two
* numbers diverge: a malformed-call retry `continue`s before this hook is
* reached and still consumes a step, so an adapter counting its own
* invocations drifts by exactly the number of retries and mislabels every
* row after the first one.
*/
buildToolResultMessages(conversation: TConversation, stepResult: AgenticLoopStepResult<TRaw>, toolResults: AgenticLoopToolCallResult[], step: number): TConversation;
mapFinishReason(rawStopReason: string | undefined, hadToolCalls: boolean): string;
/** Optional: in-turn context-budget reclaim, called once per step before buildStepRequest. */
planReclaim?(conversation: TConversation, step: number): AgenticLoopReclaimResult<TConversation> | undefined;
/** Optional: Vertex+Gemini-only single-retry-on-malformed-call. */
isMalformedStep?(stepResult: AgenticLoopStepResult<TRaw>): boolean;
buildMalformedRetryNote?(conversation: TConversation, step: number): TConversation;
};
/**
* Construction input for `createAnthropicLoopAdapter`, shared by direct
* Anthropic and Vertex+Claude.
*
* `toolFailureBreaker` is the one field the two call sites must differ on:
* Vertex+Claude ports the Gemini loops' failure-strike breaker, native
* Anthropic has never had one, and setting it for both would change native
* Anthropic's behaviour under the guise of a shared refactor.
*/
/**
* Construction input for `createAnthropicLoopAdapter`, shared by direct
* Anthropic and Claude-on-Vertex.
*
* Parameterized on the message shape because the two do NOT agree on it.
* Direct Anthropic uses the SDK's `MessageParam`; Claude-on-Vertex carries its
* own `VertexAnthropicMessage`, whose role union is narrower
* ("user" | "assistant", no "system") and whose image `media_type` is a plain
* string rather than the SDK's four-way union. Neither is assignable to the
* other, so pinning the adapter to one of them locked the other out entirely.
* The engine has been generic over its conversation type all along; this makes
* the adapter config match.
*/
export type AnthropicLoopAdapterConfig<TMessage = Anthropic.Messages.MessageParam> = {
/**
* Only `messages.create` is ever called, so only that is required.
*
* `Pick<Anthropic, "messages">` looked equivalent and is not: it demands the
* FULL `Messages` resource, including `batches` and the private `_client`.
* That excludes `AnthropicVertex`, whose `MessagesResource` is structurally
* smaller but has the one method this adapter uses — a client the adapter
* can drive perfectly, rejected for members it never touches.
*/
client: {
messages: Pick<Anthropic["messages"], "create">;
};
maxSteps: number;
/**
* Build one step's request. A closure so the per-turn work the caller
* already does — system prompt, tool declarations, sampling, thinking
* config, cache breakpoints — stays where it is rather than moving here.
*/
/**
* Returns the NON-streaming params. The adapter adds `stream: true` itself,
* so requiring the streaming variant here would force every caller to
* declare a literal `true` it does not control — and a caller whose params
* type carries `stream?: boolean` fails to match `stream: true` for a field
* the adapter is about to overwrite.
*/
buildParams: (conversation: TMessage[], step: number) => Anthropic.Messages.MessageCreateParamsNonStreaming;
/** The turn's live tool record, used for deferred-catalog resolution. */
toolsRecord: Record<string, Tool>;
/**
* Name of the terminal structured-output tool when one is in play. A call
* to it ends the turn: its arguments ARE the answer, so it is reported as
* text and omitted from `toolCalls`, which routes it through the engine's
* ordinary zero-tool-calls exit.
*/
finalResultToolName?: string;
/**
* Called with the terminal tool's payload when one was actually detected.
*
* The caller cannot infer this from the turn's result. A structured turn
* ends with the payload in `text` when the model called the terminal tool,
* and with ordinary prose in `text` when it ignored the instruction and
* answered directly — the two are indistinguishable downstream, yet they
* are handled differently: the payload is delivered as the answer, while
* prose is delivered from the caller's own buffer. Comparing strings to
* tell them apart would be guesswork, so the adapter says which happened.
*/
onTerminalResult?: (text: string) => void;
/**
* Hard deadline for ONE `messages.create` request (ms). Composed with the
* engine's signal for the duration of that step only, so it bounds a stalled
* upstream even when the turn itself has no lifetime ceiling — and is armed
* fresh per step, which is why a step boundary can never extend a deadline
* already running.
*
* When the deadline fires, the step throws the timer's own TimeoutError
* rather than returning what it had: the Anthropic SDK's stream iterator
* exits WITHOUT throwing on an aborted read, so a truncated step would
* otherwise be reported as a model turn that simply said less.
*/
requestTimeoutMs?: number;
/**
* Require the upstream terminal event (`message_stop`) before a step counts
* as complete.
*
* A response that ends early carries syntactically complete content blocks —
* including tool_use blocks — so without this the loop dispatches tools the
* model never finished asking for and reports the turn as a normal stop.
* Off by default: turning it on unconditionally would change what every
* existing caller sees from a flaky connection.
*/
requireTerminalEvent?: boolean;
toolFailureBreaker?: AgenticLoopToolFailureBreaker;
/**
* In-turn context reclaim, run once per step before the request is built.
* Returns the rebuilt conversation when it reclaimed, undefined while the
* request still fits — leaving history byte-identical in the common case so
* the rolling prompt-cache prefix stays valid.
*
* Provider-supplied because the guard decides and the caller mutates in its
* own concrete types: dropping an assistant tool_use message together with
* its paired user tool_result is what keeps blocks paired. The loop appends
* both every step with nothing else bounding growth, so a migration that
* drops this overflows the window mid-turn.
*/
planReclaim?: (conversation: TMessage[], step: number) => AgenticLoopReclaimResult<TMessage[]> | undefined;
/**
* Calibration feedback for the provider's reclaim guard: the FULL prompt
* size for the step just made — uncached input plus both cache tiers.
* Passing input_tokens alone reads a cache-hit step as tiny and lets the
* guard drift far under the real cost.
*/
noteObservedPromptTokens?: (promptTokens: number) => void;
abortSignal?: AbortSignal;
};
/**
* The things a native loop wraps around every tool call that the shared engine
* does not do itself.
*
* NOT Gemini-specific, despite where this started. Both Vertex+Claude loops
* apply these guards directly via `guardToolExecutor`, and the Gemini adapter
* applies them to hydrated tools, so the former `GeminiToolExecutionGuards`
* name described the first caller rather than the contract — and pointed the
* next reader at the wrong provider family.
*
* All optional, and the whole object is optional, because callers differ:
* Vertex bounds tool execution and runs a stall watchdog, AI Studio does
* neither. Passing nothing leaves an executor exactly as the caller supplied
* it, so this cannot quietly give a provider behaviour its hand-rolled loops
* never had.
*/
export type ToolExecutionGuards = {
/**
* Upper bound on a single execute(). Omit, or pass `null`, for no bound.
*
* `null` is accepted as well as `undefined` because it is what a caller's
* own `toolTimeoutMs: null` resolves to, and the two must not diverge: a
* value that means "unbounded" at the public surface cannot arrive here
* meaning "0ms".
*/
toolTimeoutMs?: number | null;
/**
* Turn-level abort, raced against the call so a deadline or caller cancel is
* observed immediately instead of after the tool settles.
*/
abortSignal?: AbortSignal;
/**
* Stall-watchdog ping, called either side of the await. The watchdog is a
* whole-turn interval measuring wall-clock since the last mark, so a
* legitimately slow tool reads as a stalled turn without this.
*/
onProgress?: () => void;
/**
* Wrap one tool call in the provider's own observability.
*
* A function rather than a pair of start/end callbacks, because the caller
* needs the execution to happen INSIDE its span's context — spans opened by
* the tool itself must nest under the tool call, not dangle as siblings of
* the turn. Handing over the whole invocation is the only shape that lets a
* caller do `context.with(span, run)`.
*
* The caller awaits `run()` itself, so it sees the settled result and can
* mark a FAILURE THAT WAS RETURNED rather than thrown: MCP tools report
* errors in their payload, and an observation that only watches for
* exceptions records those calls as successful.
*/
withToolSpan?: <T>(name: string, run: () => Promise<T>) => Promise<T>;
};
/**
* @deprecated Renamed to `ToolExecutionGuards` — the guards were never
* Gemini-specific. Kept because this name is re-exported from the package root
* via the types barrel, so removing it outright would break any consumer that
* imports it (CLAUDE.md rule 5). Safe to drop at the next major.
*/
export type GeminiToolExecutionGuards = ToolExecutionGuards;
/** What one Gemini step produced, carried to `buildToolResultMessages`. */
export type GeminiStepRaw = {
rawResponseParts: unknown[];
stepFunctionCalls: NativeFunctionCall[];
};
/** One turn entry in a Gemini conversation: a role plus its content parts. */
export type GeminiTurnContent = {
role: string;
parts: unknown[];
};
/**
* Construction input for `createGeminiLoopAdapter`, shared by Google AI Studio
* and Vertex Gemini. Both issue `models.generateContentStream` and consume the
* same response shape, so one adapter serves four hand-rolled loops.
*/
export type GeminiLoopAdapterCoreConfig = {
/** Used in log lines and generated tool-call ids. */
providerLabel: string;
maxSteps: number;
/** Build one step's request object (model, contents, config). */
buildRequest: (conversation: GeminiTurnContent[], step: number) => unknown;
/** Issue the request. Kept injectable so each provider keeps its own client. */
sendStep: (request: unknown, signal: AbortSignal) => Promise<AsyncIterable<{
functionCalls?: NativeFunctionCall[];
[key: string]: unknown;
}>>;
/**
* The turn's live tool record. Mid-turn `search_tools` discovery hydrates
* into this, which is what both the declaration refresh and
* `resolveToolOnMiss` read.
*/
liveTools: Record<string, Tool>;
/**
* Declarations built for this turn. Carries `originalNameMap`, which keeps
* Google's function-name sanitization on the adapter side of the engine
* boundary.
*/
declarations?: NativeToolDeclarationsResult;
toolFailureBreaker?: AgenticLoopToolFailureBreaker;
/**
* In-turn context reclaim, run once per step before the request is built.
* Returns the rebuilt conversation when it reclaimed, undefined when the
* request still fits.
*
* Provider-supplied rather than engine-owned because the two Gemini
* providers reclaim differently (reclaimAiStudioContext vs
* reclaimVertexLoopContext) while the engine only decides WHEN to ask. The
* loops append a model turn plus a tool turn every step with nothing else
* bounding growth, so a migration that drops this overflows the context
* window mid-turn and loses every completed step.
*/
planReclaim?: (conversation: GeminiTurnContent[], step: number) => AgenticLoopReclaimResult<GeminiTurnContent[]> | undefined;
/**
* Usage feedback for the provider's own context guard, called after each
* step with that step's real token counts.
*/
noteUsage?: (inputTokens: number, outputTokens: number) => void;
/**
* The same guards `buildDedupedEngineTools` wraps declared tools in, applied
* to one hydrated mid-turn.
*
* Without this a tool discovered during a turn is the ONE executor that runs
* raw: no per-turn dedup, no execution timeout, no stall-clock ping. That is
* the opposite of what discovery is for — the tool the model just found is
* the one most likely to be called repeatedly with the same arguments.
*/
toolGuards?: ToolExecutionGuards;
/**
* Name of the terminal structured-output tool when one is in play. A call
* to it ends the turn: its arguments ARE the answer, so it is reported as
* text and omitted from `toolCalls`, which routes it through the engine's
* ordinary zero-tool-calls exit — never dispatched, never counted against
* the breaker, never recorded as a tool execution.
*/
finalResultToolName?: string;
/**
* Called with the terminal tool's payload when one was actually detected.
*
* The caller cannot infer this from the turn's result: a structured turn
* ends with the payload in `text` when the model called the terminal tool,
* and with ordinary prose in `text` when it answered directly instead, and
* those two are indistinguishable downstream while being handled
* differently. Comparing strings to tell them apart would be guesswork.
*/
onTerminalResult?: (text: string) => void;
/**
* Fold one step's raw stream into the shape the adapter reports.
*
* Defaults to `collectStreamChunksIncremental`, which is what AI Studio and
* any provider sharing the googleNativeGemini3 helpers want. Vertex does
* NOT share them: its loop drains the stream itself, folding cumulative
* usage counts as deltas and capturing thought signatures in its own way,
* and that behaviour is characterized rather than incidental.
*
* So the collector is a hook rather than a hard-coded call. A provider
* whose drain differs supplies its own and keeps its measured behaviour;
* one that matches the shared helper passes nothing.
*/
collectStep?: (stream: unknown, channel: {
push(chunk: AgenticLoopChunk): void;
}) => Promise<CollectedChunkResult>;
};
/**
* Opt in to the single MALFORMED_FUNCTION_CALL retry.
*
* Vertex Gemini only. AI Studio has no such retry today (confirmed: zero
* MALFORMED_FUNCTION_CALL handling in its client), and turning it on there
* would be a behaviour change disguised as a shared refactor. The engine owns
* the one-retry budget; this only says whether to ask.
*
* A union rather than two independent optional fields because the retry is
* only worth spending a step on if the re-issued request differs from the one
* that just failed. `runAgenticLoop` falls back to the unchanged conversation
* when no note builder is supplied (`buildMalformedRetryNote?.(…) ??
* conversation`), so enabling the retry without one re-sends a byte-identical
* request and most often reproduces the same malformed call — a step burned
* for nothing. Requiring the builder here makes that combination unsayable
* instead of merely discouraged.
*/
export type GeminiMalformedRetryConfig = {
enableMalformedRetry: true;
/**
* Append the corrective turn that the retry re-issues with.
* Provider-supplied because the note is written in the provider's own
* content shape.
*/
buildMalformedRetryNote: (conversation: GeminiTurnContent[], step: number) => GeminiTurnContent[];
} | {
enableMalformedRetry?: false;
buildMalformedRetryNote?: never;
};
export type GeminiLoopAdapterConfig = GeminiLoopAdapterCoreConfig & GeminiMalformedRetryConfig;
export type AgenticLoopOptions = {
tools?: Record<string, {
execute?: (args: Record<string, unknown>, opts: unknown) => Promise<unknown>;
}>;
abortSignal?: AbortSignal;
/**
* Span the per-step provider retry annotates, via
* `withProviderRetry(..., span, ...)` — it records
* `gen_ai.provider.total_attempts` on every completed step, retried or not.
*
* Caller-supplied rather than read from the ambient context inside the
* engine. Reading it here would hand the attribute to every provider on the
* engine, including ones whose hand-rolled loops never emitted it, and a
* refactor that silently ADDS observable behaviour is the same defect as one
* that silently drops it. Today only the direct Anthropic loops set this,
* because only they threaded a span before moving onto the engine.
*/
span?: Span;
/**
* Upper bound on a single `tool.execute()` (ms), or `null` for no bound.
* Defaults to `DEFAULT_TOOL_EXECUTION_TIMEOUT_MS` (300_000) when omitted.
*
* The turn-level timers do not cover this: a per-request deadline bounds one
* model call and is disposed when the step settles, and the step cap only
* advances when a step completes. Between two steps, a tool that never
* returns has nothing watching it, and a turn that opted out of a lifetime
* ceiling then hangs forever. A tool that exceeds the bound is told to stop
* — the signal it was handed is aborted — and fails with an error tool
* result costing one step, exactly as it does on the native generate path.
*
* `null` restores the behaviour of a loop with no per-tool timer at all:
* `execute` is awaited unguarded, with the turn's own signal. Callers that
* relied on unbounded tool execution say so with it. The one combination
* refused is `null` together with `executionControl.lifetimeTimeoutMs: null`
* — that would leave the turn with no bound anywhere.
*
* Callers that already guard their executors (Vertex and the Gemini
* adapters, via `guardToolExecutor`) should pass the SAME value they gave
* those guards, so this backstop can never be tighter than what the caller
* asked for.
*/
toolTimeoutMs?: number | null;
/**
* Step-boundary callback. Runs after a step's tool results have settled and
* been written into the conversation, and BEFORE the loop re-checks the step
* cap — the one point in a turn where raising the cap changes what happens
* next without replaying anything that already happened.
*
* Already bounded and cancellable by the time it reaches the engine: the
* caller owns the callback's own budget, because the caller is what the
* public option was validated against. The engine only calls it, applies a
* strictly-larger finite cap if one comes back, and asks the adapter to
* write the nudge. It never restarts a step, retries a request, or replays a
* tool.
*/
beforeStep?: (context: ExecutionControlStepContext) => Promise<ExecutionControlDecision | undefined>;
};
export type AgenticLoopResult<TConversation> = {
text: string;
toolCalls: AgenticLoopToolCall[];
/**
* Every tool dispatch the loop performed, in order, including the ones that
* failed.
*
* `id` and `error` are carried because providers persist tool activity as
* paired call/result records keyed by the provider's own tool-call id, and
* a result that failed is stored differently from one that succeeded. A
* shape with only name/input/output cannot reconstruct either, so a
* provider migrating its hand-rolled loop onto this engine would have to
* silently drop both from its history — which is a behaviour change, not a
* refactor.
*/
toolExecutions: Array<{
id: string;
name: string;
input: Record<string, unknown>;
output: unknown;
error?: string;
}>;
usage: AgenticLoopUsage;
finishReason: string;
rawStopReason: string | undefined;
conversation: TConversation;
/**
* True when the turn ended because its abort signal fired rather than
* because the model finished.
*
* Not derivable from anything else on this result, which is why it is here.
* A turn cut short mid-stream never receives a terminal event, so
* `rawStopReason` is undefined and `mapFinishReason` lands on exactly the
* value a model that answered and stopped produces. Without this flag a
* caller reading the result cannot tell "the model finished" from "we
* stopped listening", and every consumer that branches on the outcome —
* fallback gates, retry policy, a UI that says why a turn ended — reads the
* interrupted turn as a success.
*/
aborted: boolean;
};