Skip to content

The harness watchdog

The watchdog is an optional safety net you turn on for a personality. While the agent works, the watchdog watches for signs that the agent is stuck in a loop, and when it sees one, the watchdog drops a short nudge into the conversation to break the agent out. The nudge is an ordinary message, tagged [harness] to mark it apart from your own messages. The agent is free to ignore the nudge and keep going.

The watchdog is off unless you turn it on. A fresh setup never nudges, because no personality watches until you add a watchdog to one. The built-in default personality watches only once you set a default policy for it. It is the personality active when you have selected no personality of your own.

Turns and rounds

Two words matter for the settings below.

A turn is everything the agent does in response to one prompt you send. The agent may think, call a tool, read the result, think again, call another tool, and so on, until it has an answer for you and hands control back. One prompt from you is one turn.

A round is a model response inside a turn. A turn usually contains several rounds: the model responds, tools run, the model responds again with the results in hand. The turn ends when a response comes back with no tool calls.

The counts below (rounds without progress, nudges allowed per turn, and so on) are measured within a turn, and they start fresh each time you send a new prompt.

Enabling it

Add a watchdog table to a personality in your Lua config. Adding the key turns the watchdog on. An empty table turns it on with every default:

registry:add {
  name = 'Executive',
  model = 'beast/qwen38-27b',
  watchdog = {},
}

Deleting the key turns the watchdog off again. The watchdog re-reads its settings at the start of every turn. Because of that, switching to this personality with /personality turns the watchdog on, switching away turns it off, and /reload picks up any edits you make to the table right away.

The default personality

The built-in default personality takes no watchdog key of its own. To watch it, set a default policy from a reconfigure handler:

agent.on('reconfigure', function()
  agent.set_default_watchdog {}
end)

An empty table enables it with every default. The keys are the same as a personality watchdog table. This policy governs only the default personality. A named personality never inherits it, and opts in through its own watchdog key. A named personality may take these same settings explicitly with agent.default_watchdog():

registry:add {
  name = 'Executive',
  model = 'beast/qwen38-27b',
  watchdog = agent.default_watchdog(),
}

When no default policy is set, agent.default_watchdog() reads as nil, which leaves that personality's watchdog off.

Sub-agents

A spawned sub-agent runs its own watchdog, driven by the watchdog key of the sub-agent's own personality, with counts independent of the parent and of any other sub-agent. A sub-agent always runs under a named personality, which has no default policy to fall back to. It watches only when that personality declares a watchdog.

What it watches for

The watchdog has four independent triggers. Each one watches for a different shape of loop, and one of them can fire a nudge. The examples below sketch what each loop looks like in the TUI. The » watchdog: line is the nudge.

A tool that keeps failing

The agent calls a tool, the call fails, and the agent tries the same thing again. After max_consecutive_errors failures in a row (3 by default) with no success in between, the watchdog nudges. One successful call clears the count.

you> get the test suite green
agent> runs: cargo test    ->  error[E0433]: failed to resolve
agent> runs: cargo test    ->  error[E0433]: failed to resolve
agent> runs: cargo test    ->  error[E0433]: failed to resolve
» watchdog: Perhaps it is time to ask for help?

Rounds that don't make progress

The agent keeps working but never completes a successful tool call. After max_rounds_without_success rounds in a row (4 by default) end with no successful result, the watchdog nudges. A round in which the agent finishes and hands control back to you does not count, because stopping on purpose is not spinning.

you> find who calls parse_header
agent> greps: "parse_header" in src/   ->  no matches
agent> greps: "parseHeader"  in src/   ->  no matches
agent> greps: "parse_hdr"    in src/   ->  no matches
agent> greps: "header("      in src/   ->  no matches
» watchdog: Perhaps it is time to ask for help?

The same tool calls over and over

The agent repeats the same sequence of tool calls even though every call succeeds. Neither trigger above notices this, because nothing fails and each round makes a "success". The watchdog looks at the last repeat_call_window tool calls (20 by default) and nudges when the same group of calls has repeated repeat_call_threshold times (4 by default).

you> summarize the config module
agent> reads: src/config.rs        ->  ok
       greps: "load_config"        ->  ok
agent> reads: src/config.rs        ->  ok
       greps: "load_config"        ->  ok
agent> reads: src/config.rs        ->  ok
       greps: "load_config"        ->  ok
agent> reads: src/config.rs        ->  ok
       greps: "load_config"        ->  ok
» watchdog: Perhaps it is time to ask for help?

Thinking in circles

The model keeps saying the same thing, in its thinking or in its visible replies, whether or not it is calling tools in between. This is the one trigger that sends the break-the-loop nudge instead of the escalation nudge:

you> refactor the parser
agent> (thinking) I should start by understanding the existing parser first.
agent> (thinking) Let me begin by getting a handle on how the parser works.
agent> (thinking) First I need to understand the current parser.
       ... the same thought, reworded, keeps coming back ...
» watchdog: You have been repeating yourself. Decide on a next step or state your conclusion.

The detector matches at two levels. A strict match is the same sentence almost word for word, and it fires after repeat_sentence_threshold repeats (4 by default). A loose match is the same idea in different words, like the three rephrasings above, and it fires after the higher repeat_sentence_threshold_loose count (6 by default). To avoid nudging on a brief thought, the detector ignores sentences shorter than min_sentence_chars characters and does not fire until the model has produced min_armed_sentences sentences in the turn.

How the triggers work together

A few rules govern all four triggers simultaneously:

  • The first three triggers send the escalation nudge (nudge_text). Only thinking repetition sends the break-the-loop nudge (break_loop_text).
  • After any nudge, the watchdog waits before nudging again. It always waits refractory_seconds (30 by default). Once a round has finished since the nudge, it also waits refractory_rounds more rounds (2 by default). A nudge that fires in the middle of a round, such as a thinking nudge, is held by the seconds only, because no round has finished yet for the round count to apply.
  • The watchdog does not fire while the agent has a sub-agent running, since the agent is already reaching for help. Meanwhile the sub-agent supervises itself with a separate watchdog, driven by the sub-agent's own personality policy.
  • The break-the-loop nudge is limited to max_think_nudges per turn (2 by default). When the agent hits that limit and keeps looping, the watchdog interrupts the turn outright (interrupt_on_cap, on by default). Set interrupt_on_cap = false to let the turn keep running instead. The other three triggers have no per-turn limit.
  • The watchdog remembers its counts per session, and it forgets them when you switch personality, so a streak on one personality never transfers into another.

What the nudge looks like

The nudge goes into the conversation as a message from you, tagged [harness], to let the model read it as part of the dialogue and answer it. In the TUI the same nudge also shows as a dim one-line notice, set apart from your own messages:

» watchdog: Perhaps it is time to ask for help?

That notice is only a display of the nudge. The message itself is stored in the conversation history.

Configuration reference

Every key is optional, and an omitted key takes its default. All thresholds must be at least 1.

Key Meaning Default When to adjust
max_consecutive_errors Failing tool calls in a row that fire the nudge 3 Lower to break out of a failing tool sooner. Raise if a tool that legitimately fails a few times in a row is getting nudged.
max_rounds_without_success Rounds in a row with no successful tool result that fire the nudge 4 Lower for an earlier nudge on stuck work. Raise if long tool-heavy work with no clear "success" is getting nudged.
repeat_call_window How many recent tool calls the detector looks back over 20 Raise to hold repeat_call_threshold * repeat_call_max_len calls in the window. Must be at least that product.
repeat_call_threshold How many times a group of calls must repeat before the nudge 4 Lower to catch a loop sooner. Raise if a legitimately repeated sequence is getting nudged.
repeat_call_max_len The longest run of calls treated as one repeating group 4 Raise if the repeating group is longer than four calls. Must be at least 2.
repeat_sentence_window How many recent sentences the thinking detector looks back over 50 Raise if the repeated thought is spread out among other thinking.
repeat_sentence_threshold Repeats of a near-identical sentence that fire the strict match 4 Raise if the model restates itself legitimately and gets nudged. Lower to catch a loop sooner.
repeat_sentence_threshold_loose Repeats of the same reworded idea that fire the loose match 6 Raise if a reworded but legitimate thought is getting nudged. Must be greater than repeat_sentence_threshold.
min_sentence_chars Sentences shorter than this are ignored 20 Raise to ignore short boilerplate like "Let me check."
min_armed_sentences Sentences the model must produce in a turn before the thinking detector can fire 10 Raise if short bursts of thinking are getting nudged. Lower to catch a loop that starts early.
max_think_nudges Break-the-loop nudges allowed per turn before the limit kicks in 2 Raise to provide more nudges to the agent before the turn is cut. Lower to give up sooner.
interrupt_on_cap Interrupt the turn once max_think_nudges is reached true Set to false to let the turn keep running past the limit instead of being cut.
refractory_rounds Rounds that must pass after a nudge, once a round has finished, before the next nudge 2 Raise if a second nudge follows the first too soon.
refractory_seconds Wall-clock seconds that must pass after a nudge before the next one 30 Raise to space nudges further apart on a slow, long-running turn.
nudge_text The escalation nudge text, used as written "Perhaps it is time to ask for help?" Change to steer the agent a different way, such as toward asking you a question.
break_loop_text The break-the-loop nudge text, used as written "You have been repeating yourself. Decide on a next step or state your conclusion." Change to name the specific loop you want the agent to drop.

Here is a personality that keeps the defaults but nudges sooner and speaks to the agent in its own words:

registry:add {
  name = 'Executive',
  model = 'beast/qwen38-27b',
  watchdog = {
    max_consecutive_errors = 2,
    max_rounds_without_success = 3,
    nudge_text = 'Stop and tell me what is blocking you.',
    break_loop_text = 'You are going in circles. Pick one next step.',
  },
}

If you mistype a key or give a threshold a non-integer value, the config fails to load and the error points at the offending line, rather than the mistake being ignored.