CI / checks (push) Skipped
CI / checks (pull_request) Successful in 5m14s
- A top-level message that isn't a command is a conversation: an item in the `general` scope (L1), threaded under the operator's message, with `reply`, `ask_human` and `propose_work` and no workspace or execution. `general` is reserved as a project name. - propose_work(project, title, summary) goes up as a revision for a go-ahead (or a plain yes). On go, under a file_issue intent (L8), the record issue is opened in that project, assigned to the agent and carrying the new item's ID; its item starts agreed, with the summary as its brief. Intake skips any issue carrying an ID (D1); boot adopts a filed issue or puts the proposal up again. - A conversation closes after tuning.loop.general_idle_s (default a day); a reply in its thread then starts a new conversation that continues it. Tests: conversation unit tests (threading, filing, the marker, an unknown project, changes, idle close and continuation, a plain yes, both crash cases, commands at top level); the walk with conversations, proposals and idle time, and records_have_one_item / conversations_do_no_work; an e2e conversation that becomes filed work. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
539 lines
26 KiB
Markdown
539 lines
26 KiB
Markdown
# Tuning Guide
|
||
|
||
How to fit a local model onto the inference host, and how to set the agent's
|
||
tuning knobs. Part 1 covers the ideas you need to reason about the hardware.
|
||
Part 2 covers each knob in `nisse-dev.yaml`. Part 3 gives an order to tune
|
||
in.
|
||
|
||
The knobs live under `tuning:` in the config file (see
|
||
`nisse-dev.example.yaml`). They are read at startup, so a change takes effect
|
||
on the next restart. Unknown or misspelt keys are startup errors.
|
||
|
||
---
|
||
|
||
## Part 1 — How a local model spends VRAM
|
||
|
||
A model server holding a model in GPU memory spends it on three things:
|
||
|
||
1. **Weights**, which are fixed for a given model and quantization.
|
||
2. **The KV cache**, which grows with context length.
|
||
3. **Working memory** for activations and runtime buffers. Budget 1–2 GB of
|
||
headroom.
|
||
|
||
On a 24 GB card, the whole game is trading (1) against (2).
|
||
|
||
### Weights and quantization
|
||
|
||
A model's weights take roughly *parameters × bits per weight ÷ 8* bytes.
|
||
|
||
| | 16-bit (full) | 8-bit | ~4.5-bit (Q4_K_M, AWQ) |
|
||
|---|---|---|---|
|
||
| 24B | 48 GB | 24 GB | ~14 GB |
|
||
| 32B | 64 GB | 32 GB | ~19 GB |
|
||
|
||
Quantization stores each weight in fewer bits. Common 4-bit formats come out
|
||
nearer 4.5–5 bits per weight once scaling factors are counted, so check the
|
||
actual file size. The quality cost is small at 5–6 bits, usually acceptable at
|
||
4, and it climbs quickly below that. For agent work the cost shows up first as
|
||
**worse tool-call discipline**, not as worse prose. The capability matrix
|
||
(Part 3) measures exactly that.
|
||
|
||
**Mixture-of-experts** models such as Qwen3-30B-A3B are different. Memory is
|
||
set by *total* parameters (30B), but speed is set by the parameters *active*
|
||
for each token (3B). An MoE model of a given size therefore generates much
|
||
faster than a dense model of the same size. It's often weaker than a dense
|
||
model of the same total size, but not always for tool use.
|
||
|
||
### The KV cache, and why context costs memory
|
||
|
||
As the model reads the prompt, every layer stores a key and a value vector for
|
||
every token, so that later tokens can attend to them without recomputing.
|
||
That store is the KV cache. Its size per token is:
|
||
|
||
2 (key and value) × layers × KV heads × head size × bytes per value
|
||
|
||
The numbers come from the model's `config.json` (`num_hidden_layers`,
|
||
`num_key_value_heads`, `head_dim`). Some worked examples at 16-bit:
|
||
|
||
| Model | Layers × KV heads × head size | Per token | 32k tokens |
|
||
|---|---|---|---|
|
||
| Qwen3-32B | 64 × 8 × 128 | 256 KB | ~8 GB |
|
||
| Mistral/Devstral Small 24B | 40 × 8 × 128 | 160 KB | ~5 GB |
|
||
| Qwen3-30B-A3B (MoE) | 48 × 4 × 128 | 96 KB | ~3 GB |
|
||
|
||
(Taken from the published configs; recheck against the model card before
|
||
relying on them.)
|
||
|
||
Two consequences:
|
||
|
||
- **Context length is a memory decision, not a free setting.** The spec's
|
||
estimate (the original build spec) was about 5 GB left for KV cache beside a 4-bit 32B model.
|
||
At 16-bit, that's only about 20k tokens of context for Qwen3-32B.
|
||
- **KV-cache quantization** stores keys and values in 8 bits (or 4). At 8
|
||
bits it halves the cache and is close to lossless. At 4 bits it is
|
||
noticeably worse. Quantizing the cache to 8-bit is usually the first lever
|
||
to pull, since it buys twice the context for almost nothing.
|
||
|
||
The server allocates the cache for the context length you configure. It
|
||
doesn't matter how much of it a request uses. So set the context length on the
|
||
server deliberately: it is the ceiling for *prompt plus response* on every
|
||
request.
|
||
|
||
### Prompt processing vs generation
|
||
|
||
A request has two phases, and they run at very different speeds:
|
||
|
||
- **Prefill** reads the prompt. It is parallel and fast: thousands of tokens
|
||
per second on a 4090.
|
||
- **Decode** writes the response one token at a time. Roughly 30–40 tokens per
|
||
second for a dense 32B at 4-bit, and several times that for a 3B-active MoE.
|
||
Treat these numbers as ballpark; measure your own.
|
||
|
||
An agent loop re-sends the whole transcript on every turn, which makes the
|
||
prompt long and the responses short (a tool call is often under 200 tokens).
|
||
So prefill matters more than the headline tokens-per-second suggests.
|
||
|
||
**Prefix caching** is the fix. If the start of a prompt matches the previous
|
||
request, the server reuses the KV cache for it and prefills only the new part.
|
||
llama.cpp does this per slot, and vLLM does it automatically. For this
|
||
workload it is often the single largest speed win. It works here because the
|
||
rendered transcript only grows at the end, so consecutive turns share
|
||
everything but their newest events.
|
||
|
||
### Tokens and characters
|
||
|
||
For English and code, a token is roughly **4 characters**. That's useful for
|
||
converting the character-based knobs below into context cost.
|
||
|
||
---
|
||
|
||
## Part 2 — The knobs
|
||
|
||
For each knob: what it controls, what goes wrong at either extreme, and what
|
||
to look at when deciding.
|
||
|
||
### `llm.max_output_tokens` — default 16384
|
||
|
||
The ceiling on a single response, for every role. The ceiling actually sent
|
||
is the smaller of this and the model's own `max_output_tokens` in LiteLLM, so
|
||
a local model can be held to less without affecting the frontier roles.
|
||
|
||
- **Why it exists:** a response that hits the ceiling is truncated
|
||
mid-sentence, or worse, mid-tool-call, where it can still parse. The agent
|
||
detects truncation and treats it as an error, but a ceiling that's too
|
||
tight still costs a failed turn. Planning with a frontier model was observed
|
||
using over 4,000 output tokens.
|
||
- **Too high, on a local model:** the server reserves room for the whole
|
||
response inside the context window. With a 32k context and a 16k ceiling,
|
||
only 16k is left for the prompt. Some servers reject any request where
|
||
prompt plus ceiling exceeds the context.
|
||
- **Too low:** truncated plans and tool calls.
|
||
- **Deciding:** executor turns are short, so 2–4k is plenty for a local
|
||
executor. Frontier planning wants the large default. Leave this high, and
|
||
set a local model's smaller ceiling as its `max_output_tokens` in LiteLLM.
|
||
|
||
### `llm.request_timeout_s` — default 600
|
||
|
||
How long one model request may take before it is abandoned. It applies to
|
||
every request, on both routes.
|
||
|
||
- **Too low:** a slow model's long answer is cut off, and the executor
|
||
escalates work that was going well. At 120 s, the old fixed value, a dense
|
||
27B model on the RTX 4090 timed out writing one module in one turn.
|
||
- **Too high:** a hung connection holds the milestone for that long before
|
||
anything notices.
|
||
- **Deciding:** this is a safety net, not a budget. `max_output_tokens` and
|
||
`max_turns` bound the work; set this above the slowest single answer you
|
||
expect, which is the model's output ceiling divided by its generation
|
||
speed.
|
||
|
||
### `tuning.executor.max_turns` — default 40
|
||
|
||
How many model calls one milestone may make before the executor stops with
|
||
`capped` and asks you what to do.
|
||
|
||
- **Too low:** milestones that were making progress stop short. You'll see
|
||
`capped` outcomes on work that was going well.
|
||
- **Too high:** a confused model keeps going. Every turn also adds its tool
|
||
calls and results to the transcript, so a long run spends context as well
|
||
as time.
|
||
- **Deciding:** look at how many turns *successful* milestones take, and set
|
||
the cap comfortably above the high end, around 1.5–2× the turns of your
|
||
longer successful runs. A stronger model usually needs fewer turns, not
|
||
more.
|
||
- **Measured:** the default was 24 until the harder benchmark tasks arrived.
|
||
Haiku, used as the ceiling in calibration, hit 24 on both runs of
|
||
`refactor-rename` (editing ten files one at a time) and of
|
||
`long-milestone`, one of which had in fact finished the work. It is a guard
|
||
against a model trying forever, not a budget, so it was raised to 40.
|
||
|
||
### `tuning.executor.failure_limit` — default 2
|
||
|
||
How many consecutive failed `run_command` calls trigger escalation: the
|
||
sub-task is handed to a frontier model as a delegation (architecture L9). A
|
||
successful command resets the count. Failed file reads don't count, since
|
||
reading is looking around, not doing the work.
|
||
|
||
- **Why it's a count and not a judgment:** small models are poorly calibrated
|
||
about their own competence. Asked "are you stuck?", they say no until far
|
||
too late, or yes constantly. A count of observable failures is a signal
|
||
that doesn't depend on the model's opinion of itself.
|
||
- **Too low:** normal iteration escalates. Test-first work *expects* a failing
|
||
test run before the fix, so a limit of 1 hands off routine work and spends
|
||
frontier tokens on it.
|
||
- **Too high:** a weak model circles, re-running the same failing command
|
||
with small variations, while the transcript fills up.
|
||
- **Deciding:** raise it as local models get stronger. Watch two things
|
||
together. First, how often delegations fix what the local model couldn't
|
||
(escalation is doing its job). Second, how often the local model recovers
|
||
on its own after its second failure (the limit may be too low).
|
||
|
||
### `tuning.expert.consult_budget` — default 3
|
||
|
||
How many `ask_expert` calls one milestone may make. `ask_expert` is a single
|
||
stateless question to a stronger model, with no access to the repository
|
||
(architecture L9), for things like "is this API deprecated?".
|
||
|
||
- **Too low:** the executor can't get unstuck on a knowledge question it
|
||
can't answer from the code.
|
||
- **Too high:** a confused executor asks variations of the same question
|
||
instead of doing the work or failing. That's the circling L9 warns about.
|
||
- **Deciding:** count consults per milestone, and check whether the answers
|
||
changed what the executor did next. If it regularly exhausts the budget
|
||
without progress, the budget is fine and the model or the plan is the
|
||
problem.
|
||
|
||
### `tuning.delegation` — hard caps on one delegation
|
||
|
||
A delegation is a frontier model working a sub-task with full tool access
|
||
(architecture L9). When a cap is hit, the delegation **fails**, and its branch and
|
||
work are discarded, because resuming on a half-edited tree isn't safe.
|
||
|
||
| Knob | Default | Caps |
|
||
|---|---|---|
|
||
| `max_turns` | 30 | model calls |
|
||
| `max_tokens` | 200000 | input + output tokens, as reported by the provider |
|
||
| `wall_clock_s` | 900 | elapsed time |
|
||
|
||
- **`max_tokens` is a scope limit, not the money limit.** Spending limits live
|
||
on the LiteLLM key (architecture L9). This cap stops one delegation from growing
|
||
without bound. An unbounded one could still exhaust a daily budget and take
|
||
every frontier model away until it resets.
|
||
- **Too low:** delegations fail just short of finishing and their work is
|
||
thrown away. That is the most expensive way to be wrong, because you paid
|
||
for the work.
|
||
- **Too high:** a stuck delegation burns tokens and time before failing.
|
||
- **Deciding:** every delegation records the tokens it used and how long it
|
||
ran (the `delegation_completed` event, captured for evaluation per spec
|
||
L9). Set each cap above what successful delegations actually use, with
|
||
room to spare.
|
||
|
||
### `tuning.tools.command_timeout_s` — default 120
|
||
|
||
How long one `run_command` may run before its whole process group is killed.
|
||
|
||
- **Too low:** slow test suites, dependency installs and Docker builds get
|
||
killed and reported as failures. That also counts toward `failure_limit`.
|
||
- **Too high:** a command that never exits blocks the turn for the whole
|
||
timeout, such as a dev server started in the foreground or a test waiting on
|
||
input. `stop` waits for a running command to finish too, so this is also the
|
||
worst-case delay before a stop takes effect.
|
||
- **Deciding:** set it just above the slowest legitimate command in the
|
||
projects the agent works on.
|
||
|
||
### `tuning.tools.max_output_chars` — default 4000
|
||
|
||
How much of a tool's output (command output, file contents, directory
|
||
listings) the model sees. Longer output keeps its first quarter and last three
|
||
quarters, with a note in between saying how much was left out. The end gets
|
||
the larger share because test runners and compilers print their summary of
|
||
what failed last; the start keeps the exit code.
|
||
|
||
- **This is a context knob.** Every tool result stays in the transcript and is
|
||
re-sent every turn. 4,000 characters is about 1,000 tokens, so 20 turns of
|
||
maximal output is about 20k tokens, which is already most of a local
|
||
model's context.
|
||
- **Too low:** the model doesn't see the part of the output that matters.
|
||
- **Too high:** the transcript outgrows the context window sooner. The
|
||
context budget copes (see below), but by cutting older output harder.
|
||
|
||
### `tuning.context` — fitting each request into the window
|
||
|
||
Before any request is sent, the agent checks it against the model's context
|
||
window and makes it fit. A server doesn't always refuse a prompt that's too
|
||
long. Ollama silently drops its start, which is where the system prompt and
|
||
the plan are, so an unfitted request can fail in a way nobody sees.
|
||
|
||
The window is each model's `max_input_tokens` in LiteLLM (see *Declaring a
|
||
local model's limits* below). A model in use without one stops the agent at
|
||
startup. From the window come the output ceiling and a safety margin, and
|
||
the rest is for the prompt. When the prompt is too big, three steps apply in
|
||
order, stopping at the first that fits:
|
||
|
||
1. **Older tool results are cut** to about 600 characters, head and tail.
|
||
The most recent turns keep their output whole.
|
||
2. **The oldest turns are dropped,** tool calls together with their results.
|
||
A note at the start tells the model how many events were left out. The
|
||
log keeps everything; only the request is shortened.
|
||
3. **If the system prompt and the recent turns still don't fit,** the
|
||
request is not sent. The milestone stops and asks you.
|
||
|
||
Tokens are estimated from characters (pessimistically, at 3 per token) and
|
||
the estimate is corrected from the prompt sizes the server reports. The
|
||
correction only ever makes the estimate larger.
|
||
|
||
The executor also sends less in the first place: the approved milestone plan
|
||
goes in its system prompt, which is never cut, and it sees only what happened
|
||
since that plan. The planning conversation and earlier milestones stay in the
|
||
log.
|
||
|
||
| Knob | Default | |
|
||
|---|---|---|
|
||
| `keep_recent_turns` | 6 | model turns that are never cut or dropped |
|
||
| `safety_margin` | 0.1 | fraction of the window held back for estimation error |
|
||
|
||
- **`keep_recent_turns` too low:** the model loses sight of what it just did
|
||
and repeats itself. **Too high:** less room to cut before step 3 gives up;
|
||
on a small window, overflows come sooner.
|
||
- **`safety_margin` too low:** an underestimate reaches the server, and a
|
||
silently cutting server drops the start of the prompt. **Too high:** wasted
|
||
context. Lower it only once the correction from reported usage has settled,
|
||
which takes a few calls per model.
|
||
- **Deciding:** if milestones stop with "cannot fit", look first at
|
||
`tools.max_output_chars` and the model's window, not at these.
|
||
|
||
### Enforced tool calls — `supports_tool_choice`, declared in LiteLLM
|
||
|
||
The agent's loops depend on every turn being a well-formed call to a real
|
||
tool. A model only *writes text*: a "tool call" is text in a format the model
|
||
was trained to produce, which the server recognises and turns into a
|
||
structured call. By default (`tool_choice: auto`) the server just parses
|
||
whatever comes out, so prose, broken JSON and calls to invented tools are all
|
||
possible.
|
||
|
||
With `tool_choice: required`, a server that supports it **constrains
|
||
decoding**: at every step it rules out any token that couldn't lead to a
|
||
valid call to one of the offered tools, with arguments matching its schema.
|
||
The model still chooses the tool and the arguments; it can't produce anything
|
||
else. That makes it a property of the **model server** (and sometimes of the
|
||
model), not something a prompt can buy.
|
||
|
||
So the agent asks for it only where it is known to work: models whose LiteLLM
|
||
entry declares `supports_tool_choice: true`. Measured on the 4070:
|
||
|
||
| Alias | Server | `required` on a chat prompt | Declare |
|
||
|---|---|---|---|
|
||
| `local-fast` (Qwen3-8B-AWQ) | vLLM 0.29 | calls `finish` every time | `true` |
|
||
| `local-coder` (Qwen3-14B) | llama.cpp | can't stop; writes until the output ceiling (a known Qwen3-14B bug) | `false` |
|
||
| `claude-haiku-4-5` | Anthropic | calls `finish` every time | `true` (LiteLLM already says so) |
|
||
|
||
Asking a model that can't honour it can be worse than not asking: the
|
||
Qwen3-14B turn above costs a full output ceiling of generation, where a plain
|
||
prose reply would have cost a few hundred tokens.
|
||
|
||
Where it isn't enforced, the executor holds the contract itself:
|
||
|
||
- **`finish` is the only way to end a milestone.** It is refused once if
|
||
nothing has been changed yet.
|
||
- **A prose reply is a violation.** The model is told once; a second escalates.
|
||
- **A call to a tool that wasn't offered counts as a failure** toward
|
||
`failure_limit`, like a failed command, so an inventing model escalates in a
|
||
couple of turns instead of using up `max_turns`.
|
||
|
||
The capability matrix checks the declaration: a model declared `true` that
|
||
answers a required call in prose fails
|
||
`test_supports_tool_choice_is_declared_truthfully`.
|
||
|
||
### Declaring a local model's limits
|
||
|
||
LiteLLM knows commercial models' limits. For a local model, declare them in
|
||
LiteLLM's config as the model's `model_info`:
|
||
|
||
```yaml
|
||
model_list:
|
||
- model_name: local-fast
|
||
litellm_params: {model: openai/qwen3-8b-awq, api_base: http://inference-host:8080/v1}
|
||
model_info:
|
||
max_input_tokens: 32768 # the server's per-request context
|
||
max_output_tokens: 4096 # the most one response may use
|
||
supports_function_calling: true
|
||
supports_tool_choice: true # only once measured; see above
|
||
```
|
||
|
||
`max_input_tokens` is the *whole* window, prompt plus response. The agent
|
||
takes the output ceiling out of it. That's how the commercial numbers work
|
||
too: Claude's prompt and response together must fit in its 200k.
|
||
|
||
The number to use is what the **server** allocates per request, not the
|
||
model's advertised maximum:
|
||
|
||
- **The model's ceiling:** `max_position_embeddings` in `config.json`, or
|
||
`<arch>.context_length` in a GGUF file. Some models reach further with RoPE
|
||
scaling; the model card says whether that needs switching on.
|
||
- **llama.cpp / llama-swap:** the `-c` / `--ctx-size` flag, shown as `n_ctx`
|
||
in the load log and in `GET /props`. With `--parallel N` each request gets
|
||
`n_ctx / N`, so run the agent's models with `--parallel 1`.
|
||
- **vLLM:** `--max-model-len`, reported by `GET /v1/models`.
|
||
- **Ollama:** 4096 unless `OLLAMA_CONTEXT_LENGTH` or a Modelfile's `num_ctx`
|
||
says otherwise. `GET /api/ps` shows the loaded model's `context_length`;
|
||
`ollama show` shows only the trained maximum.
|
||
|
||
Then check the declaration against the server:
|
||
|
||
uv run python scripts/probe_context.py local-fast
|
||
|
||
It sends one prompt half again as long as the declared window and reports
|
||
whether the server refused it, cut it (and where), or took it all. A cut
|
||
below the declaration is the case to fix: prompts between the two would be
|
||
silently cut. Measured on the Jetson's `llama3.2`: cut at 4096, matching its
|
||
declaration.
|
||
|
||
---
|
||
|
||
## Benchmarking models
|
||
|
||
The capability matrix asks whether a model *can* hold the tool contract. The
|
||
benchmark asks whether it *finishes real work*: eight canned milestones, each an
|
||
approved plan over a small seed project, judged by a hidden check the model
|
||
never sees. Success means the check passes, not that the model said it was
|
||
done.
|
||
|
||
| Task | What it exercises |
|
||
|---|---|
|
||
| `write-function` | write a small function and its tests |
|
||
| `fix-bug` | find and fix the bug a failing test exposes, without touching the test |
|
||
| `multi-file` | thread a new option through several files |
|
||
| `long-output` | find one failure in test output far longer than `max_output_chars` |
|
||
| `trace-bug` | follow a wrong total to its cause in a shared helper, and fix it there rather than at the symptom |
|
||
| `refactor-rename` | rename a function across a ten-module package, sparing two similar names a blind replace would break |
|
||
| `spec-parser` | implement a parser from an eight-rule written spec when the plan gives only three examples, with tests |
|
||
| `long-milestone` | carry out eight dependent steps (model, storage, sorting, CLI) over 25+ turns |
|
||
|
||
The first four were the original set. By September 2026 all seven newer local
|
||
models passed all of them, so the last four were added to separate models that
|
||
can hold the tool contract from models that can do a milestone's real work.
|
||
Their plans stay explicit, as the planner is told to write them; what makes
|
||
them harder is the work itself: tracing a cause, searching a codebase,
|
||
reading a spec closely, and holding a plan over many turns.
|
||
|
||
It runs the executor exactly as the agent does (the same client wiring,
|
||
context budget and `tool_choice` handling, and your config's tuning knobs),
|
||
with **delegation and `ask_expert` turned off**. An escalation is a failure of
|
||
the model under test, not a frontier model's success.
|
||
|
||
**Evaluating a new model** is one command, once its LiteLLM entry declares its
|
||
limits:
|
||
|
||
scripts/evaluate_model.sh qwen3-32b-q4 "vLLM 0.29, AWQ, fp8 KV" 5
|
||
|
||
It runs the context probe, then the capability matrix, then five runs of
|
||
every task, and stops at the first step that fails. Or run the steps
|
||
yourself:
|
||
|
||
uv run python scripts/benchmark.py run --models A,B --runs 5 --note "how it's served"
|
||
uv run python scripts/benchmark.py report [--models A,B] [--tasks T]
|
||
|
||
**Results are kept.** Every run is one line in `bench/results/`, committed
|
||
with the repo, recording:
|
||
- the model's backing name and declared facts;
|
||
- your note on how it was served;
|
||
- the task's content hash;
|
||
- the agent's commit and tuning;
|
||
- the outcome, turns, failures, tokens, time and cost.
|
||
|
||
The report rebuilds the table from every stored run. Benchmarking a new model
|
||
means running only that model and reading it next to everything before it.
|
||
Runs against an older version of a task are marked `(old)`. Runs of one model
|
||
served differently (different note) are kept apart.
|
||
|
||
**Reading it:**
|
||
- **Compare the intervals, not the rates.** 3 of 5 (23–88%) and 5 of 5
|
||
(57–100%) overlap: five runs don't separate them. Add runs until the
|
||
intervals stop overlapping, or accept that the models are close.
|
||
- **Look at why runs failed**, not just how many (`outcome_note` and the
|
||
counts in each result line). An early shakedown on `local-fast` failed
|
||
mostly by escalation: the model was fixing its own mistakes and hit
|
||
`failure_limit: 2` mid-recovery. That says as much about the knob as about
|
||
the model. Tuning a knob and re-running is a fair comparison, because the
|
||
tuning is recorded with every run.
|
||
- **Cost** is per run, straight from LiteLLM, so a frontier model as executor
|
||
can be compared on success rate *and* dollars.
|
||
|
||
---
|
||
|
||
## Part 3 — An order to tune in
|
||
|
||
Upstream choices change what's right downstream, so tune in this order and
|
||
change one thing at a time.
|
||
|
||
1. **Model and quantization.** Start with whether a candidate holds the
|
||
tool-call contract at all, and whether its server enforces `tool_choice`:
|
||
|
||
uv run pytest -m integration tests/integration/test_model_capability.py \
|
||
--capability-models=alias-a,alias-b
|
||
|
||
A model that falls back to prose instead of calling tools is disqualified,
|
||
however good its benchmarks look. Before comparing aliases, check what
|
||
each one actually points at in LiteLLM (`/model/info`, the
|
||
`litellm_params.model` field). Two aliases believed to be the same model
|
||
once weren't, and that cost a lot of debugging.
|
||
|
||
**Make sure LiteLLM has no fallback for a model you are measuring.** A
|
||
fallback answers a failed request with another model, under that model's
|
||
name, in a response that otherwise looks normal. One made a local model
|
||
that couldn't call tools at all look like it "held the tool contract 20 of
|
||
20": every answer was Claude's. The capability matrix and the probe now
|
||
check which model answered and fail on a mismatch, but the fallback also
|
||
quietly turns local work into frontier spending in production.
|
||
2. **Context length and KV-cache precision** on the server. Take the largest
|
||
context that fits beside the weights with 1–2 GB of headroom. Try an 8-bit
|
||
KV cache before giving up context.
|
||
3. **Declare the window** in LiteLLM and check it with the probe, then set
|
||
the model's `max_output_tokens` there so the prompt keeps most of the
|
||
window.
|
||
4. **The benchmark.** Run it and look at the evidence: success rate, turns,
|
||
escalations, and time taken.
|
||
5. **The loop knobs**: `failure_limit`, `max_turns`, `consult_budget`, the
|
||
delegation caps, then the tool limits, one at a time.
|
||
|
||
**Expect noise.** Local model behaviour varies a lot from run to run. One
|
||
early comparison measured 12 of 20 runs holding the tool contract against 20
|
||
of 20, a real difference (p ≈ 0.003). Three runs each could easily have
|
||
pointed the other way. Before changing a knob because of a result, repeat the
|
||
run enough times that the result would survive a coin flip.
|
||
|
||
---
|
||
|
||
## Coming next
|
||
|
||
Later work that will add knobs, each with its own section here:
|
||
|
||
- **Compaction.** A model-written summary replacing old turns, instead of
|
||
dropping them. The log already has an event for it; it needs a summarizing
|
||
model and a policy for what is safe to summarize.
|
||
- **Benchmarking the other roles:** planner and delegate, with delegation
|
||
turned back on for comparisons across frontier tiers.
|
||
|
||
### `tuning.loop.bug_grace_s` — default 600
|
||
|
||
How long the restatement of an issue labelled `bug` waits for a reply before
|
||
the agent goes ahead on its own (architecture D1). Any reply in the item's
|
||
thread stops the countdown, and `go` starts at once.
|
||
|
||
- **Too low:** you can't read the restatement before the fix starts, so a
|
||
misread report costs a wasted milestone.
|
||
- **Too high:** bugs wait as long as features, and the label buys nothing.
|
||
- **Deciding:** about the time it takes you to notice a message and read it
|
||
when you're around. A wrong start costs only time, since the fix still comes
|
||
as a PR you review.
|
||
|
||
### `tuning.loop.general_idle_s` — default 86400
|
||
|
||
How long a `general` conversation (one not about a project, architecture L1)
|
||
stays open without a message. It then closes with a note in its log; a later
|
||
reply in its thread starts a new conversation linked to it.
|
||
|
||
- **Too low:** a conversation you come back to after lunch has to be picked up
|
||
again from its summary.
|
||
- **Too high:** idle conversations pile up in `status` and in the queue.
|