Skip to content

feat(llm): reasoning budget for local tool steps and progress-note replies (F49, F50) - #453

Merged
plombeer31 merged 4 commits into
mainfrom
harness/16-local-turn-shaping
Sep 17, 2026
Merged

plombeer31 merged 4 commits into
mainfrom
harness/16-local-turn-shaping

Conversation

@plombeer31

@plombeer31 plombeer31 commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

What

Two local-model turn-shaping fixes from re-running the bench-2 matrix with a second local model (Qwen 3.8 27B, findings 32–33):

  • F49 — a reasoning budget for local tool steps, and localModels.thinking: off honoured on the built prompt. Qwen-shaped profiles get a prefilled <think> and a grammar whose reasoning prelude was unbounded; at 4.5 tok/s the model spent 500–7,000 reasoning tokens (84–96 % of every completion, one step 22 minutes) before each one-line tool call. Now localModels.reasoningBudgetTokens (default 1,500; 0 = unbounded) bounds the prelude in the GBNF grammar by characters (think-char{0,N}, N = 4 × budget; same for the Gemma 4 channel prelude) — past the budget the grammar admits only the close sentinel, so the model must close and emit the call; the forced final step keeps an unbounded prelude. localModels.thinking: off, which until now only reached server-templated prompts, also applies to the built prompt for prefilled profiles: the prompt ends with the template's own disabled marker (<think>\n\n</think>\n\n) and the grammar drops the prelude. The llm_completion trace event carries reasoningTokens (an estimate from the text, so a cut reads as >= budget).
  • F50 — a reply batched with work tools is a progress note, not the end of the turn. The model batched os.shell.run + reply "(collecting file contents…)"; the runtime ran both and ended the turn with nothing built, because any reply was terminal wherever it sat in the batch. Now a reply that shares a completion with non-terminal calls is taken out before validation, the other calls run, and the text is recorded as a progress note (assistant_reply turn flagged progressNote, tool result details.progressNote: true, step summary progress note + N tools, step_finished flagged); the model is told once per turn, through the notice channel, that a reply ends the turn only when it is the sole call. A sole reply, the forced final step and finish behave exactly as before. On the OpenAI-compatible HTTP stream a note goes out as a progress_note event (opt-in extensions), never as a content delta, and the final payload picks the last non-note reply; Telegram, Discord, the task runner and fusion worker results skip notes.

Config version 67 → 68: localModels.reasoningBudgetTokens.

Why

A local model that thinks in the open pays for every reasoning token at local speed; without a bound one step can take longer than the whole task. And a status line to the user should not silently be the end of the task — the transcript now shows it as what it was.

The budget is deliberately generous (1,500 tokens ≈ 6,000 characters — above every Gemma step observed and three times Qwen's median) and the cut is visible in the trace; 0 restores today's behaviour.

How it was verified

  • npm run lint clean
  • npx vitest run src/config src/llm src/prompt src/agent src/http src/runtime src/tracing — 199 files / 2,856 tests green
  • Full suite on this head: 910 files: 908 passed / 2 failed, 10,665 tests: 10,660 passed, 3 skipped, 2 failed — the pre-existing send-message-concurrency baseline and git-remote-sync's 15 s timeout under machine load (src/tools/os/git re-run alone with --no-file-parallelism: 16 files / 109 tests green)
  • F49: grammar carries the bound and the sentinel rule at 1,500, is unbounded at 0 (string size unchanged, ~5.3 KB — the expansion is server-side); the prompt tail for thinking: off on a qwen profile ends with the disabled marker and the grammar has the plain root; the trace field; existing grammar/prompt pins updated deliberately.
  • F50: [shell, reply] → turn continues, note recorded, next step runs, a sole reply then ends the turn; [reply, shell] same; [reply] alone ends; forced final [reply] ends; notice once per turn; streaming [note, final] yields one content delta with only the final text plus one progress_note event; without the change 5 of the 9 new executor/loop tests fail.

Stacked on #452 (harness/15-session-boundaries); merge in order.


Rebased with the whole stack onto main at v0.6.2 (2026-09-17). Full suite on the stack head (#459): 928/929 files, 10,815 passed, 1 failed — the pre-existing send-message-concurrency baseline.

Adds localModels.reasoningBudgetTokens (default 1500, 0 = unbounded,
else [64, 32768]) to the user file and the runtime config. Config v67
-> v68: the upgrade is additive (an older file has no field and takes
the default) and 67 joins the accepted input versions; the v67 step
(tools.shell.defaultTimeoutMs, agent.readScope) is untouched. The field
bounds the GBNF think prelude of a local reasoning model; the grammar
and prompt changes land in the F49 commit.
…onoured on the built prompt

A grammar-served reasoning model thought without bound before every tool
call: the fusion bench measured 500–7,152 reasoning tokens per step at
4.5 tok/s (84–96 % of each completion, one step of 22 minutes), and
`localModels.thinking` only reached server-templated prompts.

Grammar: the base grammar's prelude body is `think-char{0,N}` (Gemma:
`channel-char`) with N = reasoningBudgetTokens × 4 characters, single-width
units so the bound is in characters; past N only the close sentinel is
admitted. Budget 0 keeps the unbounded `think-fragment*` text. The grammar
string stays ~5.3 KB — llama.cpp expands `{0,N}` server-side into N nested
optional rules (6,000 at the default). Two per-request variants in
`reasoning-prelude.ts`: `withUnboundedReasoningPrelude` on the forced final
step (a reply/finish is never cut mid-thought) and `withoutReasoningPrelude`
under thinking: off. `buildGrammarForTools` keeps its per-base cache; the
base slot count grows to 8 for the variants.

Thinking switch: `qwen-think` carries `promptThinkingDisabledMarker`
(`<think>\n\n</think>\n\n`, the template's own disabled rendering);
`thinkingDisabledOnBuiltPrompt` keys `off` on it. The built prompt ends with
the marker, the request grammar takes the plain root, the completion is
parsed as starting outside a think block, the repair prompt strips and
re-appends the marker, and the prompt invariant accepts it. Gemma 4's turn
framing has no marker and is left as is; on/auto unchanged; the template
path keeps its own switch.

Visibility: `llm_raw_completion` and the `llm_completion` trace event carry
`reasoningTokens` — the text before the close sentinel priced at the same
four characters per token, so a cut step reads as `>= budget`. The TUI has
no output-token field on the step line, so nothing was added there.

Tests: build-grammar (bound, language up to and past the bound, budget 0,
string size, both variants, tool-name rewrite on variants), profile-manager
threading, server-template-policy predicate, profile-invariants marker,
build-prompt tail for off/on/auto/gemma/suppressed, step-executor (base
grammar, final-step lift, reasoningTokens, thinking: off end to end, repair
marker, thinking: on), trace-recorder forwarding, reasoning-budget units.
@plombeer31
plombeer31 force-pushed the harness/16-local-turn-shaping branch from 727baf2 to 4e45059 Compare September 16, 2026 22:03
@plombeer31
plombeer31 force-pushed the harness/15-session-boundaries branch from 40d52cd to 1ff5148 Compare September 16, 2026 22:03
@plombeer31
plombeer31 changed the base branch from harness/15-session-boundaries to main September 17, 2026 10:30
@plombeer31
plombeer31 merged commit 4fcc3b1 into main Sep 17, 2026
@plombeer31
plombeer31 deleted the harness/16-local-turn-shaping branch September 17, 2026 10:31
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