/** * @packageDocumentation * EXT-159 — the typed reason a run ended. * * A run can stop for a dozen unrelated causes — a rate limit, a provider-side fault, a full context * window, a content-policy refusal, the approvals gate, a tool-error budget, the user pressing Esc * — and every one of them used to reach the surface as one untyped sentence. This module is the * single taxonomy those causes are classified into, so the fact "why did this end" is carried as a * value rather than reconstructed from prose. * * **Two feeders converge here, and they are not interchangeable.** * * - A **metadata reader** (`detectStopMetadata` in `core/refusal.ts`, called from * `GthAbstractAgent`) handles reasons that arrive *on a message* — a stop/finish reason in * `response_metadata` or * `additional_kwargs`. It sits at that layer because that is the only place the metadata is * visible. * - An **exception classifier** ({@link classifyThrownTermination}, called from the runner's * catches) handles reasons that arrive as a *thrown error*. Those are not in `response_metadata` * at all, so no metadata reader can ever see them. * * Built as one metadata reader the whole thrown-error half of the class falls outside it; built as * two taxonomies every consumer grows its own. Hence: two feeders, one taxonomy. * * **Retryability is two facts, never a boolean.** `@langchain/core` exports a typed * `ContextOverflowError` that stamps itself non-retryable in its own constructor. That is right for * "send the same prompt again" and exactly backwards for the remedy this cause actually has, which * is to send a *smaller* one. {@link GthTerminationReason} therefore carries * {@link GthTerminationReason#retryableAsIs} and * {@link GthTerminationReason#retryableAfterRemedy} separately, with the remedy named. * * Classification only. Nothing here surfaces anything, formats anything, or changes what a run * does; the user-facing strings stay where they are and keep their own wording. */ /** * What ended the run, as one closed vocabulary shared by every site and every consumer. * * Members are causes, not messages: two sites that stop for the same reason report the same * category and are told apart by {@link GthTerminationSite}. */ export type GthTerminationCategory = /** The model finished of its own accord — the ordinary end of a turn. */ 'completed' /** The turn produced no content at all (no refusal, no error, nothing). */ | 'empty_response' /** * [[EXT-204]] — the model's reply was cut off before it finished (no answer text, no tool call and * no finish reason), and the one retry was cut off too. */ | 'stream_cut' /** The model or the provider's safety system declined to answer. */ | 'content_refusal' /** The answer was cut off against the output cap rather than finished. */ | 'output_truncated' /** Prompt plus history exceeded the model's input window. */ | 'context_overflow' /** The provider refused for rate/quota reasons (HTTP 429). */ | 'rate_limited' /** Credentials were missing, wrong or unauthorised (HTTP 401/403). */ | 'auth_failed' /** The provider rejected the request itself (HTTP 400) for a reason retrying cannot change. */ | 'invalid_request' /** A fault on the provider's side (HTTP 5xx, "internal error during token generation"). */ | 'provider_error' /** The request never completed at the transport level. */ | 'network_error' /** A deadline elapsed before the run finished. */ | 'timeout' /** The user stopped it — Esc, a cancelled signal, a closed client. */ | 'cancelled' /** The approvals gate deliberately ended the run. */ | 'approval_stop' /** The tool-error budget ended the run rather than spend another model call. */ | 'tool_error_budget' /** The tool-loop guard ended a no-progress identical-call loop. */ | 'tool_loop_guard' /** * The tool-approval interrupt drain gave up: the graph re-suspended on a gated tool call more * times in one turn than the drain loop is willing to resume, so the RUNTIME ended the turn. * * Deliberately not `recursion_limit`, which states that the graph hit *its* recursion limit — a * different bound, owned by LangGraph, with a different knob. Reporting one for the other would * be the same false-category defect this taxonomy exists to remove, and `site` is what separates * the two surfaces this bound has. */ | 'interrupt_drain_guard' /** A tool threw, and the failure ended the turn. */ | 'tool_error' /** The graph suspended on an `interrupt()` and is waiting to be resumed. */ | 'suspended' /** The graph hit its recursion limit. */ | 'recursion_limit' /** The consumer stopped consuming the turn before it ended. */ | 'abandoned' /** Nothing in the taxonomy matched — recorded as such rather than guessed at. */ | 'unknown'; /** * Where the classification was made, as a stable identifier per termination site. * * The site is a distinct fact from the category: several sites classify into the same category (the * runner's two exception wrappers both report whatever the classifier says), and several categories * can be reported from one site (an aborted stream and a suspended graph leave `streamWithEvents` * at the same place). Diagnosis needs both. */ export type GthTerminationSite = /** `GthAgentRunner.processMessages` returned an answer. */ 'runner.completed' /** The streamed turn was empty and the non-streaming fallback was empty too. */ | 'runner.empty-after-fallback' /** * [[EXT-203]] — the streamed turn was empty and the non-streaming fallback was NOT spent, * because the thread already ends with the model's own turn and re-sending it would call the * provider on a history ending with a model message. */ | 'runner.empty-stream' /** The non-streaming turn produced no content. */ | 'runner.empty-invoke' /** An approvals stop re-thrown out of the stream drain. */ | 'runner.stream-approval-stop' /** An approvals stop re-thrown out of the turn. */ | 'runner.turn-approval-stop' /** The stream drain threw. */ | 'runner.stream-error' /** The turn threw. */ | 'runner.turn-error' /** * [[EXT-160]] — the turn overflowed the context and compaction could not make it smaller: there * was nothing left worth folding, the summary could not be produced, or the agent exposes no * conversation state to rewrite. Distinct from the two wrappers above because the category they * would report is the same and the *remedy* is not: this one says the automatic remedy was tried * and had nothing left to give, so the next step is a human's. */ | 'runner.overflow-compact' /** * [[EXT-160]] — the turn overflowed AGAIN on the retry that followed a successful compaction. * One retry is the whole budget: a second overflow after the conversation was already folded is * evidence that the prompt is too large for reasons compaction cannot reach (one enormous tool * result, a window smaller than the system prompt), and a second compaction would fold the tail * it just kept and try the same thing again. */ | 'runner.overflow-compact-exhausted' /** The string path's tool-approval drain ran out of resume rounds. */ | 'runner.interrupt-guard-exhausted' /** `GthAgentRunner.processMessagesWithEvents` drained its stream to the end. */ | 'runner.events-completed' /** The typed-event turn ended having yielded no answer text at all. */ | 'runner.events-empty' /** The typed-event turn ended because its signal was aborted. */ | 'runner.events-cancelled' /** The typed-event turn threw. */ | 'runner.events-error' /** The consumer stopped consuming the typed-event turn before it ended. */ | 'runner.events-abandoned' /** The typed-event path's tool-approval drain ran out of resume rounds. */ | 'runner.events-interrupt-guard-exhausted' /** The metadata reader fired on the non-streaming `invoke` path. */ | 'agent.invoke-stop-metadata' /** The metadata reader fired on the string-streaming path. */ | 'agent.stream-stop-metadata' /** The metadata reader fired on the typed-event path. */ | 'agent.events-stop-metadata' /** A `ToolException` was turned into the turn's answer on the `invoke` path. */ | 'agent.invoke-tool-exception' /** * [[EXT-184]] — the non-streaming `invoke` path ended on Esc / an abort. * * Its own site rather than sharing `agent.stream-cancelled`, because the two arms of the runner's * `streamOutput` branch are exactly what a reader of this reason needs told apart: they cancel * through different machinery and leave the turn holding different things (partial content on one * arm, a notice on the other). */ | 'agent.invoke-cancelled' /** The string-streaming path ended on Esc / an abort. */ | 'agent.stream-cancelled' /** `streamWithEvents` ended on a suspend or an abort. */ | 'agent.events-ended' /** `streamWithEventsResume` ended on a suspend or an abort. */ | 'agent.events-resume-ended' /** The tool-error budget's `jumpTo: 'end'`. */ | 'middleware.tool-error-budget' /** The tool-loop guard's `jumpTo: 'end'`. */ | 'middleware.tool-loop-guard' /** * A model call carrying an injected binary attachment was rejected by the provider, and the * binary-content-injection middleware added an explanation to the error's text. * * The site exists because that middleware is a WRITER of the text this module's classifier * reads. The note carries a **filename**, which is user data nobody there controls, so the * middleware classifies the failure and commits the value BEFORE it touches the message: it is * its own classifier's caller, and reading `holiday-timeout.pdf` back out of a note it had * already written would answer `timeout` for a rejection. Committing first is what keeps that * order honest; {@link classifyThrownTermination} then declines to read the message at all for * anyone downstream. `attachTerminationReason` is first-write-wins, so an inner site that * already classified the same failure still keeps it. */ | 'middleware.binary-attachment-rejected' /** * [[EXT-204]] — the cut-stream retry middleware: the model's reply was cut off before it finished, * it was retried once inside the same model call, and the retry was cut off as well. */ | 'middleware.stream-cut-retry'; /** * Which feeder produced the classification. * * `metadata` — read off a message's stop/finish reason. `exception` — classified from a thrown * error. `control` — the runtime itself decided to end the run (a gate, a middleware, a * cancellation, an ordinary completion), so there was nothing to classify. */ export type GthTerminationSource = 'metadata' | 'exception' | 'control'; /** * What would have to change before a retry is worth making. * * Named rather than implied, because {@link GthTerminationReason#retryableAfterRemedy} is only * actionable if a consumer knows *which* remedy it is being told about. */ export type GthTerminationRemedy = /** Send less: compact the history, drop context, summarise. */ 'reduce-context' /** Wait, then send the same thing again. */ | 'back-off' /** Send something different — rephrase, narrow, change approach. */ | 'change-request' /** Send it to a different model. */ | 'change-model' /** Repair credentials or configuration first. */ | 'fix-credentials' /** Nothing is wrong: the run is parked and can be continued where it stopped. */ | 'resume'; /** * The retry posture of a category — the two facts, plus the remedy the second one refers to. * * **Posture is per-CATEGORY, so anything acting on a reason must read its `site` as well.** One * table decides the posture precisely so no site can invent its own, and the price of that is that * the table cannot know how far a given site already got. The standing example is * `empty_response`, which is `retryableAsIs: true` because an empty turn usually is worth asking * again — yet `runner.empty-after-fallback` is reached only *because* that as-is retry was already * spent, while `runner.events-empty` is reached with it never spent at all. A consumer reading the * posture flags alone would retry the first of those a second time. */ export interface GthTerminationPosture { /** Is sending the identical request again a sane thing to do? */ retryableAsIs: boolean; /** Is sending it again worthwhile once {@link remedy} has been applied? */ retryableAfterRemedy: boolean; /** The change that makes {@link retryableAfterRemedy} true; absent when it is `false`. */ remedy?: GthTerminationRemedy; } /** The retry posture of a category. */ export declare function terminationPosture(category: GthTerminationCategory): GthTerminationPosture; /** Why a run ended, as one value. */ export interface GthTerminationReason extends GthTerminationPosture { /** The taxonomy member. */ category: GthTerminationCategory; /** The site that classified it. */ site: GthTerminationSite; /** Which feeder classified it. */ source: GthTerminationSource; /** Provider family, where the classification knew one. */ provider?: string; /** * The raw token the classification was made from — a `finish_reason`, a `stop_reason`, an error * name or status. Diagnostic detail, never the carrier of the classification itself. */ detail?: string; } /** * [[EXT-159]] — one observation of what the provider said about why a model message stopped. * * Recorded per finished model message, on every path, whether or not the provider said anything. * No `finish_reason` was written to any log anywhere before this, so the artifact a maintainer * reaches for first could not answer the question it exists to answer — and a turn where the * provider stayed silent looked exactly like one that ended normally. * * **The absence is the observation.** `token: null` states that the message carried no stop or * finish reason at all; it is never a stand-in for one, and no observation is invented for a * message that was never seen. */ export interface GthFinishReasonObservation { /** When the message was observed, as an ISO instant with a zone. */ at: string; /** Which of the agent's three paths produced the message. */ path: 'invoke' | 'stream' | 'events'; /** The provider's raw token, lower-cased — or `null` when the message carried none. */ token: string | null; } /** The classification a feeder produces, before a site is attached to it. */ export interface GthTerminationClassification { category: GthTerminationCategory; provider?: string; detail?: string; } /** * Build a {@link GthTerminationReason}: attach a site and a feeder to a classification and fill in * the posture from the one table. Every site builds through here, so no site can invent a posture. */ export declare function terminationReason(site: GthTerminationSite, source: GthTerminationSource, classification: GthTerminationCategory | GthTerminationClassification): GthTerminationReason; /** * Whether a thrown value is a context overflow. * * The predicate is `ContextOverflowError.isInstance`, never `lc_error_code`: the code is set * asymmetrically across providers (Anthropic stamps both the class and the code, OpenAI only the * class), so keying on it silently misses most of where the typed class actually works. The * substring fallback then covers the providers LangChain does not type at all, and the case where * a reworded provider message drops the class. */ export declare function isContextOverflow(error: unknown): boolean; /** * Which context-overflow arms `error`'s text actually matches, in list order. * * `isContextOverflow` answers yes or no. That is not enough for a caller whose job is to find out * whether one *particular* arm is still doing its work, because several arms reach the same answer * and two of them reach it from opposite causes: a groq context overflow is carried by the prose * arm `reduce the length of the messages`, while a groq TPM rate rejection is carried by the * adjacent `request too large`. A check that reads only the resulting category therefore passes for * the wrong reason at exactly the moment the first arm stops matching. Naming the arms is what * separates them. * * Read-only, and no classification consults it — it reports what the same pattern list and the same * text reader already decide. The reason it lives here rather than in the caller is that a caller * outside this module cannot reproduce the answer without copying the list, and a copy is precisely * what would keep such a check green after the production arm had been deleted. */ export declare function contextOverflowPatternsMatching(error: unknown): readonly string[]; /** * The exception feeder: classify a thrown value into the taxonomy. * * **[[CFG-73]] A committed reason outranks everything else, prose included.** When * {@link terminationReasonOf} answers for the thrown value, that answer is returned and no text is * read at all. The site that attached it watched the failure happen, and this module's whole * premise is that the message is not the carrier — so a feeder that reads the message anyway * exempts itself from the rule it states for every consumer. * * The exemption was user-visible. A message is the one part of an error that untrusted data reaches: * the binary-attachment middleware interpolates a **filename** into a provider rejection so the user * can see which file was refused, and `api-timeout-investigation.pdf` or `contract - terminated.pdf` * then read back out of the prose as a timeout or a dropped connection. Both are retryable postures, * so the user is told to send again a request the provider will refuse identically every time, and a * name matching an overflow pattern sends the runner off to compact the history first. Sanitising * the text against the pattern lists would only enumerate today's patterns and go stale the next * time one grows; preferring the committed value cannot go stale, because it does not depend on what * the text says. * * **Everything below is the fallback for a value nobody classified**, and it is unchanged. Order * still matters there. The typed and named cases are decided first, because a context overflow is * also an HTTP 400 and an abort is also a `DOMException`; only once those are excluded does the * status code and then the prose get a say. * * **The prose arms stay ahead of the invalid-request arm, and `status === 400` stays behind them.** * 400 is a bucket rather than a cause — providers put a bad API key, an expired grant and an * exhausted quota in it — so the specific arms are what recover the cause, and each of the * categories they recover names a remedy `invalid_request` has none of. Lifting the status above * them would trade `auth_failed` and its `fix-credentials` for a category that offers the user * nothing, and it would not close the hazard either: any permutation of a substring matcher leaves * some hostile substring that flips some category. Untrusted text reaching the matcher at all is the * defect, and the committed reason is what closes it. * * Never throws: an unclassifiable value is `unknown`, which is a recorded fact rather than a guess. */ export declare function classifyThrownTermination(error: unknown): GthTerminationClassification; /** * Attach a reason to a thrown value and return it, so a `throw` site reads as one expression. * * Non-enumerable, so the reason never widens what an error serialises to (a logged or * JSON-stringified error keeps exactly the shape it had), and first-write-wins so a re-throw * through an outer wrapper cannot overwrite the inner, truer classification. Fail-soft: a frozen or * primitive throw value is returned unchanged rather than turning a failure into a different one. */ export declare function attachTerminationReason(error: T, reason: GthTerminationReason): T; /** * [[EXT-160]] — **replace** the reason attached to a thrown value. * * {@link attachTerminationReason} is first-write-wins, which is right for the nested wrappers it * was built for: the inner site saw the failure first and is the truer classification. This is the * one case that legitimately overwrites, and it is not an exception to that rule but an instance of * it — the compact-and-retry seam has seen the overflow happen TWICE, and the wrapper that * classified the second one only ever saw one of them. Without this the runner's own field and the * reason riding on the error would disagree about the same failure, which is precisely what * `classifyThrownAt` exists to prevent. */ export declare function replaceTerminationReason(error: T, reason: GthTerminationReason): T; /** The reason attached to a thrown value, following one `cause` link. Undefined when none is. */ export declare function terminationReasonOf(error: unknown): GthTerminationReason | undefined;