Skip to content

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 — 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.

ToolLoopGuardOptions = {}

(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).

AgentMiddleware<undefined, undefined, unknown, readonly (ClientTool | ServerTool)[], readonly []>