UNPKG

@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
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; };