createToolLoopGuardMiddleware
createToolLoopGuardMiddleware(
options?,onWarn?):AgentMiddleware<undefined,undefined,unknown, readonly (ClientTool|ServerTool)[], readonly []>
Defined in: _worktrees/docs-release/gaunt-sloth/packages/core/src/core/GthLangChainAgent.ts:238
EXT-36 — the tool-loop guardrail as a standalone, testable middleware factory. The ORTHOGONAL
sibling of 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 onWarn
and returns
undefined— zerostate.messagesmutation, 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-reachesthresholdand surfaces again — no marker/additional_kwargsmachinery 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.
Parameters
Section titled “Parameters”options?
Section titled “options?”ToolLoopGuardOptions = {}
onWarn?
Section titled “onWarn?”(message) => void
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).
Returns
Section titled “Returns”AgentMiddleware<undefined, undefined, unknown, readonly (ClientTool | ServerTool)[], readonly []>