Skip to content

Tool-only auto-pause should nudge, not hard-stop, on turn count alone - #401

Closed
TheGreatAxios wants to merge 9 commits into
mainfrom
cl-5611-tool-only-pause-should-nudge-not-kill
Closed

Tool-only auto-pause should nudge, not hard-stop, on turn count alone#401
TheGreatAxios wants to merge 9 commits into
mainfrom
cl-5611-tool-only-pause-should-nudge-not-kill

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • The main-session tool-only pause used to hard-pause on raw turn count alone, which killed sessions making genuine progress (CL-5611).
  • Loop protection is now two independent signals:
    • Period detection (fast path)detectToolFingerprintThrash (src/subagent/stop-policy.ts) runs exact-period detection over a rolling history of tool-call fingerprints, catching not just identical-every-turn repeats but alternating/rotating cycles (A,B,A,B,...; A,B,C,A,B,C,...) at any streak length. This is what actually hard-pauses on a genuine thrash — fast (an alternating pair pauses at 6 turns, a 3-call cycle at 9) and independent of streak length. It has a period ceiling (8) with no forensic backing above period 6 in the local scan, only headroom.
    • Turns-since-user-message backstop (delayed escalation) — catches what period detection structurally cannot: cycles above the period ceiling, and "phase-broken" cycles that insert a varying element between otherwise-repeating windows and never settle into an exact repeating tail. This counts turns since the operator's last genuine message and does not pause on its own — reaching the threshold (100 turns) only fires a nudge asking for a progress summary. Only if that nudge goes unanswered for a further full 100-turn interval, with still no user message and no thrash detected, does the session hard-pause. A detected cycle still preempts this and pauses immediately.
  • Why the backstop escalates instead of pausing outright: the operator explicitly wants long autonomous runs to keep going. Silence alone, with no detected cycle, is not sufficient grounds to stop a session that may simply be doing a long stretch of varied, productive work.
  • The reset split (this round's fix): an earlier version of the backstop shared one reset condition with period detection — any turn with narrated text cleared both. That let a model reset the backstop by narrating one word every ~55 turns, escaping indefinitely (verified with a 2240-turn repro that never paused). The two counters now reset independently:
    • Period-detection history (toolFingerprintHistory/lastThrashCheck) still clears on any narrated turn — narration is legitimate evidence the model is not stuck in a tight loop.
    • The backstop counter (turnsSinceUserMessage) clears only on a genuine fresh user message. Model-emitted text does not reset it.
  • docs/ARCHITECTURE.md updated to describe the fast-path/backstop split, the nudge-then-escalate behavior, and which forensic dataset informs which threshold.

Thresholds and their forensic basis

  • Period-detection repeat floors (IDENTICAL_REPEAT_MIN = 5, CYCLE_REPEAT_MIN = 3) are informed by scripts/tool-fingerprint-forensics.ts (328 sessions with a tool-only run, 559 tool-only runs): zero occurrences of any repeating cycle for periods 1-6 in local trace history.
  • The backstop threshold (TURNS_SINCE_USER_MESSAGE_BACKSTOP = 100) is informed by a separate measurement: turns since the last genuine user message (filtering out API tool-result echoes, which are also role user in the transcript format but are not the operator), not tool-only run length — narration no longer resets this counter, so it needed its own distribution. p50 5, p90 14, p99 29, max 32 across 428 runs from the same 358-session local corpus. 100 sits roughly 3x the measured max and >3x measured p99.

Why this is director policy, human review required

This changes main-session ChatDirector loop protection. Per team standing rule, prompt/director/reactor/context-management changes never merge without the operator reading the diff — this PR is not auto-mergeable.

Test plan

  • bun run typecheck
  • bun run build
  • bun run test (full suite; only pre-existing, unrelated failure is src/agent/lsp-availability.test.ts, caused by a missing typescript-language-server devDependency install in this worktree — not touched by this change)
  • Regression test: the 2240-turn one-narrated-word-every-55-turns repro now nudges, then hard-pauses when the nudge goes unheeded.
  • A genuine fresh user message resets the backstop counter.
  • Model narration does not reset the backstop but does clear period-detection history (tested together in one scenario).
  • Long varied productive work, including with real periodic user interaction, never pauses or nudges.
  • Period detection still fires fast on A,B (6 turns) and A,B,C (9 turns), well ahead of the backstop, and is distinguishable from a backstop pause in the reply message.

CL-5611

…progress signal

A Grok session hard-paused at 10 turns while making real progress through
Linear lookups and code reads, because the pause fired on any tool-only turn
count rather than actual thrash. Forensics over ~/.corbits/projects session
traces (54 sessions with tool-only runs) found healthy streaks topping out at
13 turns and zero sessions repeating an identical tool-call fingerprint 3+
times in a row.

The soft wrap-up nudge now fires at a shared 25-turn threshold for every
model family (still just a check-in, never a stop). The hard pause now
requires the tool calls to actually repeat identically 4 turns in a row
(fingerprintToolCalls, the same helper SubAgentDirector already uses),
independent of overall streak length. Grok drops its miscalibrated 6/10
tool-only pair and shares the default; its shorter sub-agent stall timeout
and finish-bias residual are untouched.
@linear-code

linear-code Bot commented Aug 8, 2026

Copy link
Copy Markdown

CL-5611

…shared period-detection helper

Lifted the shortest-period-that-repeats-enough search out of
detectRepetition into src/util/period-detection.ts so tool-call
fingerprints can reuse the same detection shape instead of a hand-rolled
consecutive-identical check. stall-watchdog's detectRepetition now
delegates to it; behavior is unchanged, covered by its existing test suite.
The old identicalToolFingerprintStreak only compared each turn to the one
immediately before it, so an alternating A,B tool-call pattern never
triggered the hard pause at any length (critique proved this over 200
turns), while 4 truly identical calls in a row still false-positived on
legitimate polling (rerunning a flaky test, checking a build).

detectToolFingerprintThrash runs exact-period detection over a rolling
fingerprint history instead, catching A,A,A..., A,B,A,B..., and
A,B,C,A,B,C... uniformly. Identical-consecutive (period 1) needs 5 repeats
to tolerate legitimate short polling; any longer cycle needs only 3, since
there's no legitimate reason to repeat a fixed rotation of different tool
calls.

Added scripts/tool-fingerprint-forensics.ts to re-derive these thresholds
against real local session traces: 328 sessions / 559 tool-only runs show
zero repeating cycles of any period 1-8 at all, so both floors sit well
above the measured healthy ceiling. The period-1 floor of 5 is inferred
headroom for the polling case (not measured — the dataset has no repeats to
calibrate against), chosen only to clear the previously false-positived
value of 4.
ChatDirector now keeps a capped rolling history of tool-only-turn
fingerprints and pauses on detectToolFingerprintThrash instead of a
hand-rolled last-fingerprint comparison, so it catches alternating and
rotating tool-call cycles the old check missed entirely, without
false-positiving on a handful of identical polling calls. Removed
toolOnlyNoProgressRepeatLimit from ModelFamilyPolicy — the thrash check is
no longer a single tunable number, and isn't family-specific.

Updated the stale applyToolOnlyLoopProtection JSDoc, which claimed the pause
only fires after the nudge — no longer true, since the thrash check can
(and often does) fire well before the nudge threshold. Updated
docs/ARCHITECTURE.md's director-policy section to describe period detection
accurately, with file:line references.

Tests: alternating A,B for 200 turns now pauses (critique's exact repro), a
3-cycle A,B,C pauses, 4 identical polls followed by varied work does not
pause, a long varied productive streak never pauses, and the nudge path
still does not reply-pause.
Period detection has a hard ceiling (max scanned period 8) and only fires on
an exact repeating tail, so a rotation longer than the ceiling, or a
"phase-broken" cycle that inserts a varying element between repeats (e.g.
A,B,A,B,UNIQUE,...), escapes it forever regardless of streak length.

detectRawToolOnlyBackstop is a secondary, pattern-free check on the raw
tool-only streak length, wired into the ChatDirector so it only fires once
period detection has not already caught the turn. It uses its own pause
message ("ran N tool-only turns without narrating progress") rather than the
pattern-detection wording, since no pattern was found. Threshold is 60,
derived from the current forensic scan (328 sessions with a tool-only run,
559 tool-only runs): run-length p50 3, p90 8, p99 16, max 28 — 60 is more
than double the longest healthy streak ever observed and stays well clear of
the old hard-pause-at-10 that originally motivated this rework.

Also documents on TOOL_FINGERPRINT_MAX_PERIOD that it is a ceiling with no
forensic backing above period 6 (the scan's actual range), and that the
backstop is what catches anything above it.
An earlier commit on this branch claimed the forensic scan
(scripts/tool-fingerprint-forensics.ts) covered periods 1-8 across 328
sessions; the script only ever scanned periods 1-6 (MAX_PERIOD_SCANNED). That
inaccurate claim was repeated in docs/ARCHITECTURE.md and
model-family-policy.ts (stop-policy.ts's copy was fixed in the previous
commit alongside the ceiling comment it lives next to). All three now say
periods 1-6, and model-family-policy.ts's healthy-streak figures are updated
to the run this scan currently produces (p50 3, p90 8, p99 16, max 28) rather
than the older "13-28" summary. Also drops product-name attribution from a
comment that no longer needs it.

docs/ARCHITECTURE.md's director-policy section now also describes the
raw-count backstop added in the previous commit: period detection as the
fast path, the backstop as the final net for cycles above the period
ceiling or phase-broken patterns, with file references for both.
Critique found the round-3 backstop's own escape: narrated text reset both
the period-detection history AND the raw backstop counter, so a model that
narrated one word every ~55 turns kept resetting the backstop before it
could fire. Period detection ("is the model cycling?") still clears on
narration. The backstop is now a separate counter, turnsSinceUserMessage,
that only clears on a genuine fresh user message.

Since narration no longer buys back backstop budget, a legitimately long
autonomous run will now reach it. Reaching the backstop no longer pauses
outright — it nudges for a progress summary. Only if that nudge goes
unanswered for a further full backstop interval, with still no user message
and no thrash detected, does the session hard-pause. A genuine cycle (period
detection) still pauses immediately regardless.

Re-derived the threshold from a fresh local scan of
turns-since-last-genuine-user-message (filtering tool-result echoes, which
are also role "user" in the transcript format): p50 5, p90 14, p99 29, max
32 across 428 runs. Set to 100, roughly 3x the measured max.
… apart from synthetic sends

Round 4 reset the turns-since-user-message backstop on any message.received
event, which synthetic system sends (compaction continuations from
tui/runner.ts, exec/runner.ts, subagent/run.ts) also fire without being
operator input — and compaction fires more often during long tool-only
loops, exactly when the backstop should be counting.

Adds OPERATOR_ORIGINATED_FLAG, set only where a human actually submits a
prompt (TUI prompt-submit and the "send" command result, exec's initial
task). The backstop now resets only when that flag is present, so a future
synthetic sender has to explicitly opt in rather than silently qualifying
by omission.
The commit message, stop-policy.ts, and ARCHITECTURE.md cited a
358-session/428-run scan of turns-since-last-genuine-operator-message with a
stated methodology; no corresponding script or output exists anywhere in
the tree, and the two numbers already disagreed with each other. That
measurement was never taken.

Rewrites all three to state plainly that 100 is a judgment call, not a
measured value, informed only by the streak-length data we do have
(tool-fingerprint-forensics.ts: p50 3, p90 8, p99 16, max 28 across 328
sessions) even though that measures a different quantity than this counter.
Also fixes the stale backstopNudgeFiredAtTurn comment, which claimed a reset
on thrash/escalation that does not happen in code.
@TheGreatAxios

Copy link
Copy Markdown
Collaborator Author

Closing — fold into continuous-director (CL-5758)

This PR is closed without merge. It conflicts with main and is not the product home for this work. Salvage the durable pieces into CL-5758 (continuous-director); do not finish or rebase this as a standalone PR.

Why close

  • Branch is CONFLICTING with main; standing alone is the wrong packaging.
  • Product direction belongs under continuous-director (CL-5758), not a one-off tool-only pause PR.
  • Several behaviors in this branch are either incomplete (OpenTUI submit provenance), over-escalating (hard-pause after 100 turns), or forensic noise to drop.

SALVAGE into CL-5758

Bring these into continuous-director work (do not lose them with the close):

  1. Period thrash hard-stopdetectToolFingerprintThrash / period detection; period-1 floor 5 / cycle floor 3; fixes for A,B and A,B,C blind spots.
  2. Drop toolOnlyTurnPauseAt — stop hard-pausing productive tool-only work on raw turn count alone.
  3. Soft wrap-up nudge ~25 for all families; Grok shares the nudge, keeps stall/finish-bias.
  4. Reset split — fingerprint history clears on narration; operator counters do not.
  5. OPERATOR_ORIGINATED_FLAG / message-provenancemust cover the OpenTUI submit path (not only exec + tui runner).
  6. Shared detectSequencePeriod util + stall-watchdog delegate.
  7. tool-fingerprint-forensics script + director thrash/nudge tests.

DROP (do not carry into CL-5758)

Branch

Leaving cl-5611-tool-only-pause-should-nudge-not-kill as historical reference (not deleted).

Related: CL-5611 (origin), fold target CL-5758.

@TheGreatAxios

Copy link
Copy Markdown
Collaborator Author

Closed without merge — work folds into continuous-director (CL-5758). See fold plan comment above. Branch retained as historical reference.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant