/** * Stop/finish-reason detection for the agent run loop — the **metadata feeder** of the * [[EXT-159]] termination taxonomy. * * This module is the one reader of a message's stop/finish reason. It normalizes the per-provider * spellings into two shapes: {@link detectRefusal}, whose {@link RefusalInfo} also drives the * user-facing notice, and {@link detectStopMetadata}, which classifies the same metadata into the * shared taxonomy for every consumer that only needs to know *why the turn ended*. There is * deliberately no second reader of `response_metadata` beside it. * * Its counterpart is the exception classifier in `core/terminationReason.ts`: a reason that arrives * as a thrown error is not in `response_metadata` at all, so nothing here can see it. * * A *successful* model response (HTTP 200) can carry a stop/finish reason that means the model — or * the provider's safety system — declined to answer. The content is usually empty, so without this * detection the response falls through the empty-response retry in {@link GthAgentRunner} and is * mis-surfaced as "no content, try again" — burning a second, paid call to reproduce a * DETERMINISTIC refusal. This module normalizes the per-provider shapes into one signal so the run * loop can surface the refusal clearly and terminate (never retry the same prompt). * * Prior art: hermes-agent `conversation_loop.py` treats `finish_reason == "content_filter"` as a * terminal, non-retryable "content policy blocked" outcome and surfaces the model's explanation. * * Detection lives here (and is called from {@link GthAbstractAgent}, the invoke/stream loop over * messages/chunks) because that is the only layer where a message's `response_metadata` / * `additional_kwargs` — where finish/stop reasons live — are visible; `GthAgentRunner` only ever * sees the rendered string. */ import type { GthTerminationClassification } from '#src/core/terminationReason.js'; /** One detected refusal, normalized across providers. */ export interface RefusalInfo { /** Best-effort provider family the signal came from (for logging / the surfaced message). */ provider: 'openai' | 'anthropic' | 'bedrock' | 'google' | 'unknown'; /** * The stop/finish reason token that flagged the refusal, lower-cased (e.g. `content_filter`). * * Lower-cased rather than raw because the token's case is a per-provider spelling, not a fact: * Gemini shouts `SAFETY` where OpenAI writes `content_filter`, and this value is matched, logged * and carried into the taxonomy's `detail` — the same normalization * {@link detectOutputTruncation} already applies to its own token. */ reason: string; /** Any model-provided explanation text (empty string when the refusal carried none). */ explanation: string; } /** * Inspect a finished model message (an `AIMessage` / `AIMessageChunk`, or any object exposing * `response_metadata` / `additional_kwargs`) and return a {@link RefusalInfo} when its stop/finish * reason indicates a content-policy refusal, else `null`. Defensive: any non-message / unexpected * shape yields `null`, so a normal turn is never mistaken for a refusal. * * Reasons are compared lower-cased, because the case is a provider's spelling and not a fact — * Gemini shouts `SAFETY` where OpenAI writes `content_filter`. * * Covered shapes: * - OpenAI-family `finish_reason: 'content_filter'` (also under `additional_kwargs`). * - Anthropic `stop_reason: 'refusal'`. * - Bedrock Converse guardrail intervention: `stopReason`/`stop_reason`/`finish_reason` === * `'guardrail_intervened'`, or `additional_kwargs['amazon-bedrock-guardrailAction'] === * `'INTERVENED'`. * - Bedrock Converse content filter: `stopReason`/`stop_reason`/`finish_reason` === * `'content_filtered'` (EXT-41 — a distinct `StopReason` enum value from `guardrail_intervened` * that was previously mapped to `null`, i.e. a silent empty turn / false negative). * - Gemini's safety reasons ({@link GEMINI_REFUSAL_REASONS}) under **camelCase `finishReason`** * (CFG-41). That key is the half that has to come first: `@langchain/google`'s streamed chunk * puts `model_provider` and nothing else in `response_metadata` and the reason only in * `additional_kwargs.finishReason`, so a token added without the key would sit on a branch that * never ran for Gemini. */ export declare function detectRefusal(message: unknown): RefusalInfo | null; /** * [[EXT-159]] — the provider's own stop/finish reason for a finished message, or `null` when the * message carried none. * * **`null` is a recorded fact, not a missing one.** No `finish_reason` was written to any log * anywhere, so a turn that ended without the provider saying why was indistinguishable from one * that ended normally. A caller records the `null` as explicitly as it records a token. * * Built on the SAME reader {@link detectStopMetadata} uses rather than a second one beside it: a * separate scan of `response_metadata` is how two readers come to disagree about what the provider * said, and the whole point of this node is that the two halves of the classification share one * source. The first token wins, in the key order that shared reader walks, and it is lower-cased * there — this is diagnostic detail, never the carrier of a classification. */ export declare function readStopReasonToken(message: unknown): string | null; /** * Inspect a finished model message and return an output-truncation classification when its * stop/finish reason says the answer hit the output cap, else `null`. * * A truncated answer is not a refused one and not an error: the turn returns a *successful* * response that simply stops mid-thought. Nothing is surfaced from here — the classification exists * so a turn that ended this way is not indistinguishable from one the model finished. */ export declare function detectOutputTruncation(message: unknown): GthTerminationClassification | null; /** * The metadata feeder, as one call: classify a finished model message's stop/finish reason into the * [[EXT-159]] taxonomy, or `null` when it says nothing worth recording. * * `null` for an ordinary end is deliberate. Only a *terminal and interesting* reason is reported — * a refusal or a truncation — because every other end of a turn is classified by the site that * actually ends it, and a reader that reported `completed` off a mid-turn tool round would pin the * wrong reason before the real one happened. */ export declare function detectStopMetadata(message: unknown): GthTerminationClassification | null; /** * The taxonomy classification a {@link RefusalInfo} stands for. * * Exported so a call site that already holds the detected {@link RefusalInfo} — because it needs * the explanation text for the user-facing notice — records the same classification * {@link detectStopMetadata} would, rather than re-deriving the mapping beside it. */ export declare function classifyRefusal(info: RefusalInfo): GthTerminationClassification; /** * Build the clear, user-facing message shown when the model declines. Framed as the model / * provider's own policy decision (not a Gaunt Sloth fault) and stated as terminal — a refusal is * deterministic for the same input, so retrying as-is will not help. Any model-provided explanation * is included verbatim. This string is BOTH surfaced to the console and RETURNED as the turn's * answer, so the non-interactive caller writes it to the output file and exits `ok` (it is a * successful, if declined, response — not a failure to be re-wrapped as "Failed to get answer"). */ export declare function buildRefusalMessage(info: RefusalInfo): string;