Failure recovery

Nudge the agent toward a debug tool when the recent transcript looks like a retry loop or the turn has stacked up failures.

After two or three back-to-back failures the agent often spirals. It retries the same approach, restates the same error, and never reaches for a debug tool. You want a Stop-event nudge that fires only when the recent narration reads like a retry loop, plus a separate per-failure nudge that points at the project’s debugging playbook. Both silence themselves once the agent has already done the thing you’d suggest.

"""Break the agent out of a retry loop after repeated failures."""

from __future__ import annotations

import re

from captain_hook import Allow, Event, Input, ReadFile, Signal, Signals, T, UsedSkill, Warn, nudge

RETRY_SIGNALS = Signals(
    patterns=[
        Signal(pattern=r"let me try again", weight=2, flags=re.IGNORECASE),
        Signal(pattern=r"one more attempt", weight=2, flags=re.IGNORECASE),
        Signal(pattern=r"same (error|failure|issue)", weight=2, flags=re.IGNORECASE),
        Signal(pattern=r"\bretrying\b", weight=1, flags=re.IGNORECASE),
    ],
    threshold=4,
    window=10,
    scope="window",
)


nudge(
    "Repeated failures detected. Stop retrying and pick a debug tool:\n"
    "  - run `/codex` for a second opinion on the failing approach\n"
    "  - open the trace in your observability tool to inspect the failing span\n"
    "  - isolate the minimum failing case before changing more code",
    signals=RETRY_SIGNALS,
    events=Event.Stop,
    skip_if=[UsedSkill("codex", scope="session"), ReadFile("DEBUGGING.md")],
    max_fires=1,
    tests={
        Input(transcript=[T.assistant("Same error again. Let me try again.")]): Warn(pattern="debug tool"),
        Input(transcript=[T.assistant("All checks pass; wrapping up.")]): Allow(),
    },
)


nudge(
    "Three tool failures this turn without a debug skill. Read DEBUGGING.md before the next attempt.",
    events=Event.PostToolUseFailure,
    when=lambda evt: evt.ctx.turn.count_failures() >= 3,
    skip_if=[ReadFile("DEBUGGING.md")],
    max_fires=2,
    tests={
        Input(
            transcript=[
                line
                for _ in range(3)
                for line in T.tool_turn("Bash", result="ModuleNotFoundError", is_error=True, command="uv run pytest")
            ]
        ): Warn(pattern="DEBUGGING.md"),
        Input(
            transcript=T.tool_turn("Bash", result="ModuleNotFoundError", is_error=True, command="uv run pytest")
        ): Allow(),
    },
)

This page composes two nudge primitives: a signals-driven Stop nudge that scores the agent’s narration, and a PostToolUseFailure nudge gated on the turn’s failure count. The retry bundle sets scope="window" because its tells span attempts — one retry phrase per failed try pools toward the threshold, where the default per-text scope needs a single message to reach it alone.

What it catches

Same error again. Let me try again.   # narration hits two weight-2 retry phrases -> reaches threshold=4
3 failed Bash tool calls in one turn   # count_failures() >= 3 with no debug skill read -> points at DEBUGGING.md

What it allows

All checks pass; wrapping up.          # no retry-loop narration -> Stop nudge stays quiet
2 failed tool calls in one turn        # below the 3-failure gate -> no DEBUGGING.md nudge

The block / allow split mirrors the hook inline tests, so it stays true as the hook evolves.

Run it yourself

uvx capt-hook --hooks docs/examples test

See also