Skip to content

Loop trace, small-window support, and ten harness fixes it found - #789

Merged
yogthos merged 14 commits into
mainfrom
loop-trace
Aug 15, 2026
Merged

Loop trace, small-window support, and ten harness fixes it found#789
yogthos merged 14 commits into
mainfrom
loop-trace

Conversation

@yogthos

@yogthos yogthos commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

--trace <path> writes a JSONL record per loop decision — turns, tool calls,
token usage, the context manager's verdict, what the front end receives, and
every harness intervention attributed to the guard that sent it.
scripts/loop-trace.py renders it; docs/loop-trace.md explains how to read
one and docs/harness-review-2026-08.md is the full write-up with evidence.

It taps the LoopEvent stream at the pump in integration.rs — the one point
every event passes through — so there is no second set of call sites to keep in
step. describe() is exhaustive, so a new variant won't compile until it says
how it traces. Interventions are named from the existing tag registry.

Everything below was found by running dirge end to end: first against a 27B
Qwen on llama.cpp, then the same task on deepseek-v4-flash and glm-5.3 to check
the fixes weren't shaped to one model. All three passed the 22-test task, and
all three piped their test output through tail — masking is what models
do, not a small-model quirk.

The pattern

Every guard's reasoning was right. Several described themselves wrongly, and in
each measured case the model did not comply — it worked around the guard, once
into a fabricated claim. Same shape as docs/verification-discipline.md's
"a guard that cannot explain its scope gets routed around, not satisfied".

Loop control

A force-ended turn ended the whole run, silently. The ExitWithSummary
tier breaks the inner loop — which is the turn loop, so control fell to
finalization and stopped. A model over the threshold got one turn: its tool
calls ran, results were appended, run over before it saw them. It only appeared
to work because a gate happened to fire and restart the outer loop. vpma.22 in
#779 introduced this. Now it continues when the fold made room and stops with a
notice naming prompt_tokens and ctx_max when it didn't.

turns= read 0 for any run that force-ends — the denominator every other
count on the gates line is read against.

Context accounting

Per-provider context_window (GH #772). Top-level only meant no way to
correct one model's window without corrupting the others'. The loop's override
and the session's gauge now resolve from the same call. Verified live.

An unknown model silently got 128k. glm-5.3 matched nothing (glm-5.2 is
listed at 1M); a local model is named by its file path, so
…/Qwen3.8-27B-Q8_0.gguf matched qwen → 32000, smaller than dirge's own
prompt. Family prefixes now cover point releases; an unknown model warns once.
Verified live — glm-5.3 now resolves to a 250k effective window.

The context gauge measured the wrong thing. 226.9k/128.0k, 100%, compaction soon with one compaction: it divided the persisted transcript's
estimate by the advertised window, while compaction compares the request's
prompt_tokens against effective_ctx_max. Now reads the two numbers
decide_after_usage actually compares.

Breadcrumb tool schemas below a 48k window. dirge's opening request is
16,172 prompt tokens with the built-in tools and 32,621 with MCP servers — the
latter larger than a 32k window entirely, so such a run could not take a single
turn. Descriptions are trimmed to their first sentence; names, types, enums and
required-ness are untouched, and no tool is dropped. Measured at 32k: 16,202 →
12,249 tokens, peak 51% → 39.5%, and the model still selected list_symbols
unprompted. compact_tool_schemas: auto / on / off.

Guards that misdescribed themselves

"You didn't run the tests", after four passing runs. The verifier declined
pytest … | tail -12 correctly — the exit status is tail's — but returned
without recording why. The model re-ran the same shape twice more, then added
; echo "exit=$?", and its final answer asserted "exit status 0", a number it
never had. A/B on the same task: old message → Unverified with a green suite;
new message → corrected on the first try, VerifiedGreen. Confirmed on all
three models.

claim_gate and the verifier disagreed about python3 -m pytest.
segment_kind took the first token, got python3, returned None; the
verifier matches any token and recorded green. So a model that had just been
corrected, complied exactly, and reported truthfully was told its report was
unsupported. Interpreter and runner prefixes are now peeled; neither recogniser
knew unittest. A test pins that the two agree where they overlap.

"exit 0" was a build claim. deepseek ran a clean pytest -q and reported
the exit status the verify nudge had asked for — and the claim gate demanded a
build. Now kind-agnostic, satisfied by any verification, while a build still
cannot support "N passed".

The stall checkpoint fired at a model that had passed every test. A masked
green means verified_green never latches, and the progress monitor counts a
green as one of its three progress events. It now stands down while a masked
decline is outstanding — the verify nudge owns that state. (My first fix here
was wrong: I blamed edits_since_verify, which the stall nudge doesn't read
and which an existing test pins the other way. Reverted and recorded.)

What the TUI renders

Tracing the front-end stream showed every intervention rendering its body
twice: the notice carries summary+body because headless sees only it, while
the TUI gets both and renders the body from the message too. The notice now
shows its summary line in the TUI; the body stays on the message path, which is
the copy dirge-m10x guarantees survives the next stream anchor. Headless
unchanged.

That made the last fix safe: boundary nudges now emit
MessageStart/MessageEnd
like the finalization path. They were pushed
straight into context with only a notice, so stall, budget, prologue,
track-work, file-touch, safe-state, fast-verify and reflection nudges were
absent from the message stream — the tally read nudge_progress_stall=2 while
the trace recorded one intervention. Verified live: [stall] now appears as an
attributed intervention.

Filed, not fixed

dirge-hwk9.7: the stall checkpoint tends to fire as a successful run
concludes — qwen at 618.0s of 618.1s, deepseek at 55.3s of 55.4s. Behaving to
spec, but spent on a model that is finishing. Needs a decision.

5580 tests pass; fmt and all four clippy configs clean.

Yogthos added 11 commits August 14, 2026 23:16
--trace <path> writes a JSONL record per loop decision: turns, tool calls,
token usage, the context manager's verdict, and every harness intervention
attributed to the guard that sent it. scripts/loop-trace.py renders it.

It taps the LoopEvent stream at the pump in integration.rs, the one point
every event passes through on its way to every consumer, so there is no
second set of call sites to keep in step with the first. describe() is
exhaustive, so a new LoopEvent variant won't compile until it says how it
traces. Interventions are named from the existing tag registry, so a guard
added later is traced without anyone editing this file.

Three bugs found by running it against a local model:

A force-ended turn ended the whole run. The ExitWithSummary tier sets
force_turn_end, which broke out of the inner loop — but the inner loop is
the turn loop, so control fell to the finalization poll and the run stopped.
A model over the threshold got one turn: its tool calls ran, results were
appended, and the run ended before it ever saw them, silently. vpma.22 in
#779 introduced this; the arm did nothing at all before that. Now it
continues the run when the fold actually made room, and when it didn't,
stops with a notice naming prompt_tokens and ctx_max.

glm-5.3 matched nothing in the context-window table (glm-5.2 is listed at
1M) and fell to the 128k default, so a live session showed 226.9k/128.0k
and "compaction soon" on a model with eight times that room. Every context
tier divides by that number. Family prefixes now cover point releases,
qwen3/qwen4 no longer inherit the original qwen's 32k, and a model neither
lookup knows warns once instead of being guessed in silence.

turns= on the gates line read 0 for any run that force-ends its turns:
record_turn sat past the break. Moved to where the turn happens. The fold
and force-summary log lines now carry prompt_tokens and ctx_max, which took
solving a division by hand to recover.
The verifier declines a build/test command whose exit status was piped or
sequenced away — the zero belongs to tail, not pytest — and that is right.
But it returned without recording why, so status() fell to the "nothing ran"
branch and the model was told "you didn't run the tests or build to check
it" after running pytest four times.

Measured against a local model: it did not remove the pipe, because the
sentence was visibly false. It re-ran the same shape twice more, then added
`; echo "exit=$?"`, which reports echo's status. The claim gate fired on the
consequence and the final answer asserted "exit status 0" — a number it
never had. Three of fifteen turns, ending Unverified with a green suite.

The verdict is unchanged: a masked pass still is not a green. Only the
message changes — it now quotes the command, says the status belongs to the
last stage of the pipe, and says what to run instead.
The summary counted assistant message records, which the trace stopped
writing when the pre-stream placeholder was dropped — so it read 0 turns
for a run that took 15. turn_end is one per completed turn and matches the
gates line.
A local model was observed thinking for thirteen minutes across 2892 tokens
with nothing recorded between turn_start and the next event — which reads
like a deadlock in a file whose stated purpose is explaining a run that
hung. MessageUpdate now emits one throttled record per 10s saying what the
turn is doing and how much it has produced. One record per delta would be
the opposite failure, so the throttle is asserted both ways.
context_window (GH #772) is now settable per provider. It was top-level
only, so with more than one provider configured — the common case — there
was no way to correct one model's window without corrupting the others'.
Correcting a local model's window this session needed a whole separate
config directory for exactly that reason. Precedence is
providers.<name>.context_window, then the top-level key, then the model
table, then 128k.

The loop's window override and the session's gauge now resolve from the
same call, at the one point where provider and model are both final. It
stays None when nothing is configured, so the loop keeps re-resolving from
the table per run and a mid-session /model switch still tracks the new
model.

Separately: claim_gate::segment_kind took the first token of a segment as
the command, so `python3 -m pytest -v` classified as `python3` and returned
None. The verifier matches on any token, found `pytest`, and recorded the
run green — so the model was told "no build/test command of the matching
kind ran this run" one turn after running one correctly, having just been
corrected by the verifier for running it wrong. Measured live.

segment_kind now steps past an interpreter or runner prefix: python -m
<module>, npx/bunx, poetry/uv/pipenv/rye/hatch run. `python script.py` and
`python -c '…'` are deliberately left alone — the interpreter is running a
script, not a known tool. Neither recogniser knew `unittest`; both do now.
A test asserts the two agree on the commands where they overlap.
The gauge divided total_estimated_tokens — a chars/4 heuristic plus
per-tool-call overhead over the whole persisted transcript, tool results at
full length — by the model's advertised window. The context manager
compares the provider's prompt_tokens for the REQUEST, whose oversized
results have been snipped and whose old turns are a summary, against
effective_ctx_max. Reported live: 226.9k/128.0k, 100%, 'compaction soon',
one compaction, on a run whose requests were never close.

Session now keeps last_prompt_tokens, set from usage.input_tokens alone —
the same field decide_after_usage divides by. Not the sum of the three
input figures: that would be closer to the true prompt on Anthropic and
double-count on DeepSeek, and either way put the gauge back to describing a
fold that does not happen. The estimate stands in until the first response.
Both the panel and the status bar read it.
Told to drop the `|` or `;`, one model produced a clean `pytest -v` and
another produced `pytest -v; echo "EXIT=$?"` — which obeys the letter, tries
to surface the status, and masks anyway. The nudge is now positional (the
build/test command last, nothing after it) and names the three idioms that
have actually shown up.

Also records the long-horizon run in docs/harness-review-2026-08.md,
including a cascade it exposed: a masked-but-green test run means
verified_green never latches, so the progress monitor — which counts a green
as one of its three progress events — fired the stall nudge twice at a model
that had passed all 22 tests, the second time at 618.0s of a 618.1s run.
Filed as hwk9.4 rather than patched: my first fix blamed edits_since_verify,
which the stall nudge does not read and which an existing test deliberately
pins the other way.
@yogthos yogthos changed the title Add a loop trace, and fix four bugs it found Loop trace, per-provider context window, and six harness fixes it found Aug 15, 2026
Yogthos added 3 commits August 15, 2026 01:03
…stream

Tracing what the front end receives, not just what the loop decided, showed
the TUI rendering every harness intervention's body twice. The notice
carries "harness intervention: {summary}\n{body}" because headless sees only
it — --print renders SystemNotice and ignores UserMessage entirely — but the
TUI gets both and renders the body from the message too. The notice now
shows its summary line only in the TUI; the body stays on the message path,
which is the copy dirge-m10x guarantees survives the next stream anchor.
Headless output is unchanged.

With that settled, boundary nudges emit MessageStart/MessageEnd like the
finalization path. They were pushed straight into context with only a
SystemNotice, so stall, budget, prologue, track-work, file-touch,
safe-state, fast-verify and reflection nudges were absent from the message
stream — the tally read nudge_progress_stall=2 while the trace recorded one
intervention. Adding them before the render fix would have put the body on
screen a third time.

Also: the stall checkpoint stands down while the verifier holds a masked
decline. Measured — a run that passed all 22 tests at 345s via
`pytest … | tail -28` was told twice it had made no progress for three
turns, the second time at 618.0s of a 618.1s run. The verify nudge owns that
state and has the actionable message; the stall text offers "getting a green
check" as the way out, which is what had just happened and could not be
counted. The prologue checkpoint is not suppressed — it fires on a run that
produced nothing, where a masked verification is not the explanation.
dirge's opening request is large before the model has done anything:
measured, 16,172 prompt tokens with the 34 built-in tools and 32,621 with
MCP servers loaded. The second is larger than a 32k window in its entirety,
so such a run could not take a single turn — and the only symptom was the
context manager force-ending every one.

On a window at or below 48k, each tool's description is trimmed to its first
sentence and each parameter's to a short clause. Names, types, enums and
required-ness are untouched: the model keeps everything it needs to form a
well-formed call and loses the prose about when to prefer one tool over
another. No tool is dropped — a model that cannot see a tool cannot ask for
it, and that failure is silent and looks like incapability.

Measured on the same task and model at a 32k window: 16,202 -> 12,249 prompt
tokens, context peak 51% -> 39.5%, and the model still reached for
list_symbols unprompted and answered correctly.

config `compact_tool_schemas`: auto (default) / on / off. The decision is
made at loop_tool_to_rig_definition, the one point every tool becomes a
provider schema, and sized against the same window the session gauge and
compaction use — passed in rather than re-derived.
Measured on deepseek: the model ran a clean `python3 -m pytest -q` and
reported "Confirmed with a real exit status: 22 passed in 0.01s, exit 0."
The claim gate fired, because "exit 0" sat in the build/lint list and no
build had run. The verify nudge is what asks for the exit status when it
declines a masked run — so one guard requested the number and another
penalised the answer.

"exit 0" / "exit code 0" / "exit status 0" are now satisfied by any observed
verification. Kind-matching stays where it earns its keep: a build still
cannot support "N passed" and a test run still cannot support "clippy
clean", both pinned by a test.

Found by running the same task on qwen, deepseek and glm to check the
earlier fixes weren't shaped to one model. All three passed 22/22, and all
three piped their test output through `tail` — masking is what models do,
not a small-model quirk.
@yogthos yogthos changed the title Loop trace, per-provider context window, and six harness fixes it found Loop trace, small-window support, and ten harness fixes it found Aug 15, 2026
@yogthos
yogthos merged commit c610208 into main Aug 15, 2026
15 checks passed
@yogthos
yogthos deleted the loop-trace branch August 15, 2026 05:33
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