import { GthConfig } from '#src/config.js'; import { GthAgentInitOptions, GthCommand } from '#src/core/types.js'; import { GthAbstractAgent } from '#src/core/GthAbstractAgent.js'; import { type GthTerminationReason } from '#src/core/terminationReason.js'; import { type CompactMessagesResult } from '#src/core/compaction.js'; import { type ContextWindowSource } from '#src/core/contextWindow.js'; import { type BaseMessage } from '@langchain/core/messages'; import { BaseCheckpointSaver } from '@langchain/langgraph'; export type { AgentStreamEvent } from '#src/core/types.js'; /** * GS2-36 — default cap for the tool-error retry budget: how many status:'error' tool results may * accrue back-to-back (no successful tool result in between) before the run is ended gracefully. * Small on purpose: it still lets the model try a couple of genuine recovery variants (the whole * point of feeding errors back — GS2-32 showed the model routes around a surfaced error in 1–2 * tries) while stopping a runaway self-inflicted loop long before createAgent's coarse * recursionLimit backstop would. */ export declare const MAX_CONSECUTIVE_TOOL_ERRORS = 5; /** * GS2-36 — the tool-error retry budget as a standalone, testable middleware factory (exported so the * real thing can be unit-tested and exercised in a real `createAgent` graph, mirroring * `createPathNamespaceCorrectionMiddleware`). * * Runs in `beforeModel` (like langchain's own `modelCallLimitMiddleware`): after the tools node has * appended its result(s) and before the next model call is spent, it walks the trailing messages and * counts CONSECUTIVE errored tool results — a `ToolMessage` with `status: 'error'` (the shape the * shell/MCP softeners produce; GthAbstractAgent maps `status==='error' → isError`). The walk skips * the assistant tool-call requests between rounds, and RESETS on the first successful tool result * (progress / diagnosis) or a Human/System message (a fresh user turn). Once the count reaches the * cap it returns `{ jumpTo: 'end', messages: [] }`, ending the run without * spending another model call. * * Scope: counts `status: 'error'` results only. The recoverable fs error STRINGS * (`write_file`/`edit_file`/…) are `status: 'success'` by the write_file precedent, so a pure fs * error loop is deliberately NOT capped here — it stays bounded by the coarse `recursionLimit` and is * the remit of the loop-DETECTION node (EXT-36). Counting `status: 'error'` overall (not per-tool) * catches both same-tool and alternating-tool error loops with one robust rule. */ export declare function createToolErrorBudgetMiddleware(maxConsecutiveErrors?: number, onHalt?: (_reason: GthTerminationReason) => void): import("langchain").AgentMiddleware; /** * EXT-36 — default number of consecutive identical `(tool, args)` calls before the tool-loop guard * fires. Small on purpose: it must catch a genuine no-progress loop (a model re-issuing the SAME * call verbatim) while never tripping a legitimate one-off retry (2x). Kept below GS2-36's coarser * error cap (5) because a same-signature repeat is a stronger, more specific loop signal than "an * error happened again". */ export declare const DEFAULT_TOOL_LOOP_THRESHOLD = 3; /** EXT-36 — resolved knobs for {@link createToolLoopGuardMiddleware}. */ export interface ToolLoopGuardOptions { /** Surface a user-visible notice at threshold (NO model-input mutation). Default ON. */ warn?: boolean; /** End the run via `jumpTo:'end'` at threshold (opt-in, active loop-breaking). Default OFF. */ halt?: boolean; /** Consecutive identical-signature repeats that trip the guard. Default {@link DEFAULT_TOOL_LOOP_THRESHOLD}. */ threshold?: number; } /** * EXT-36 — normalise the `toolLoopGuard` config union (`false | true | { warn?, halt?, threshold? }`) * into concrete {@link ToolLoopGuardOptions}, applying the WARN-ON-by-default policy at the read site * (mirrors how `debugDump.redact` defaults with `!== false`, NOT in DEFAULT_CONFIG, so the * effective-config snapshot never churns). * - `false` → both modes off (a no-op guard); * - `true` / absent → warn on, halt off, default threshold; * - object → per-field, with warn defaulting ON and halt defaulting OFF. */ export declare function resolveToolLoopGuardOptions(setting: boolean | ToolLoopGuardOptions | undefined | null): ToolLoopGuardOptions; /** * The delimiter between a tool name and its serialised arguments in a call signature. U+0000 is * chosen because a tool name cannot contain a control character, so no `(name, args)` pair can be * spelled two ways and no pair of distinct calls can collide on one signature. * * It MUST stay written as this escape. A raw NUL byte in the source makes the whole file binary to * ripgrep and ugrep, which then skip it in silence — every symbol in this file becomes invisible to * a repo-wide search, and an empty result reads as proof of absence. `noRawControlBytes.spec.ts` guards * the repo against the raw form returning. */ export declare const TOOL_CALL_SIGNATURE_DELIMITER = "\0"; /** * EXT-36 — the `(tool, args)` identity a repeat-detection streak is counted over. Exported so the * delimiter invariant above is asserted directly rather than inferred from loop behaviour. */ export declare function toolCallSignature(name: string, args: unknown): string; /** * EXT-36 — the tool-loop guardrail as a standalone, testable middleware factory. The ORTHOGONAL * sibling of {@link createToolErrorBudgetMiddleware}: GS2-36 caps a consecutive-tool-ERROR streak; * this catches a **repeated identical `(tool, args)` / no-progress loop** — the same call re-issued * verbatim, whether it keeps erroring OR keeps "succeeding" with the same result (the fs-error-string * loop GS2-36's comment explicitly leaves to EXT-36). * * STATELESS (the critical trap): the factory runs ONCE per session, so a closure-held counter would * bleed across every turn. Like GS2-36 it holds NO state — each `beforeModel` recomputes the streak * from the message tail. * * Detection. A signature is `(tool_name, args_hash)`. Name + args live on `AIMessage.tool_calls[]`, * NOT on the `ToolMessage` (softener ToolMessages carry only content+tool_call_id+status), so each * `ToolMessage` is paired to its call by `tool_call_id === AIMessage.tool_calls[].id` to recover the * signature. The backward walk counts CONSECUTIVE ToolMessages with the SAME signature; a DIFFERENT * signature breaks the streak (the model tried something else = progress), and a Human/System message * is a fresh-turn boundary. Assistant messages (the tool-call requests) are skipped. Known no-op * (safe, never a false trip): a single AIMessage issuing PARALLEL tool calls yields back-to-back * differing signatures, which the walk reads as progress and resets. * * Two modes (composable): * - WARN (default ON, provably harmless): the default path must NOT change what the model sees. * Appending ANY message then re-invoking the model mutates its input — a *steer*, not a *warn* — * and is provider-unsafe by default (Gemini expects a trailing user turn → crash risk; Anthropic * treats a trailing assistant as PREFILL → the note silently becomes the opening of the model's own * next reply, corrupt with no error; a HumanMessage after a ToolMessage is two consecutive user * turns for Anthropic/Gemini). So WARN instead SURFACES a user-visible notice via {@link onWarn} * and returns `undefined` — zero `state.messages` mutation, zero control-flow. Fired statelessly * ONCE per streak at the exact crossing (`streak === threshold`): a still-looping streak on later * turns (`streak > threshold`) does not re-surface, while an interrupted-then-resumed loop * re-reaches `threshold` and surfaces again — no marker/`additional_kwargs` machinery needed. * - HALT (opt-in only, active loop-breaking): at/over threshold, return * `{ jumpTo: 'end', messages: [new AIMessage(reason)] }` — a TERMINAL notice (the model is never * re-invoked after it, so the prefill/role hazard cannot arise; the GS2-72-proven clean-stream * path). NEVER throws. A validated behaviour change lives behind this opt-in. * * @param onWarn TUI-safe user-notice sink for WARN, wired at the read site to * `statusUpdate(StatusLevel.WARNING, …)` (routed through the agent's status callback the renderer * consumes, never raw stdout, so it can't leak over the Ink frame). Omitted → WARN still runs but * surfaces nothing (still zero model-input mutation). */ export declare function createToolLoopGuardMiddleware(options?: ToolLoopGuardOptions, onWarn?: (message: string) => void, onHalt?: (_reason: GthTerminationReason) => void): import("langchain").AgentMiddleware; /** * EXT-160 — how many characters of prompt one token is assumed to carry. * * 3.5 rather than the usual English rule of thumb of 4, because the prompt this guard measures is * not English: it is a system prompt full of tool schemas, JSON tool arguments and file paths, which * tokenise far worse than prose. Erring low makes the estimate HIGH, which is the safe direction — * an early compaction costs one summary call, a late one costs a silently truncated conversation. */ export declare const ESTIMATE_CHARS_PER_TOKEN = 3.5; /** * EXT-160 — the multiplier applied to the whole estimate, on top of the pessimistic ratio above. * * The estimate is anchored on a real token count and extrapolates only the delta, so 10% covers the * part that is genuinely guessed: the tokens of messages added since the anchor, plus whatever the * provider adds per message that no character count can see (role headers, tool-call framing). */ export declare const ESTIMATE_SAFETY_MARGIN = 1.1; /** * [[EXT-189]] — the share of the prompt a provider must claim to have read before its own number is * believed. Below this, the reported count is treated as evidence the prompt was silently cut down. * * **0.5 is chosen against a measured gap, not picked round.** The two populations the predicate has * to separate were measured live on 2026-09-17 with one identical 35,991-character payload, which at * {@link ESTIMATE_CHARS_PER_TOKEN} reads as a floor estimate of 10,283 tokens: * * | endpoint | window | reported input tokens | share of the estimate | * |---|---|---|---| * | `openai/gpt-3.5-turbo-0613` via OpenRouter | 4,095 | 2,363 | 0.23 | * | `undi95/remm-slerp-l2-13b` via OpenRouter | 6,144 | 3,571 | 0.35 | * | `gryphe/mythomax-l2-13b` via OpenRouter | 8,192 | 4,749 | 0.46 | * | `sao10k/l3-lunaris-8b` via OpenRouter | 8,192 | 4,777 | 0.46 | * | `deepseek/deepseek-r1-distill-llama-70b` via OpenRouter | 8,192 | 4,865 | 0.47 | * | `google/gemma-2-27b-it` via OpenRouter | 8,192 | 6,508 | 0.63 | * | **`openai/gpt-4o-mini` via OpenRouter — NOT truncated** | 128,000 | **21,912** | **2.13** | * * The honest call reports **more** than the estimate rather than less, because that payload tokenises * at about 1.6 characters per token against the 3.5 this module assumes — the worst case available, * and it errs in the direction that keeps the predicate quiet. So the separation is between 0.63 and * 2.13, and 0.5 sits inside it with a 4.3× margin on the honest side. * * **What it costs to be wrong, in each direction.** A false positive discards a real anchor and falls * back to extrapolating the whole prompt from characters, which reads HIGH and at worst buys one * early compaction — the direction {@link ESTIMATE_CHARS_PER_TOKEN} already argues is the safe one. A * false negative leaves the estimate anchored on a number the provider computed after discarding most * of the prompt, which is the failure this exists to stop. To reach a false positive real content * would have to tokenise at 7 characters per token sustained across a whole prompt; prose runs near * 4, code and tool JSON near 3, and base64 near 1.3. Long runs of padding or repeated whitespace are * the shape that could, and they pay the cheap side of the trade. * * **Deliberately NOT caught: the 0.63 row.** `google/gemma-2-27b-it` discarded most of the same * prompt and is not detected, because its tokeniser puts the arrived count closer to our estimate. * Raising the threshold to catch it would spend the honest-side margin on one row; it is recorded * here as known rather than fixed. */ export declare const SILENT_TRUNCATION_REPORTED_FRACTION = 0.5; /** * [[EXT-189]] — the smallest estimate {@link promptTokensLookTruncated} will judge at all. * * A ratio computed over a short prompt is noise: the per-message framing a character count cannot * see (role headers, tool-call scaffolding) is a large share of a small prompt and a rounding error * in a big one, so a two-message conversation can legitimately report half what was estimated. Below * this floor the predicate says nothing rather than guessing, and nothing is lost by the silence — * a prompt this small cannot be overflowing any real window, which is the only situation a provider * would have cause to cut it down in. */ export declare const SILENT_TRUNCATION_MIN_ESTIMATE_TOKENS = 4000; /** * [[EXT-189]] — **did the provider quietly answer from less than it was given?** * * `estimated` is what we believe we sent; `reported` is the provider's own * `usage_metadata.input_tokens` for that same call. A provider that will not accept an oversized * prompt says so with an error, and one that accepts it reports roughly what arrived — so a **200 * whose usage envelope is far below the prompt** is the signature of a prompt that was cut down in * transit and answered anyway. * * Measured on OpenRouter 2026-09-17: a 21,912-token prompt to a 4,095-window endpoint came back * `200` reporting 2,363 input tokens, with the answer computed from the 11% that survived. The * generation record shows a single upstream response at status 200 carrying that same 2,363, so the * prompt was reduced **before** it was forwarded; OpenRouter's documented opt-out (`transforms: []`) * was sent on an otherwise byte-identical request and changed nothing. Six of six OpenRouter * endpoints at or below 8k did this; the same model on the same upstream through a different * aggregator, and every direct provider probed, returned a 400 instead. * * **The blind spot is worth knowing before trusting this.** It can only see a provider that reports * what it actually read. One that truncates internally and still reports the full count is invisible * here, and no post-hoc check of the usage envelope can see it — that case needs the pre-call guard, * which is why this predicate protects the guard's input rather than replacing it. */ export declare function promptTokensLookTruncated(estimated: number, reported: number): boolean; /** * EXT-160 — tokens reserved for the model's own answer when nothing else says. * * On ollama `num_ctx` covers the prompt **and** the generation from one budget, so a prompt that * "fits" with nothing left over does not fail — it produces a truncated answer, which is the second * failure of the spike's three wearing the first's clothes. Reserving the room is what keeps a * guard that is about input overflow from shipping output truncation instead. */ export declare const DEFAULT_OUTPUT_RESERVE_TOKENS = 2048; /** * EXT-160 — the most any ONE non-text content block may add to the estimate, in tokens. * * **This is an approximation and does not model any provider's image tokenisation.** It cannot: the * real number depends on the provider, the model, and how the image is tiled, and none of that is * knowable from the block. What it does instead is bound the damage in both directions. * * The bound is needed because a block's ENCODED LENGTH is not a proxy for its size at all. A 100 KB * inline image is roughly 136 500 base64 characters, which at the ratio above is about 43 000 * tokens, while providers charge on the order of a thousand for it — an over-count near 33×, larger * than any window this project wires. Charging that would fold the conversation on every single * turn, which is the same thrashing the reserve clamp exists to prevent, arriving from the other * side. * * 1600 sits at the top of the range providers charge for a large image, so it **errs high** — the * same direction as every other approximation here, and the direction the node prefers, because an * early compaction costs one summary call while a late one costs a silently truncated conversation. * It errs high by a bounded factor rather than an unbounded one, which is the whole point. * * It is a CAP, not a flat charge: a block shorter than this is counted at its real length, so a * small structured tool result stays accurate instead of being inflated to an image's cost. */ export declare const NON_TEXT_BLOCK_TOKEN_ALLOWANCE = 1600; /** * EXT-160 — {@link NON_TEXT_BLOCK_TOKEN_ALLOWANCE} in the characters the estimator actually sums, * so the cap survives the division by {@link ESTIMATE_CHARS_PER_TOKEN} that follows. */ export declare const NON_TEXT_BLOCK_CHARACTER_ALLOWANCE: number; /** * EXT-160 — a residual this small is a block's structural keys, not its payload. * * After a block's `text` is removed (already counted elsewhere) an ordinary text block leaves only * its discriminator — `{"type":"text"}` is fifteen characters. Charging that would add noise * proportional to the NUMBER of blocks rather than to what they carry, and the system prompt alone * is an array of them. Anything above this floor is treated as payload and counted. * * This is the one approximation here that errs LOW, and only ever by this many characters per * block, because a residual under the floor cannot be a payload worth compacting for. */ export declare const BLOCK_STRUCTURE_FLOOR_CHARACTERS = 64; /** * EXT-160 — the largest fraction of the window the **default** reserve is allowed to claim. * * A fixed reserve is meaningless against a window near its own size: measured live on a * deliberately small `num_ctx` of 2048, the flat 2048-token reserve made `estimate + reserve > * window` true for every call whatever the conversation held, so the guard folded on every turn and * spent a summary call each time. The clamp keeps the reserve proportionate — a quarter of the * window is still real headroom for an answer, and it can never be the whole budget. * * This applies to the number nobody chose. A reserve the user configured is clamped by * {@link MAX_EXPLICIT_RESERVE_FRACTION_OF_WINDOW} instead, and the reason is below. */ export declare const MAX_RESERVE_FRACTION_OF_WINDOW = 0.25; /** * EXT-160 — the largest fraction of the window a **configured** reserve is allowed to claim. * * A user who sets `llm.numPredict` has named an answer budget, which the default has not, so * clamping it to a quarter of the window silently gives them less than they asked for and then * truncates the answer — the exact failure the reserve exists to prevent, reintroduced by the fix * for a different one. Measured: at `numCtx: 16384` with `numPredict: 8192`, a quarter-clamp holds * back 4096, the guard sees 16140 against 16384 and passes the turn, and the answer is then short * by 3852 tokens. * * **A configured number can still be reduced, and this is where.** Above half the window it is cut * to half, because a reserve larger than that leaves less room for the conversation than for the * answer, and at that point no conversation fits and the guard would fold on every turn — the * thrashing the default clamp exists to stop. So: honoured up to half the window, reduced beyond * it. */ export declare const MAX_EXPLICIT_RESERVE_FRACTION_OF_WINDOW = 0.5; /** * EXT-160 — the answer reserve actually applied to a given window: the requested number, clamped to * a fraction of the window that depends on **who chose it** (a quarter for the default, half for a * number the user configured — see the two constants above). * * EXT-161 pulled it out of the hook because the preventive threshold's default is defined as * `window − this`, and {@link AutocompactController} has to be able to compute the same number the * guard would. Two copies of this arithmetic is exactly how `/status` would come to advertise a * threshold the guard does not enforce. */ export declare function effectiveOutputReserve(window: number, reserve: number, reserveWasConfigured: boolean): number; /** * EXT-161 — the prompt size at which a conversation is folded when the user named no threshold: * the window, less the room held back for the answer. * * This is EXT-160's original trigger condition rearranged, not a new one — `estimate + reserve > * window` and `estimate > window − reserve` are the same inequality — so the default path behaves * exactly as it did before a threshold could be configured. */ export declare function defaultCompactionThreshold(window: number, reserve: number, reserveWasConfigured: boolean): number; /** EXT-160 — what {@link createContextGuardMiddleware} needs to decide, all of it injectable. */ export interface ContextGuardOptions { /** * The window to guard against, in tokens. **`null` is a first-class answer meaning "unknown", and * an unknown window never triggers a compaction** — see `contextWindow.ts`. */ windowSource: ContextWindowSource; /** * Compact the conversation. Returns the unchanged list with `changed: false` when there is * nothing worth folding, which is what stops this guard looping (see the hook). */ compact: (_messages: BaseMessage[]) => Promise; /** * Tokens held back for the answer; {@link DEFAULT_OUTPUT_RESERVE_TOKENS} when omitted. * * Whether this was set matters, not just its value: an omitted reserve is clamped by * {@link MAX_RESERVE_FRACTION_OF_WINDOW}, a configured one by the more generous * {@link MAX_EXPLICIT_RESERVE_FRACTION_OF_WINDOW}, because a number the user chose says something * the default does not. Passing the default value explicitly therefore takes the configured * path — which is what a caller forwarding `llm.numPredict` wants, and what a test pinning the * default must avoid. */ reserve?: number; /** * The static system prompt's characters, read at call time. `createAgent` applies the system * prompt on every call without ever putting it in `state.messages`, so a guard that measured only * the state would under-count by the largest single block in the request. A function rather than * a number because the graph's middleware is assembled before the prompt it will be built with. */ systemPromptCharacters?: () => number; /** Estimator override; {@link estimatePromptTokens} when omitted. Injected by the tests. */ estimateTokens?: (_messages: readonly BaseMessage[], _systemPromptCharacters: number) => number; /** User-visible notice sink, wired to the agent's status channel. */ onCompact?: (_message: string) => void; /** * EXT-161 — **the prompt size at which to fold, asked fresh on every call.** * * Supplied by {@link AutocompactController}, which resolves the session override, then the * `autocompact` config key, then the derived default. `null` means nothing fires — an unknown * window with no absolute threshold, or the off switch. * * A FUNCTION and not a number, for the same reason `systemPromptCharacters` is: the factory runs * once per session and the hook holds no state, so a captured threshold could never be moved by * an `/autocompact` typed later in the session. * * Omitted, the guard computes {@link defaultCompactionThreshold} itself, which is EXT-160's * original behaviour and what every caller that does not configure compaction still gets. */ thresholdSource?: () => Promise; } export declare function estimatePromptTokens(messages: readonly BaseMessage[], systemPromptCharacters?: number): number; /** * EXT-160 — **the pre-call context guard: compact BEFORE the request, never after the damage.** * * Why a `beforeModel` middleware and not the runner's turn funnel: every model call inside a tool * loop grows the context, and the runner only ever sees the turn boundary — so a turn that fits * when it starts and overflows on its fourth tool round is invisible there. A `beforeModel` hook * sees `state.messages` on every call, which is the only place this can be caught. * * **Pre-call means pre-call, and that is the whole point.** Ollama does not raise on overflow: the * daemon silently drops the oldest tokens to fit `num_ctx` and answers happily from what is left, * so there is no error, no stop reason, and nothing on the wire that differs from a healthy turn. * By the time a response exists the evidence is gone — a check that ran after it could only ever * confirm damage already done, and the damage is not a missing answer but a plausible one built on * a conversation whose head was quietly discarded. * * **The loop hazard, and the bail that closes it.** The factory runs once per session, so like its * two siblings above this hook holds NO state and recomputes everything from `state.messages`. That * means a compaction which folds nothing would leave the state identical, the estimate identical, * and this hook firing again on the next entry — forever. So `changed: false` **lets the call * proceed**: a conversation already at its irreducible minimum is sent as it is and allowed to fail * honestly (reactively compacted or reported), which is strictly better than a hang. * * The `compact` closure must call the model DIRECTLY, never through the graph, or the summariser's * own call re-enters this hook. */ export declare function createContextGuardMiddleware(options: ContextGuardOptions): import("langchain").AgentMiddleware; /** * Lean agent: builds a standard `createAgent` (ReAct) graph. All run/stream/event * plumbing lives in {@link GthAbstractAgent}; this class only knows how to construct * the graph in {@link init}. */ export declare class GthLangChainAgent extends GthAbstractAgent { init(command: GthCommand | undefined, configIn: GthConfig, checkpointer?: BaseCheckpointSaver | undefined, options?: GthAgentInitOptions): Promise; /** * BATCH-32 — build the advertised-tool inventory from the pre-allow-list list and the list that * survived it, for {@link GthAbstractAgent.recordAdvertisedTools}. * * `advertised` is every tool loaded; `retained` is what the `allowedTools` filter left (the same * array when no allow-list is configured, in which case nothing is filtered out). The difference * is computed by NAME rather than by object identity: the filter preserves identity today, but a * coverage report keyed on names must not silently start reporting everything as filtered-out if * some future step maps or wraps a tool on its way through. * * ## Attribution is resolved against the CONFIGURED server keys, never by splitting the name * * An MCP tool is registered as `mcp____`, but a configured `mcpServers` key may * itself contain the separator, so splitting on it attributes such a tool to a shorter key that * also happens to be configured — one server's tool reported under another server's name. This * delegates to `approvalSubjectForToolName`, which scans the configured keys instead and yields * `UNRESOLVED_MCP_SERVER` where zero or two of them explain a name. That is the same resolution * the approvals gate uses, so a per-server coverage breakdown and a per-server trust decision can * never disagree about which server a tool belongs to. * * ## Nameless tools are counted, not listed * * A nameless ServerTool has no name to put in either half of the fraction (see * {@link GthAdvertisedTools.unnamed}), so it is tallied and excluded from `tools`. Duplicate * names collapse to one entry: the model sees one tool per name, and counting a name twice in * the denominator would make full coverage arithmetically unreachable. */ private buildAdvertisedTools; }