Skip to content

Model router with preference learning data capturing - #172

Draft
laminair wants to merge 7 commits into
devfrom
simple-router
Draft

Model router with preference learning data capturing#172
laminair wants to merge 7 commits into
devfrom
simple-router

Conversation

@laminair

@laminair laminair commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

What the router does, conceptually

The router picks the coding model (Claude → Anthropic tiers; OpenCode → whatever providers you have configured, including a local Ollama qwen3:27b) per session and per turn, and learns from the user's own behavior which model serves them best for which kind of task. The summarizer stays one fixed cheap model — it gets a second job as the judge that produces a structured verdict on every committed turn, on the same worker thread that already writes the commit summary (no extra round-trip when the trace contains an obvious "no, that's wrong").

The shape mirrors how an experienced developer would choose a model: try a model, see what happened, prefer the one that paid off in situations like this. The user never has to rate anything; corrections, re-prompts, discards and reverts are all signals. Explicit 1-5 ratings (Ctrl-G rate or the dashboard) are the user's most direct vote.

Where the signal comes from

Every aGiTrack turn already produces the interaction trace the commit carries (the same ## User / ## Agent text). On the existing summary worker, the judge reads that trace and returns a tight JSON verdict:

{ "task_class": "debug", "complexity": "medium",
  "correction": "explicit_negative", "evidence": "wrong file" }

A loud negative trace ("no, that's wrong — undo that") is caught by a substring heuristic before the model call so the small model isn't even invoked for the obvious cases. The harder cases go to the model. The verdict feeds three things:

  • the policy score for the current (model, task_class) cell (EMA blending at ≥3 per-class samples so per-class EMAs dominate the model-wide one once you have enough data);
  • the router's hint when a different model would score better (visible in the status bar, Ctrl-G → model lets the user switch);
  • the audit feed on the new /routing dashboard panel, alongside the per-model quality table and a rating widget.

Where the model signal comes from

A turn records exactly one (or more) of these:

Signal Source Weight
explicit rating (1-5) Ctrl-G → rate or /routing strongest
judge: explicit negative / redo the summarizer-as-judge strong negative
judge: clean turn the summarizer-as-judge weak positive
discard (Esc → "Yes, discard") the runner strongest negative — the user threw the work away
revert (git revert of an <aGiTrack> commit) new collect.py pass strong negative
redo_followup (re-prompt) the judge reads the queued follow-up medium
post_agent_edit (user patches the worktree right after a turn) the runner weak — could be unrelated
cancel (Esc, kept the work) the runner medium negative
switch / reroute the runner neutral — the next turn's rating is the real signal

Each is folded into the per-model and per-(model, task_class) quality via an exponential moving average (alpha 0.3, quality in [0, 1], cold-start prior 0.55). The event ring buffer caps at 500 so the file stays bounded.

How a decision is made

ModelRouter.choose() scores every model in the user's pool (auto-seeded from the active backend's own model list: Claude → the curated tiers; OpenCode → whatever the user has configured there, including a local Ollama provider):

final = sample(quality_ema, exploration/total_attempts) − λ · cost_tier

with a Thompson-sampling bonus that shrinks as attempts grow (so unknown models are still tried occasionally, well-trodden models are picked deterministically). With ≥3 samples for a (model, task_class) cell, the per-class EMA dominates the model-wide one (70/30 blend) — the router learns "haiku is great at small edits, opus at big refactors" from a few sessions of normal use.

Three modes:

  • off (default) — the judge still runs and records signals, but the router never switches models. Lets a curious user build a profile without surprises.
  • suggest — status-bar hint when another model would score better; Ctrl-G → model opens a pool picker with a "router suggests" badge; switching happens on user demand.
  • auto — between turns, when the agent is idle, the runner types the backend's own model-switch command into the TUI (/model <name> for Claude, ctrl+x m then typing for OpenCode's model dialog), with a relaunch-with---model fallback when the picker UI can't be scripted deterministically. Per-turn auto-routing in headless json/bridge mode too.

Why the summarizer-as-judge

The summarizer is already running on every committed turn in every mode (proxy async, background synchronous, shell inline). It's a cheap model, it's in bare mode with no tools or memory, and it shares the summary worker's thread and lock — so adding a second bare call costs "one small completion per turn" and nothing else. The judge shares the same backend, the same model, the same scratch dir, and the same UnusableSummaryError / prompt-echo guards as the commit summary, so a tiny local model that echoes its prompt is detected and recorded as usable=False (no signal) rather than corrupting the store.

Pool topology

Per the user's design choice: the pool is derived from the active backend, never a global "which pool" question. The router never asks "which pool" — when you're on Claude, the pool is Anthropic tiers; when you're on OpenCode, the pool is whatever providers you've configured there, including a local Ollama qwen3:27b. Cross-backend pool mixing was deliberately not done; it would require either new ProxyAgent machinery per local HTTP backend or a heavy OpenCode-everything push.

What ships in v0.6.0.dev1

  • New package agitrack/routing/ (~1,500 lines): store, judge, policy, router facade, signals, switch — all best-effort by contract.
  • New dashboard panel at /routing: per-model quality table, event feed, rating widget, sync toggle.
  • Two new Ctrl-G commands: model (pool picker, router-suggested badge) and rate (1-5).
  • New settings-menu entries: routing_mode, routing_pool (JSON), routing_allow_cloud, routing_judge, routing_sync.
  • Judge wired into the proxy, background, and shell summary workers; in-TUI model switching from the runner.
  • Implicit signals wired at the existing discard / cancel sites.
  • New per-user store .agitrack/routing.json (git-ignored, per-GitHub-user keyed) with optional orphan-ref sync to refs/agitrack/routing-prefs (mirrors the learn-page pattern).
  • 67 new tests (store / judge / policy / switch / router facade / signal recorders / runner integration / dashboard endpoints / real-CLI smoke) — bash scripts/check.sh green, 2201 tests pass.

Why a persistent signal store, not a volatile cache

The store follows the same shape as the learn page's learning.json: per-user, git-ignored, optional orphan-ref sync so preferences follow the user across machines. A fresh clone restores your profile the first time you open /routing. Empty local profile never overwrites a synced remote; the local store is the source of truth, rebuilt on every push.

Risks called out

  • In-TUI model switch is version-sensitive: the documented /model <name> and ctrl+x m sequences are verified against current Claude and OpenCode releases, but a future backend TUI revision could break the byte sequence. The runner falls back to relaunch-with---model when injection can't be verified, so a drift degrades to "auto mode" being a relaunch instead of a mid-session swap — never a crash, never a stuck model.
  • Judge on a tiny local model is noisy: the guard chain (heuristic → model → strict JSON schema → echo detection) reduces the noise, but the router is intentionally conservative in auto mode (a default min_margin of 0.05 keeps marginal wins from triggering disruptive mid-session switches).
  • The judge adds a small token cost per turn: a 12k-char trace cap, a 1.5k-char response cap, and a 90s bare-run timeout. The summarizer's existing cost accounting is extended so the judge line items land on the commit's summary_tokens_* fields alongside the commit summary's. Users who don't want the router to learn anything can set routing_judge: false to silence the call while keeping the router scaffolding.

Demo flow

  1. agitrack — open a session in claude (or opencode), coding on dev.
  2. Use the agent for a turn, give it a 5-star rating (Ctrl-G → rate, or on /routing). The signal lands in .agitrack/routing.json under your GitHub id.
  3. Ctrl-G → model shows the pool with a "router suggests" badge on the model that scores highest for the kind of task the last turn was.
  4. Switch to a local Ollama qwen3:27b. aGiTrack types /model ollama/qwen3:27b into the TUI; the backend updates the model line, the next turn runs on qwen.
  5. The qwen turn lands a commit; the runner records the verdict; the next Ctrl-G → model call already sees a richer profile (debug: claude wins, small edits: qwen is fine).
  6. On a different machine, the synced profile is restored from refs/agitrack/routing-prefs the first time you open /routing.

laminair added 7 commits July 18, 2026 21:21
# aGiTrack Metadata
commit_type: user
backend: agit
agitrack_session_id: agitrack-3cb5acb1-51a7-44d1-883f-ca8cda0755c6
system: Ubuntu 26.04
agitrack_version: 0.5.0
I would like you to pay special attention to the summarizer mechanism.
In this session, I would like to develop an idea how we can route
between models. Imagine, I have a local model (e.g., qwen 3.5 27B), and
two models in the cloud (e.g., kimi k3, claude opus). My idea is to
learn preferences from users as users use agitrack and we collect commit
 messages. We can use a user's corrections as a signal to derive when a
model failed. / Please continue / go ahead. You are free to implement
all. Bump the verion to v0.6.0.dev1 / Well, this is a design flaw we
currently have. There are ways to use "/models" to switch between models
 in the tui. Maybe we can find a way to emulate that and switch model
using these comands? / That should depend on the backend used by the
user. When it's claude, we want to route between Anthropic models, when
using OpenCode, we want to route between available models in opencode.

# Interaction Trace

## User

Please analyze the code repo. I would like you to pay special attention
to the summarizer mechanism. In this session, I would like to develop an
 idea how we can route between models. Imagine, I have a local model
(e.g., qwen 3.5 27B), and two models in the cloud (e.g., kimi k3, claude
 opus). My idea is to learn preferences from users as users use agitrack
 and we collect commit messages. We can use a user's corrections as a
signal to derive when a model failed.

## User

Please continue

## Agent

All loose ends are tied. Here is the complete design, built on what the
exploration verified and your three answers.

### Design: Coding-Model Router with Preference Learning

#### Core idea in one paragraph

The router chooses the **coding model** per session/turn from the active
 backend's own model list (Claude → Anthropic tiers; OpenCode → whatever
 providers are configured, incl. a local Ollama qwen). The **summarizer
stays one small cheap model**, but gets a second job as the **judge**:
it already reads every interaction trace, so after each turn it also
extracts a structured quality signal (task class, complexity, whether
the user corrected the agent). Combined with explicit ratings and
implicit signals (discards, reverts, redo-follow-ups), a per-user
preference store learns which model serves *this* user best for *which*
kind of task, and the router exploits + explores that. Preferences sync
across machines via an orphan git ref, exactly like the learn page's
progress sync.

#### Architecture

##### New package `agitrack/routing/`

**`store.py` — `RoutingStore`** (modeled directly on
`metrics/learn.py::LearnStore`):
- `.agitrack/routing.json`, git-ignored via `ensure_local_ignore()`,
atomic writes, per-user profiles keyed by GitHub id (reuse the learn
page's identity resolution).
- Profile: per model `{attempts, quality_ema, ratings[], discards,
reverts, judge_flags, by_task_class: {debug: {n, ema}, ...}, cost_tier}`
 plus a ring-buffer event log (~last 500) for auditability.
- Optional sync (default off): orphan ref `refs/agitrack/routing-prefs`,
 `<repo-fingerprint>/<gid>/routing.json`, lease-guarded push, restore-
on-empty-local, same code shape as `learn.py:741-869`.

**`judge.py` — `TurnJudge`** (the "summarizer generates signals on the
fly" part):
- Runs on the **existing summary worker threads**
(`ProxyRunner._start_commit_summary`, background `agit-bg-summary`, sync
 shell/recovery paths), right after the summary call, using the **same
cheap summarizer backend+model**. Zero new latency on the user's path,
one extra bare call of a few hundred tokens per turn, skippable via
config.
- Input: the interaction trace (already rendered for the commit) +
outcome flags the runner already knows (cancelled, discarded, queued
follow-ups).
- Output: strict JSON `{task_class, complexity, correction:
none|explicit_negative|redo|clarification, evidence}`; validated with
the existing `summary_is_usable`/echo guards; parse failure = no signal
(never blocks the summary).

**`policy.py` — `ModelRouter`**:
- `choose(task_features, pool, store) -> model`: score =
`quality_ema(model, task_class)` − λ·`cost_tier` + exploration bonus
(Thompson sampling on Beta posteriors per (model, task_class)); cold-
start priors by tier (cheap/local optimistic for small tasks, cloud for
large).
- Pool comes from the **active backend**: Claude → the curated tier list
 (`model_select.py` already hardcodes haiku/sonnet/opus); OpenCode →
`list_available_models("opencode")` (your Ollama qwen and kimi providers
 appear automatically). User prunes/reorders in settings. Hard filter:
`routing_allow_cloud: false` → local/ANTHROPIC-only entries.
- Modes: `off | suggest | auto` (config `routing_mode`, default
`suggest`).

**`switch.py` — in-TUI model switching** (addressing the design flaw you
 named):
- aGiTrack owns the PTY and already injects text into it (merge-conflict
 prompts, serialized by `BackendProcess._write_lock`), so between turns
it can **type the backend's own model-switch command**: `/model …` in
Claude's TUI, the models picker sequence in OpenCode's TUI. The exact
byte/key sequences per backend are a **research item to verify against
the real CLIs** (AGENTS.md mandates real-backend verification anyway);
the pyte screen mirror lets aGiWatch confirm the new model is shown
before forwarding the next prompt.
- Guardrails: only when `not _agent_is_active()`, never mid-turn; on any
 uncertainty, **fallback = relaunch spawn with `--model`** (both `claude
 --model` and `opencode --model` accept a launch model; `spawn_command`
gains an optional `model=` param).
- Headless modes (json/bridge/shell) get true per-turn routing:
`router.choose()` before each `backend.run(model=…)`.

##### Implicit + explicit signals

| Signal | Source (existing hook) | Weight |
|---|---|---|
| Judge correction/accept | `TurnJudge` on summary worker | high |
| Explicit 1–5 rating | Ctrl-G `rate` command + dashboard widget |
highest |
| Discard after Esc | `_handle_cancelled_turn` | high |
| `git revert` of an agent commit | new pass in `metrics/collect.py` |
high |
| Redo follow-up ("no, I meant…") | judge reads queued follow-ups in
trace | medium |
| Post-agent user edits before next prompt |
`_pre_agent_commit_if_needed` | low (noisy) |

##### UX surface
- Status-bar hint after a turn when another pool model outscores the
current one by a margin: "router: claude-opus-4-8 scores higher for
refactor tasks (Ctrl-G model)". Non-modal, expires like other notices.
- New Ctrl-G `model` command: pool list with learned scores + router-
recommended badge; switching injects the in-TUI switch (or relaunches).
Palette check: `m` prefix is free, but the palette order/prefix rules
mean `docs/user-flow.md` and the command-order tests get updated in the
same change.
- New Ctrl-G `rate` command (1–5) + status-bar hint after turns;
dashboard gets a routing panel (per-model EMA, event feed, rating
widget, new POST endpoints on the metrics server).

##### Config (all repo-overridable, added to `_settings_specs`)
`routing_mode` (off/suggest/auto), `routing_pool` (per-backend list,
auto-seeded), `routing_sync` (bool), `routing_exploration` (0–1, default
 0.1), `routing_judge` (bool, default on).

#### Phasing

1. **Foundation**: `RoutingStore`, `TurnJudge` on the summary workers,
implicit-signal wiring (discard/revert/redo), config keys. Learns
silently; no behavior change.
2. **Suggest**: policy scoring, status-bar hints, Ctrl-G `model`/`rate`
commands, dashboard panel + rating endpoint.
3. **Auto**: PTY switch injection (real-CLI verification first, relaunch
 fallback), per-turn routing in headless modes, pref sync.

**Testing/docs obligations per AGENTS.md**: real-git + real-backend
verification (both CLIs, incl. a real Ollama qwen), new
`tests/test_routing_*.py`, `tests/FLOW_MATRIX.md` rows for each new
flow, `docs/user-flow.md` diagram updates, README + AGENTS.md updates,
no em-dashes in shipped prose, `./scripts/check.sh` green.

**Main risk**: determinism of the in-TUI `/model` switch across backend
versions (picker UIs drift). Mitigation: screen-scrape confirmation +
relaunch fallback, and the switch code isolated per backend so a drift
breaks only auto-mode, degrading to suggest.

Shall I proceed with implementation, and if so, start with Phase 1
(foundation) or implement all three phases in one branch?

## User

go ahead. You are free to implement all. Bump the verion to v0.6.0.dev1

## User

Well, this is a design flaw we currently have. There are ways to use
"/models" to switch between models in the tui. Maybe we can find a way
to emulate that and switch model using these comands?

## User

That should depend on the backend used by the user. When it's claude, we
 want to route between Anthropic models, when using OpenCode, we want to
 route between available models in opencode.

# aGiTrack Metadata
commit_type: agent
backend: opencode
model: opencode-go/minimax-m3
reasoning_effort: on
session_name: wisdom
agitrack_session_id: agitrack-a66be59e-77ca-49b2-b149-23ad1c6d386f
backend_session_id: ses_0894da2d7ffeY5BONcAmuMfk7i
conversation_anchor: msg_f7952a802002FkthcUKo9Ulh44
agent_started_at: 2026-07-18T19:27:08Z
agent_ended_at: 2026-07-19T07:41:27Z
context_tokens: 665
tokens_since_last_commit_input: 344994
tokens_since_last_commit_cache_read: 4489990
tokens_since_last_commit_output: 11301
tokens_since_last_commit_reasoning: 8898
tokens_since_last_commit_subagent_input: 411146
tokens_since_last_commit_subagent_cache_read: 9719296
tokens_since_last_commit_subagent_output: 30011
tokens_since_last_commit_subagent_reasoning: 19623
system: Ubuntu 26.04
agitrack_version: 0.5.0
You are free to implement all. Bump the verion to v0.6.0.dev1

# Interaction Trace

## User

go ahead. You are free to implement all. Bump the verion to v0.6.0.dev1

# aGiTrack Metadata
commit_type: agent
backend: opencode
model: opencode-go/minimax-m3
session_name: wisdom
agitrack_session_id: agitrack-a66be59e-77ca-49b2-b149-23ad1c6d386f
backend_session_id: ses_0894da2d7ffeY5BONcAmuMfk7i
conversation_anchor: msg_f7957fa39002Pu5UDzZRK7H4Rh
agent_started_at: 2026-07-19T07:39:13Z
agent_ended_at: 2026-07-19T07:47:16Z
context_tokens: 33
tokens_since_last_commit_input: 183669
tokens_since_last_commit_cache_read: 10121005
tokens_since_last_commit_output: 41080
system: Ubuntu 26.04
agitrack_version: 0.5.0
I would like you to pay special attention to the summarizer mechanism.
In this session, I would like to develop an idea how we can route
between models. Imagine, I have a local model (e.g., qwen 3.5 27B), and
two models in the cloud (e.g., kimi k3, claude opus). My idea is to
learn preferences from users as users use agitrack and we collect commit
 messages. We can use a user's corrections as a signal to derive when a
model failed. / Please continue / go ahead. You are free to implement
all. Bump the verion to v0.6.0.dev1

# Interaction Trace

## User

Please analyze the code repo. I would like you to pay special attention
to the summarizer mechanism. In this session, I would like to develop an
 idea how we can route between models. Imagine, I have a local model
(e.g., qwen 3.5 27B), and two models in the cloud (e.g., kimi k3, claude
 opus). My idea is to learn preferences from users as users use agitrack
 and we collect commit messages. We can use a user's corrections as a
signal to derive when a model failed.

## User

Please continue

## Agent

All loose ends are tied. Here is the complete design, built on what the
exploration verified and your three answers.

### Design: Coding-Model Router with Preference Learning

#### Core idea in one paragraph

The router chooses the **coding model** per session/turn from the active
 backend's own model list (Claude → Anthropic tiers; OpenCode → whatever
 providers are configured, incl. a local Ollama qwen). The **summarizer
stays one small cheap model**, but gets a second job as the **judge**:
it already reads every interaction trace, so after each turn it also
extracts a structured quality signal (task class, complexity, whether
the user corrected the agent). Combined with explicit ratings and
implicit signals (discards, reverts, redo-follow-ups), a per-user
preference store learns which model serves *this* user best for *which*
kind of task, and the router exploits + explores that. Preferences sync
across machines via an orphan git ref, exactly like the learn page's
progress sync.

#### Architecture

##### New package `agitrack/routing/`

**`store.py` — `RoutingStore`** (modeled directly on
`metrics/learn.py::LearnStore`):
- `.agitrack/routing.json`, git-ignored via `ensure_local_ignore()`,
atomic writes, per-user profiles keyed by GitHub id (reuse the learn
page's identity resolution).
- Profile: per model `{attempts, quality_ema, ratings[], discards,
reverts, judge_flags, by_task_class: {debug: {n, ema}, ...}, cost_tier}`
 plus a ring-buffer event log (~last 500) for auditability.
- Optional sync (default off): orphan ref `refs/agitrack/routing-prefs`,
 `<repo-fingerprint>/<gid>/routing.json`, lease-guarded push, restore-
on-empty-local, same code shape as `learn.py:741-869`.

**`judge.py` — `TurnJudge`** (the "summarizer generates signals on the
fly" part):
- Runs on the **existing summary worker threads**
(`ProxyRunner._start_commit_summary`, background `agit-bg-summary`, sync
 shell/recovery paths), right after the summary call, using the **same
cheap summarizer backend+model**. Zero new latency on the user's path,
one extra bare call of a few hundred tokens per turn, skippable via
config.
- Input: the interaction trace (already rendered for the commit) +
outcome flags the runner already knows (cancelled, discarded, queued
follow-ups).
- Output: strict JSON `{task_class, complexity, correction:
none|explicit_negative|redo|clarification, evidence}`; validated with
the existing `summary_is_usable`/echo guards; parse failure = no signal
(never blocks the summary).

**`policy.py` — `ModelRouter`**:
- `choose(task_features, pool, store) -> model`: score =
`quality_ema(model, task_class)` − λ·`cost_tier` + exploration bonus
(Thompson sampling on Beta posteriors per (model, task_class)); cold-
start priors by tier (cheap/local optimistic for small tasks, cloud for
large).
- Pool comes from the **active backend**: Claude → the curated tier list
 (`model_select.py` already hardcodes haiku/sonnet/opus); OpenCode →
`list_available_models("opencode")` (your Ollama qwen and kimi providers
 appear automatically). User prunes/reorders in settings. Hard filter:
`routing_allow_cloud: false` → local/ANTHROPIC-only entries.
- Modes: `off | suggest | auto` (config `routing_mode`, default
`suggest`).

**`switch.py` — in-TUI model switching** (addressing the design flaw you
 named):
- aGiTrack owns the PTY and already injects text into it (merge-conflict
 prompts, serialized by `BackendProcess._write_lock`), so between turns
it can **type the backend's own model-switch command**: `/model …` in
Claude's TUI, the models picker sequence in OpenCode's TUI. The exact
byte/key sequences per backend are a **research item to verify against
the real CLIs** (AGENTS.md mandates real-backend verification anyway);
the pyte screen mirror lets aGiWatch confirm the new model is shown
before forwarding the next prompt.
- Guardrails: only when `not _agent_is_active()`, never mid-turn; on any
 uncertainty, **fallback = relaunch spawn with `--model`** (both `claude
 --model` and `opencode --model` accept a launch model; `spawn_command`
gains an optional `model=` param).
- Headless modes (json/bridge/shell) get true per-turn routing:
`router.choose()` before each `backend.run(model=…)`.

##### Implicit + explicit signals

| Signal | Source (existing hook) | Weight |
|---|---|---|
| Judge correction/accept | `TurnJudge` on summary worker | high |
| Explicit 1–5 rating | Ctrl-G `rate` command + dashboard widget |
highest |
| Discard after Esc | `_handle_cancelled_turn` | high |
| `git revert` of an agent commit | new pass in `metrics/collect.py` |
high |
| Redo follow-up ("no, I meant…") | judge reads queued follow-ups in
trace | medium |
| Post-agent user edits before next prompt |
`_pre_agent_commit_if_needed` | low (noisy) |

##### UX surface
- Status-bar hint after a turn when another pool model outscores the
current one by a margin: "router: claude-opus-4-8 scores higher for
refactor tasks (Ctrl-G model)". Non-modal, expires like other notices.
- New Ctrl-G `model` command: pool list with learned scores + router-
recommended badge; switching injects the in-TUI switch (or relaunches).
Palette check: `m` prefix is free, but the palette order/prefix rules
mean `docs/user-flow.md` and the command-order tests get updated in the
same change.
- New Ctrl-G `rate` command (1–5) + status-bar hint after turns;
dashboard gets a routing panel (per-model EMA, event feed, rating
widget, new POST endpoints on the metrics server).

##### Config (all repo-overridable, added to `_settings_specs`)
`routing_mode` (off/suggest/auto), `routing_pool` (per-backend list,
auto-seeded), `routing_sync` (bool), `routing_exploration` (0–1, default
 0.1), `routing_judge` (bool, default on).

#### Phasing

1. **Foundation**: `RoutingStore`, `TurnJudge` on the summary workers,
implicit-signal wiring (discard/revert/redo), config keys. Learns
silently; no behavior change.
2. **Suggest**: policy scoring, status-bar hints, Ctrl-G `model`/`rate`
commands, dashboard panel + rating endpoint.
3. **Auto**: PTY switch injection (real-CLI verification first, relaunch
 fallback), per-turn routing in headless modes, pref sync.

**Testing/docs obligations per AGENTS.md**: real-git + real-backend
verification (both CLIs, incl. a real Ollama qwen), new
`tests/test_routing_*.py`, `tests/FLOW_MATRIX.md` rows for each new
flow, `docs/user-flow.md` diagram updates, README + AGENTS.md updates,
no em-dashes in shipped prose, `./scripts/check.sh` green.

**Main risk**: determinism of the in-TUI `/model` switch across backend
versions (picker UIs drift). Mitigation: screen-scrape confirmation +
relaunch fallback, and the switch code isolated per backend so a drift
breaks only auto-mode, degrading to suggest.

Shall I proceed with implementation, and if so, start with Phase 1
(foundation) or implement all three phases in one branch?

## User

go ahead. You are free to implement all. Bump the verion to v0.6.0.dev1

# aGiTrack Metadata
commit_type: agent
backend: opencode
model: opencode-go/minimax-m3
reasoning_effort: on
session_name: wisdom
agitrack_session_id: agitrack-a66be59e-77ca-49b2-b149-23ad1c6d386f
backend_session_id: ses_0894da2d7ffeY5BONcAmuMfk7i
conversation_anchor: msg_f79611367002rgWsgkUtjkFK4W
agent_started_at: 2026-07-18T19:27:08Z
agent_ended_at: 2026-07-19T07:57:12Z
context_tokens: 81
tokens_since_last_commit_input: 576815
tokens_since_last_commit_cache_read: 15954842
tokens_since_last_commit_output: 56454
tokens_since_last_commit_reasoning: 8898
tokens_since_last_commit_subagent_input: 411146
tokens_since_last_commit_subagent_cache_read: 9719296
tokens_since_last_commit_subagent_output: 30011
tokens_since_last_commit_subagent_reasoning: 19623
system: Ubuntu 26.04
agitrack_version: 0.5.0
The user gave the go-ahead to implement the entire pending plan without
restrictions ("You are free to implement all"), removing the need for
per-item confirmation, and directed that the project version be bumped
to v0.6.0.dev1. The .dev1 suffix marks this as a first development pre-
release of the upcoming v0.6.0, signaling that the codebase is entering
an active, potentially unstable development cycle rather than a stable
release. The session trace contains no accompanying diff or prior turns,
 so the specific code modifications cannot be itemized here; the
recorded outcome of this turn is the blanket implementation approval
plus the version bump directive, with all implementation details
deferred to the changes themselves.

# Interaction Trace

## User

go ahead. You are free to implement all. Bump the verion to v0.6.0.dev1

# aGiTrack Metadata
commit_type: agent
backend: opencode
model: opencode-go/minimax-m3
session_name: wisdom
agitrack_session_id: agitrack-a66be59e-77ca-49b2-b149-23ad1c6d386f
backend_session_id: ses_0894da2d7ffeY5BONcAmuMfk7i
conversation_anchor: msg_f796881aa002pkXmN5FfmBYxkC
agent_started_at: 2026-07-19T07:39:13Z
agent_ended_at: 2026-07-19T08:05:19Z
context_tokens: 580
tokens_since_last_commit_input: 452276
tokens_since_last_commit_cache_read: 34778701
tokens_since_last_commit_output: 83263
system: Ubuntu 26.04
summary_model: opencode-go/minimax-m3
summary_tokens_input: 6467
summary_tokens_cache_read: 16128
summary_tokens_output: 559
agitrack_version: 0.5.0
…t, signal recorders

Polish the model-routing package (judge JSON parsing, policy type hints,
router facade), wire the runner's routing helpers without type errors, and
add the missing signal recorder tests. The dashboard /routing page
handles the rate + sync POSTs defensively so a None return is surfaced
as an in-page error rather than a 500. A real-backend smoke test verifies
the in-TUI model-switch sequence works against a real claude/opencode
PTY (skipped automatically when the CLI isn't installed).
I would like you to pay special attention to the summarizer mechanism. 
In this session, I would like to develop an idea how we can route 
between models. Imagine, I have a local model (e.g., qwen 3.5 27B), and 
two models in the cloud (e.g., kimi k3, claude opus). My idea is to 
learn preferences from users as users use agitrack and we collect commit
 messages. We can use a user's corrections as a signal to derive when a 
model failed. / Please continue / go ahead. You are free to implement 
all. Bump the verion to v0.6.0.dev1

# Interaction Trace

## User

Please analyze the code repo. I would like you to pay special attention 
to the summarizer mechanism. In this session, I would like to develop an
 idea how we can route between models. Imagine, I have a local model 
(e.g., qwen 3.5 27B), and two models in the cloud (e.g., kimi k3, claude
 opus). My idea is to learn preferences from users as users use agitrack
 and we collect commit messages. We can use a user's corrections as a 
signal to derive when a model failed.

## User

Please continue

## Agent

All loose ends are tied. Here is the complete design, built on what the 
exploration verified and your three answers.

### Design: Coding-Model Router with Preference Learning

#### Core idea in one paragraph

The router chooses the **coding model** per session/turn from the active
 backend's own model list (Claude → Anthropic tiers; OpenCode → whatever
 providers are configured, incl. a local Ollama qwen). The **summarizer 
stays one small cheap model**, but gets a second job as the **judge**: 
it already reads every interaction trace, so after each turn it also 
extracts a structured quality signal (task class, complexity, whether 
the user corrected the agent). Combined with explicit ratings and 
implicit signals (discards, reverts, redo-follow-ups), a per-user 
preference store learns which model serves *this* user best for *which* 
kind of task, and the router exploits + explores that. Preferences sync 
across machines via an orphan git ref, exactly like the learn page's 
progress sync.

#### Architecture

##### New package `agitrack/routing/`

**`store.py` — `RoutingStore`** (modeled directly on 
`metrics/learn.py::LearnStore`):
- `.agitrack/routing.json`, git-ignored via `ensure_local_ignore()`, 
atomic writes, per-user profiles keyed by GitHub id (reuse the learn 
page's identity resolution).
- Profile: per model `{attempts, quality_ema, ratings[], discards, 
reverts, judge_flags, by_task_class: {debug: {n, ema}, ...}, cost_tier}`
 plus a ring-buffer event log (~last 500) for auditability.
- Optional sync (default off): orphan ref `refs/agitrack/routing-prefs`,
 `<repo-fingerprint>/<gid>/routing.json`, lease-guarded push, restore-
on-empty-local, same code shape as `learn.py:741-869`.

**`judge.py` — `TurnJudge`** (the "summarizer generates signals on the 
fly" part):
- Runs on the **existing summary worker threads** 
(`ProxyRunner._start_commit_summary`, background `agit-bg-summary`, sync
 shell/recovery paths), right after the summary call, using the **same 
cheap summarizer backend+model**. Zero new latency on the user's path, 
one extra bare call of a few hundred tokens per turn, skippable via 
config.
- Input: the interaction trace (already rendered for the commit) + 
outcome flags the runner already knows (cancelled, discarded, queued 
follow-ups).
- Output: strict JSON `{task_class, complexity, correction: 
none|explicit_negative|redo|clarification, evidence}`; validated with 
the existing `summary_is_usable`/echo guards; parse failure = no signal 
(never blocks the summary).

**`policy.py` — `ModelRouter`**:
- `choose(task_features, pool, store) -> model`: score = 
`quality_ema(model, task_class)` − λ·`cost_tier` + exploration bonus 
(Thompson sampling on Beta posteriors per (model, task_class)); cold-
start priors by tier (cheap/local optimistic for small tasks, cloud for 
large).
- Pool comes from the **active backend**: Claude → the curated tier list
 (`model_select.py` already hardcodes haiku/sonnet/opus); OpenCode → 
`list_available_models("opencode")` (your Ollama qwen and kimi providers
 appear automatically). User prunes/reorders in settings. Hard filter: 
`routing_allow_cloud: false` → local/ANTHROPIC-only entries.
- Modes: `off | suggest | auto` (config `routing_mode`, default 
`suggest`).

**`switch.py` — in-TUI model switching** (addressing the design flaw you
 named):
- aGiTrack owns the PTY and already injects text into it (merge-conflict
 prompts, serialized by `BackendProcess._write_lock`), so between turns 
it can **type the backend's own model-switch command**: `/model …` in 
Claude's TUI, the models picker sequence in OpenCode's TUI. The exact 
byte/key sequences per backend are a **research item to verify against 
the real CLIs** (AGENTS.md mandates real-backend verification anyway); 
the pyte screen mirror lets aGiWatch confirm the new model is shown 
before forwarding the next prompt.
- Guardrails: only when `not _agent_is_active()`, never mid-turn; on any
 uncertainty, **fallback = relaunch spawn with `--model`** (both `claude
 --model` and `opencode --model` accept a launch model; `spawn_command` 
gains an optional `model=` param).
- Headless modes (json/bridge/shell) get true per-turn routing: 
`router.choose()` before each `backend.run(model=…)`.

##### Implicit + explicit signals

| Signal | Source (existing hook) | Weight |
|---|---|---|
| Judge correction/accept | `TurnJudge` on summary worker | high |
| Explicit 1–5 rating | Ctrl-G `rate` command + dashboard widget | 
highest |
| Discard after Esc | `_handle_cancelled_turn` | high |
| `git revert` of an agent commit | new pass in `metrics/collect.py` | 
high |
| Redo follow-up ("no, I meant…") | judge reads queued follow-ups in 
trace | medium |
| Post-agent user edits before next prompt | 
`_pre_agent_commit_if_needed` | low (noisy) |

##### UX surface
- Status-bar hint after a turn when another pool model outscores the 
current one by a margin: "router: claude-opus-4-8 scores higher for 
refactor tasks (Ctrl-G model)". Non-modal, expires like other notices.
- New Ctrl-G `model` command: pool list with learned scores + router-
recommended badge; switching injects the in-TUI switch (or relaunches). 
Palette check: `m` prefix is free, but the palette order/prefix rules 
mean `docs/user-flow.md` and the command-order tests get updated in the 
same change.
- New Ctrl-G `rate` command (1–5) + status-bar hint after turns; 
dashboard gets a routing panel (per-model EMA, event feed, rating 
widget, new POST endpoints on the metrics server).

##### Config (all repo-overridable, added to `_settings_specs`)
`routing_mode` (off/suggest/auto), `routing_pool` (per-backend list, 
auto-seeded), `routing_sync` (bool), `routing_exploration` (0–1, default
 0.1), `routing_judge` (bool, default on).

#### Phasing

1. **Foundation**: `RoutingStore`, `TurnJudge` on the summary workers, 
implicit-signal wiring (discard/revert/redo), config keys. Learns 
silently; no behavior change.
2. **Suggest**: policy scoring, status-bar hints, Ctrl-G `model`/`rate` 
commands, dashboard panel + rating endpoint.
3. **Auto**: PTY switch injection (real-CLI verification first, relaunch
 fallback), per-turn routing in headless modes, pref sync.

**Testing/docs obligations per AGENTS.md**: real-git + real-backend 
verification (both CLIs, incl. a real Ollama qwen), new 
`tests/test_routing_*.py`, `tests/FLOW_MATRIX.md` rows for each new 
flow, `docs/user-flow.md` diagram updates, README + AGENTS.md updates, 
no em-dashes in shipped prose, `./scripts/check.sh` green.

**Main risk**: determinism of the in-TUI `/model` switch across backend 
versions (picker UIs drift). Mitigation: screen-scrape confirmation + 
relaunch fallback, and the switch code isolated per backend so a drift 
breaks only auto-mode, degrading to suggest.

Shall I proceed with implementation, and if so, start with Phase 1 
(foundation) or implement all three phases in one branch?

## User

go ahead. You are free to implement all. Bump the verion to v0.6.0.dev1

# aGiTrack Metadata
commit_type: agent
backend: opencode
model: opencode-go/minimax-m3
reasoning_effort: on
session_name: wisdom
agitrack_session_id: agitrack-a66be59e-77ca-49b2-b149-23ad1c6d386f
backend_session_id: ses_0894da2d7ffeY5BONcAmuMfk7i
conversation_anchor: msg_f7974c76400280gFcH2VpLOD8K
covered_commits: 760455a
agent_started_at: 2026-07-18T19:27:08Z
agent_ended_at: 2026-07-19T08:18:44Z
context_tokens: 65
tokens_since_last_commit_input: 975053
tokens_since_last_commit_cache_read: 59918502
tokens_since_last_commit_output: 107583
tokens_since_last_commit_reasoning: 8898
tokens_since_last_commit_subagent_input: 411146
tokens_since_last_commit_subagent_cache_read: 9719296
tokens_since_last_commit_subagent_output: 30011
tokens_since_last_commit_subagent_reasoning: 19623
system: Ubuntu 26.04
agitrack_version: 0.5.0
@laminair

Copy link
Copy Markdown
Collaborator Author

@shiqiangw The above is a very early draft of what a router could look like. We might be able to generate preference labels, scores/rewards along the way without much overhead.

@laminair laminair changed the title Model router with preference learning (v0.6.0.dev1, #143) Model router with preference learning data capturing Jul 19, 2026
@shiqiangw

shiqiangw commented Jul 19, 2026

Copy link
Copy Markdown
Member

@laminair Could you change the version back to the original? Version bump should happen automatically via git workflow. The current version tag causes Windows CI to fail.

@shiqiangw

Copy link
Copy Markdown
Member

Also, does the routing consider token caches, which will need a new write when switching to a new model? And how is the cost tier in the expression determined?

@laminair
laminair marked this pull request as draft July 19, 2026 13:44
@laminair

Copy link
Copy Markdown
Collaborator Author

@shiqiangw Yes, that was unintentional.

The PR draft is a very, very early version. There's no token, caching, or memory accounting built-in yet. The goal for me is to first figure out how we can collect data that we can use for the routing decision. Once that is solved we should be able to factor in token economics.
My assumption for the moment is that we, by default, rely on a small (local) model and only escalate to cloud models when needed.

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.

2 participants