Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
71a0039
Replace the tool-only auto-pause's turn-count trigger with a real no-…
TheGreatAxios Aug 8, 2026
148ab6f
Generalize the stall-watchdog's character-repetition detector into a …
TheGreatAxios Aug 8, 2026
67a9a30
Detect tool-call thrash by cycle, not just consecutive-identical repeats
TheGreatAxios Aug 8, 2026
f1ed9e7
Wire the main-session hard pause to the cycle-based thrash detector
TheGreatAxios Aug 8, 2026
3ecf779
Add a raw tool-only-turn-count backstop behind period detection
TheGreatAxios Aug 8, 2026
0f73b05
Correct the forensic-scan claims and document the backstop mechanism
TheGreatAxios Aug 8, 2026
c1ec6e7
Split the tool-only backstop's reset from period detection's reset
TheGreatAxios Aug 8, 2026
936b153
Mark genuine operator submits so the tool-only backstop can tell them…
TheGreatAxios Aug 8, 2026
e419af5
Retract the fabricated turns-since-user-message forensic scan
TheGreatAxios Aug 8, 2026
fea763a
Land CL-5611 tool-only pause: thrash period detection, not turn count
TheGreatAxios Aug 9, 2026
e345f14
Wire PostHog cost token names and broaden auth/slash telemetry
TheGreatAxios Aug 9, 2026
a757531
Add intentional /feedback via PostHog survey capture
TheGreatAxios Aug 9, 2026
80170c1
Add corbits resume/continue for project-keyed sessions
TheGreatAxios Aug 9, 2026
014aa60
Tighten markdown settle predicate so CI waits for body paint
TheGreatAxios Aug 9, 2026
f5069f6
Harden feedback honesty and CLI help exit path
TheGreatAxios Aug 9, 2026
729977c
Bake Corbits PostHog survey ids into /feedback
TheGreatAxios Aug 9, 2026
11a306f
Keep local feedback and settings off the agent busy path
TheGreatAxios Aug 9, 2026
72fcbe4
Wire classifySubmit through the runner host and empty Enter cancel
TheGreatAxios Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,17 +118,35 @@ Both directors consume one `ModelFamilyPolicy` object, resolved once per session

| Field | Meaning |
|---|---|
| `toolOnlyTurnNudgeAt` | Consecutive tool-only assistant turns (tool calls, no text) before the ChatDirector injects a one-shot wrap-up nudge. |
| `toolOnlyTurnPauseAt` | Consecutive tool-only turns before the ChatDirector stops issuing infers and surfaces a loud operator-facing pause. |
| `toolOnlyTurnNudgeAt` | Consecutive tool-only assistant turns (tool calls, no text) before the ChatDirector injects a one-shot wrap-up nudge — a check-in, not a stop. |
| `wrapUpNudgeText` | Ephemeral nudge text injected at the nudge threshold. |
| `subAgentStallTimeoutMs` | Wall-clock inactivity, in ms, before a silent sub-agent leaf gets a continuation nudge. |
| `applyGrokFinishBias` | The existing grok anti-thrash residual (withheld from orchestrators — see `shouldApplyGrokAntiThrash`). |

Defaults are permissive (12 / 20 turn-only thresholds, 5-minute stall timeout) so a busy-but-progressing session — tool turns interleaved with narration — never trips either mechanism. **Grok** is tightened (6 / 10, 90s) — xAI's own CLI ships the same shape of main-session auto-pause ("Goal auto-paused after N consecutive non-completing turns"), and a directly observed 14-turn pure-tool-call grok session that the operator had to cancel by hand motivated the lower thresholds. **Kimi (Moonshot)** detection ships now (`isKimiLeafProvider`) so callers can already branch on the family, but its thresholds are provisional — pinned to the permissive default with a why-comment in the policy module — pending eval characterization of Kimi's tool-only and stall behavior.
Defaults (`src/agent/model-family-policy.ts:47`): nudge at 25 consecutive tool-only turns, 5-minute stall timeout. The hard pause is no longer a `ModelFamilyPolicy` field — it runs the same period-detection thrash check for every family (see below). Nudge-at-25 replaced an earlier count-only design (nudge at 12, hard-pause at 20 by count alone, grok tightened to 6/10) that conflated any tool-only turn with no-progress — a Grok session hard-paused at 10 turns while making real progress through Linear lookups and code reads (CL-4839's original loop protection was aimed at runaway list-crawl thrash, not busy-but-progressing tool use). A grep/jq pass over real session traces under `~/.corbits/projects/*/*/context/turns.jsonl` (54 sessions with any tool-only run) found healthy tool-only streaks topping out at 13 turns (p90 12, p99 13) — 25 sits comfortably above that. **Grok** shares the default nudge threshold (its own 6/10 pair was the miscalibration this fixed) but keeps its shorter sub-agent stall timeout (90s) and `applyGrokFinishBias` residual, both independently motivated. **Kimi (Moonshot)** detection ships now (`isKimiLeafProvider`) so callers can already branch on the family, but its thresholds are provisional — pinned to the permissive default with a why-comment in the policy module — pending eval characterization of Kimi's tool-only and stall behavior.

#### Main-session loop protection

The ChatDirector counts consecutive assistant turns that contain tool calls and no text (`toolOnlyStreak`), reset by any turn with text and by every fresh operator message. A dismissed `ask_operator` counts as a no-progress, tool-only turn — the decline path does not reset the streak. At `toolOnlyTurnNudgeAt` the director arms a one-shot ephemeral wrap-up nudge; at `toolOnlyTurnPauseAt` it stops issuing infers entirely and replies with a loud, operator-facing pause message ("Auto-paused: the model ran N steps in a row without explaining its progress. Send a message to resume", `src/agent/director.ts:424-426`), using the same `capabilities.reply()` channel the workflow-stall message already uses to reach the TUI — no new director-to-UI channel was needed. Because a turn with pending `tool_call` blocks must be followed by tool results before anything else (a bare nudge turn on top of pending tool calls is a provider-invalid conversation), both the nudge and the pause are applied by rewriting the `infer` action that follows once those pending tools have resolved — the same one-shot rewrite shape as the sub-agent report-forced wiring below. This loop-protection rewrite runs with the **highest precedence** among the terminal/continuation rewrites in `decideInner`: it is checked before the workflow-idle and open-task continuation nudges, since those exist to keep a session moving — exactly the behavior the pause guards against. Resuming is just the operator sending a new message, which resets the streak and un-pauses through the same reset path as the other nudge budgets.
The ChatDirector counts consecutive assistant turns that contain tool calls and no text (`toolOnlyStreak`), reset by any turn with text and by every fresh operator message. A dismissed `ask_operator` counts as a no-progress, tool-only turn — the decline path does not reset the streak. Two independent triggers ride on that streak: at `toolOnlyTurnNudgeAt` the director arms a one-shot ephemeral wrap-up nudge, regardless of what the tool calls were — a long streak of varied, productive tool calls runs straight through it every time.

The hard pause is a separate signal that does **not** depend on the nudge having fired first. The director appends each tool-only turn's fingerprint (`fingerprintToolCalls`, `src/subagent/stop-policy.ts:108`) to a rolling history (`toolFingerprintHistory`, `src/agent/director.ts:356`, capped at `TOOL_FINGERPRINT_HISTORY_CAP` — `src/subagent/stop-policy.ts:184` — so a very long streak doesn't grow the buffer or per-turn scan unbounded) and runs `detectToolFingerprintThrash` (`src/subagent/stop-policy.ts:168`) over it on every turn.

`detectToolFingerprintThrash` is exact-period detection, not a consecutive-identical check: it finds the shortest period `p` such that the tail of the fingerprint history is `p` repeated at least a required number of times (`detectSequencePeriod`, `src/util/period-detection.ts:61` — the same shape as the character-stream repetition detector in `src/tui/stall-watchdog.ts`'s `detectRepetition`, which now delegates to the same generic helper). This catches three shapes uniformly, where the previous consecutive-identical check only ever caught the first:

- **period 1** — the same tool call every turn (`A,A,A,...`).
- **period 2** — an alternating pair (`A,B,A,B,...`). The previous implementation compared each turn only to the one immediately before it, so this pattern never triggered at any length.
- **period ≥3** — a rotating cycle (`A,B,C,A,B,C,...`).

The repeat floor differs by period (`src/subagent/stop-policy.ts:138-157`): period 1 requires 5 repeats (`IDENTICAL_REPEAT_MIN`) — a short run of identical calls is legitimate (rerunning a flaky test, polling a build), and review on CL-5611 found the previous 4-repeat pause false-positived on exactly that. Any cycle of period ≥2 requires only 3 repeats (`CYCLE_REPEAT_MIN`) — there is no plausible legitimate reason to re-issue a fixed rotation of *different* tool calls with identical arguments, so it fires fast (an alternating pair pauses at 6 turns; a 3-call cycle at 9). Both floors are set well above the *measured* healthy ceiling: a local forensic scan (`scripts/tool-fingerprint-forensics.ts`, 328 sessions with a tool-only run, 559 tool-only runs — **this dataset informs the period-detection repeat floors above, not the backstop threshold below, which uses a separate measurement**) found zero occurrences of any repeating cycle for any period the scan checks — periods 1 through 6 (`MAX_PERIOD_SCANNED`); the scan does not check periods 7-8, so `TOOL_FINGERPRINT_MAX_PERIOD` (`src/subagent/stop-policy.ts:138`) has no forensic backing above period 6, only headroom — stronger than CL-5611's original "zero 3+ identical" finding for the periods it does cover. The 5-repeat period-1 floor itself is not independently measured (the forensic dataset contains no repeats to calibrate against); it is inferred headroom for the polling case, chosen only to sit above the previously-false-positived value of 4.

Once `detectToolFingerprintThrash` reports `repeating: true`, the director stops issuing infers entirely and replies with a loud, operator-facing pause message ("Auto-paused: the model repeated the same tool call N times in a row..." for period 1, or "...repeated a P-call cycle N times in a row..." for a longer cycle, both ending "without making progress. Send a message to resume."), using the same `capabilities.reply()` channel the workflow-stall message already uses to reach the TUI. A streak of length 200+ with a different tool call every turn never pauses. Because a turn with pending `tool_call` blocks must be followed by tool results before anything else (a bare nudge turn on top of pending tool calls is a provider-invalid conversation), both the nudge and the pause are applied by rewriting the `infer` action that follows once those pending tools have resolved (`applyToolOnlyLoopProtection`, `src/agent/director.ts:447`) — the same one-shot rewrite shape as the sub-agent report-forced wiring below. This loop-protection rewrite runs with the **highest precedence** among the terminal/continuation rewrites in `decideInner`: it is checked before the workflow-idle, open-task, and goal-governor continuation nudges, since those exist to keep a session moving — exactly the behavior the pause guards against. Resuming is just the operator sending a new message, which resets the streak, the fingerprint history, and un-pauses through the same reset path as the other nudge budgets.

**Backstop: nudge, then escalate — not an immediate pause.** Period detection has a structural blind spot: any period above `TOOL_FINGERPRINT_MAX_PERIOD`, or a "phase-broken" cycle that inserts a varying element between otherwise-repeating windows (e.g. `A,B,A,B,UNIQUE,A,B,A,B,UNIQUE,...`), never settles into an exact repeating tail and so never fires the fast path — at any streak length. Earlier versions of this backstop each had their own escape, all the same shape: the reset condition was satisfiable by something the model or the system itself could trigger. Round 4 fixed the narration escape (a raw tool-only streak that reset on any narrated turn, so a model inserting one word every ~55 turns kept resetting the counter) by separating two questions that had been sharing one reset rule — but its fix reset `turnsSinceUserMessage` on *any* `message.received` event, which is also satisfied by the synthetic content-less messages the runner sends itself after compaction (`buildCompactionContinuationMessage` in `src/tui/runner.ts`, `src/exec/runner.ts`, `src/subagent/run.ts`) — and compaction fires more often during long tool-only loops, i.e. exactly when the backstop should be counting.
Round 5 fixes the reset condition's shape instead of patching another instance: `turnsSinceUserMessage` now resets only when the inbound message carries `OPERATOR_ORIGINATED_FLAG` (`src/agent/message-provenance.ts`), a flag set only at the genuine human-input submit sites — the TUI's prompt-submit path (`userInboundMessage`, `src/tui/runner.ts`) and exec's initial-task send (`operatorTaskMessage`, `src/exec/runner.ts`). Nothing else sets it, so a message.received event from a synthetic or system-originated send (compaction continuation, retry, future director continuation) is system-originated by default and cannot accidentally qualify — the failure mode inverts from "silently forgets to exclude a sender" to "must explicitly claim to be a human." "Is the model cycling?" (`toolFingerprintHistory` / `lastThrashCheck`) is unaffected by this and is still cleared by any narrated turn — narration remains legitimate evidence the model is not stuck in a tight loop; only the "how long since the operator last saw a real checkpoint?" side (`turnsSinceUserMessage`, `src/agent/director.ts`) requires the operator flag. `detectTurnsSinceUserMessageBackstop` (`src/subagent/stop-policy.ts`) is the secondary/final-net check driven by this counter, evaluated only when period detection has not already reported `repeating: true` on that same turn — so it can never preempt the fast path, only catch what the fast path misses (periods above `TOOL_FINGERPRINT_MAX_PERIOD`, and phase-broken cycles).

**This backstop's threshold (100) is a judgment call, not a measured value.** turns-since-last-genuine-operator-message was never separately measured — an earlier revision of this doc cited a scan of it with a stated methodology and specific percentiles; no corresponding script or output exists anywhere in the tree, and the citation was internally inconsistent about the session/run counts besides. That claim is retracted. The only real measurement available is `scripts/tool-fingerprint-forensics.ts`, which measures a related but different quantity — consecutive tool-only-turn streaks, reset by narration — p50 3, p90 8, p99 16, max 28 across 328 local sessions with a tool-only run. It doesn't directly justify 100 (narration doesn't reset this counter, so the distributions aren't comparable), but it's the only forensic data point on hand, and 100 sits comfortably above every percentile of it.

Because the operator explicitly wants long autonomous runs to keep going, reaching the backstop threshold (`TURNS_SINCE_USER_MESSAGE_BACKSTOP`, 100) does not pause on its own — it fires a one-shot nudge asking the model for a progress summary, the same ephemeral-turn rewrite mechanism as the check-in nudge. Only if that nudge goes unheeded — `turnsSinceUserMessage` advances a further full `TURNS_SINCE_USER_MESSAGE_BACKSTOP` turns with still no user message and no thrash detected — does the director hard-pause, with a distinct message ("Auto-paused: went N turns without a message from the operator, and a progress-summary nudge went unanswered for a further N turns...") tagged `toolOnlyPauseReason: "backstop"` to distinguish it from a thrash pause in logs and messages. A genuine cycle (thrash) still preempts this escalation at any point and pauses immediately, since that is a fast, unambiguous no-progress signal on its own.

#### Sub-agent stall management

Expand Down
17 changes: 11 additions & 6 deletions docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,23 +272,28 @@ Providers and credentials are read exclusively from settings files: the global `

### CLI Verbs and Flags

Printed by `corbits --help` / `-h` from `CLI_HELP_TEXT` in `src/config/index.ts`
(that constant is the source of truth; keep this table in sync when flags change).

| Verb / Flag | Default | Description |
|---|---|---|
| `run` (optional) | — | Run a task (default verb) |
| `resume` | — | Resume the last run in the working directory |
| _(no verb)_ | — | Interactive session; optional trailing task text |
| `exec` / `run` | — | Run a prompt (non-interactive / one-shot) |
| `resume` / `continue` | — | Reopen the latest session for this folder (project-keyed; worktrees of the same git root share sessions) |
| `resume <session-id>` | — | Reopen a specific session |
| `resume --pick` / `--list` | — | Interactive session picker |
| `--cwd <dir>` | `process.cwd()` | Working directory |
| `--config <path>` | `~/.corbits/settings.json` | Settings file to use |
| `--provider <name>` | from settings | Select a configured provider |
| `--model <id>` | provider default | Select a model for the active provider |

| `--profile <name>` | — | Settings profile |
| `--force` | false | Override an existing run state |
| `--dangerously-skip-permissions` | false | Auto-allow anything not denied by the authorization layer (gate + pre-gate workspace sandboxes; secret-guard / authz hard denies remain) |
| `--auto` | true (default) | Force auto mode on (workspace writes + unconstrained shell without prompts) |
| `--no-auto` | false | Start with auto mode off (ask on every consequential action); no in-session key toggles it |
| `--no-workflow` | false | Deprecated no-op; workflows are manual slash commands only |
| `--help` | — | Show help |
| `--help`, `-h` | — | Show help (exit 0 via `CliHelpError`) |

Positional arguments are joined into the optional initial task delivered when the TUI mounts. With no positional task, the operator starts from an empty prompt.
Positional arguments after flags are joined into the optional initial task delivered when the TUI mounts. With no positional task, the operator starts from an empty prompt.

### Agent Source

Expand Down
Loading
Loading