Stopping agents that drift off course
Four patterns for how an agent derails mid-run, and ten steps that catch it before it works two hours in the wrong direction.
Most guides on agent quality look at the result after the run is over. Eval sets, scoring, regression comparison. All of that is right and it matters, but none of it helps you in the hour where the agent is working right now and slowly drifting away from the goal.
That is exactly where the damage happens. An agent that no longer has the original goal in context after forty steps keeps producing output diligently. It looks busy. At the end it even reports "done". Except that it spent the last thirty steps working on something nobody ordered.
Here are ten steps to catch that during the run instead of afterwards. All of it through Claude Code hooks, none of it needs an extra framework.
1. Being able to name the four patterns
Before you build anything, you need to know what you are looking for. In practice there are four patterns, and they need different countermeasures.
The loop: the agent calls the same tool with almost identical arguments over and over. Test fails, small change, test fails, small change. After twelve rounds it has not moved an inch.
The standstill: plenty of tool calls, but none of them change any state. Read, search, read again. Looks like work, is really just circling.
The lost goal: the agent found a side problem along the way and is now working on that. Often a real problem. Just not yours.
The fake done: the agent reports completion without anything having been verified. No build, no test, no look at the result. This is the most expensive pattern, because you only notice it when you check for yourself.
2. Nailing the goal down in writing before the run starts
An agent cannot be checked against a goal that only exists in your head and in the first prompt. After enough context compaction, the first prompt is no longer there word for word.
So write it out. One file, three lines, before the run starts:
cat > .claude/ziel.txt <<'EOF'
ZIEL: Login-Flow auf Magic-Link umstellen, bestehende Passwort-Logins bleiben gültig.
FERTIG WENN: npm test grün, /login manuell einmal durchgeklickt.
NICHT TEIL DER AUFGABE: Design, Rate-Limits, Passwort-Reset.
EOF
The third line is the most important one. A lost goal almost always comes through a side problem that looks plausible. If you write down in advance what explicitly does not belong, you have something to check against later.
3. Setting a step budget
Every run gets an upper limit of tool calls. Not as a cost brake, but as an emergency cord. An agent that touches a hundred and twenty tools for a manageable task does not have a tool problem, it has an understanding problem.
A PreToolUse hook keeps count. The session_id comes along as a field in every hook input, so you get a clean counter per run:
#!/usr/bin/env bash
# ~/.claude/hooks/schrittbudget.sh
input=$(cat)
sid=$(echo "$input" | jq -r '.session_id')
datei="/tmp/schritte-$sid"
n=$(( $(cat "$datei" 2>/dev/null || echo 0) + 1 ))
echo "$n" > "$datei"
if [ "$n" -gt 120 ]; then
echo "Schrittbudget 120 erreicht. Fass den Zwischenstand zusammen statt weiterzumachen." >&2
exit 2
fi
exit 0
Exit code 2 is the way a hook blocks and tells the model why at the same time. The exact effect of exit code 2 differs per event, the docs have a table of their own for that.
A hundred and twenty is a starting value, not a law of nature. Measure for two weeks what your normal runs need, then set the limit at twice the median.
4. Counting repetitions instead of guessing at them
For the loop you do not need a clever detector. It is enough to store the tool name plus a short hash of the arguments and count how often the same combination shows up.
# -S sorts the keys: two semantically identical calls must produce the
# same hash, otherwise the loop slips through.
# jq on its own first: inside a pipe its error would be swallowed and sha1sum
# would hash the EMPTY input. Every broken call would then share one
# signature and get blocked as a loop by mistake.
payload=$(echo "$input" | jq -cS '{t:.tool_name, i:.tool_input}') || exit 0
# sha1sum gibt es nicht ueberall (auf macOS heisst es shasum). Und ein leerer
# Hash waere schlimmer als kein Schutz: alle Aufrufe saehen gleich aus.
hash_cmd=$(command -v sha1sum || command -v shasum) || exit 0
sig=$(printf '%s' "$payload" | "$hash_cmd" | cut -c1-12)
case "$sig" in [0-9a-f][0-9a-f]*) ;; *) exit 0 ;; esac
state="/tmp/sig-$sid"
# Streak, not total: only count CONSECUTIVE identical calls.
read -r last count < "$state" 2>/dev/null || { last=""; count=0; }
if [ "$sig" = "$last" ]; then count=$((count + 1)); else count=1; fi
echo "$sig $count" > "$state"
if [ "$count" -ge 4 ]; then
echo "Dieser Aufruf ist der $count. identische in Folge. Ändere den Ansatz oder frag nach." >&2
exit 2
fi
The same call four times over is rare in real work and the normal case in a loop. For tools that naturally get called the same way a lot, take them out of the count.
One limit worth knowing: reading, comparing and writing the counter file happens here without a lock. If two tool calls fire at the same moment, both read the same old state and a run of four can slip through as a run of three. For an emergency cord that is tolerable, it simply bites one call later. If you need it exact, wrap the block in flock.
5. Using SubagentStop as a checkpoint
If you work with subagents, the end of each subagent is the best moment for a check. The run is finished, the result is there, and the main run has not taken it over yet.
SubagentStop gives you everything you need for that: stop_hook_active, agent_id, agent_type, agent_transcript_path and last_assistant_message. Through agent_type you can check with different strictness, a research subagent needs different criteria than one that writes code. Through agent_transcript_path you get at the full history, in case the last message alone is not enough.
6. Checking the done claim against the anchor
The stop hook is your last barrier before the done report. This is where you check the most expensive pattern, the fake done.
Blocking works through JSON on stdout:
#!/usr/bin/env bash
# ~/.claude/hooks/fertig-gate.sh
input=$(cat)
if [ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
if [ -f /tmp/verify-ok ]; then exit 0; fi
ziel=$(cat .claude/ziel.txt 2>/dev/null)
jq -n --arg z "$ziel" '{
decision: "block",
reason: ("Kein Verifikationsnachweis in diesem Lauf. Prüfe gegen den Auftrag:\n" + $z)
}'
The point is not the blocking as such. The point is that the reason carries the original assignment word for word back into the context. An agent that lost the goal after sixty steps gets it laid out in front of it again right here.
7. Preventing the endless loop inside the hook itself
A stop hook that blocks makes the model carry on working and then stop again. If your hook blocks again at that point, the session hangs.
That is exactly what stop_hook_active is for. The field tells you that the current pass was already triggered by a blocking stop hook. In the example above that is the first query, and it belongs in every stop and SubagentStop hook you write.
Second rule from the same family: hooks fail open. If jq is missing or the anchor file is gone, the hook should let things through with 0 and not block your work. A barrier that shuts down on every error of its own is one you switch off after three days.
8. Covering the middle layer through TaskCompleted
Between the single tool call and the end of the session lies the layer of tasks. TaskCreated and TaskCompleted cover it, and TaskCompleted has a decision control of its own.
That is the right place for the question "does this subtask have anything to do with the assignment at all". Not every subtask has to be verified, but every one should trace back to a line in your goal file. The exact output form for this decision control is in the hooks reference, it differs from the one for the stop hook.
9. Pulling it back instead of only blocking
A block on its own says "no". It does not say "this way". For a lost goal that is too little, because the agent considers its side path sensible.
Hooks can put text into the context instead of only aborting. The fields for that are called additionalContext and systemMessage. With those you actively push the goal file and the not-part-of-the-task line back in when you suspect drift.
One detail you otherwise learn the painful way: hook output is capped at 10,000 characters, additionalContext and systemMessage included. Anyone shoving half the transcript in there loses the end silently. Three lines of goal are plenty.
10. Arming it, but not everywhere
If you keep all these barriers on permanently, you slow yourself down on every two-liner. Nobody keeps that up, and what nobody keeps up gets switched off.
Do it in two stages. The step counter and the repetition counter always run along, they cost nothing and only block in a genuine exception. The done gate you arm deliberately when a run is big or risky. A small switch is enough:
# arm: touch "/tmp/gate-an-$sid" disarm: rm -f "/tmp/gate-an-$sid"
# With $sid the switch applies to THIS run only. A global marker would arm
# every session running in parallel as well.
# Emergency off for the day a barrier catches wrongly: GATE_AUS=1
[ "${GATE_AUS:-0}" = "1" ] && exit 0
[ -f "/tmp/gate-an-$sid" ] || exit 0
The first check line is the emergency off switch: an environment variable that lets everything through. The day will come when a barrier catches wrongly and you have no time to repair it. Then you set GATE_AUS=1 and carry on working. The one thing that matters is that the check sits right at the top, before every other test, otherwise it fails you at exactly the moment you need it.
What you have afterwards
Four patterns you have named countermeasures for, instead of a vague feeling. An assignment that exists in writing and is therefore checkable. Two counters running along in the background. A gate that does not let done reports through without proof.
What that does not replace: the eval layer after the run. The two work at different ends. Eval tells you whether your agent gets better or worse across many runs, the barriers here tell you whether this one run is derailing right now. If you do not have the eval side yet, the playbook Agent eval in 60 minutes is the fitting next step.
And if nothing fires at all when you set this up, it is almost never the concept and almost always the interplay of matcher, path and permissions. For that there is Debugging hooks when nothing fires.
Sources
All hook events and fields used here come from the official reference and were double-checked on 5 August 2026: Claude Code Hooks reference. That is also where you find the table on the behaviour of exit code 2 per event and the exact output forms of the decision controls.