Files
6krrt/AGENTS.md
adlee-was-taken 765a4a64f3 docs: sweep for live routing panel + AGENTS.md
- README.md: update test count (356→562, 27 files), add events.py,
  tui_model.py, tui_sse.py, tui_screens.py to Modules table, add
  GET /events/decisions to API Endpoints, describe live SSE feed +
  detail popup + category breakdown in Monitoring, update textual
  import note, add new test files to Testing table.
- CLAUDE.md: update dispatcher description (SSE endpoint), tui
  description (live feed, modal, breakdown, tui_model split), test
  count (562, 27 files), add events.py entry.
- AGENTS.md: new agent-facing working guide — stack snapshot, module
  map with file boundaries and import discipline, conventions,
  test commands, open items, post-change checklist.
2026-08-23 23:38:15 -04:00

158 lines
7.7 KiB
Markdown

# AGENTS.md — working guide for AI agents on this repo
The three-tier docs:
- **`README.md`** — what's built, module by module (user-facing).
- **`CLAUDE.md`** — working state + immediate next steps + design rationale
(the one to trust on what is currently true).
- **`design/local-llm-model-router.md`** — architecture and rationale,
including parts still unbuilt.
This file is the quick-reference for an agent starting work: where things
live, what's safe to touch, and the conventions that aren't obvious from the
code.
## Stack snapshot
| Dimension | Value |
|---|---|
| Language | Python 3.10+ (3.10 floor tested; 3.14 also verified) |
| Framework | FastAPI + uvicorn |
| Database | SQLite (`router.db`) |
| Config | `config.yaml` + Pydantic (`config.py`), `extra="forbid"` |
| HTTP client | `requests` (pinned in `requirements.txt`) — do NOT add httpx2/aiohttp without a requirements bump |
| TUI | `textual==8.2.8` — imported only by `tui*.py` modules, never by the dispatch path |
| Testing | `pytest`, 562 tests, all offline (no provider or local-model calls) |
| Dependencies | Pinned. Bump deliberately, never use `>=` |
## Module map and file boundaries
### Dispatcher / service (I/O: DB + network)
| File | Role | Agent notes |
|---|---|---|
| `dispatcher.py` | FastAPI service: routes, calls providers, logs, streams | ~2500 LOC; only edit the specific function you need. **Must not import `metrics`** (circular). Imports `events` for SSE fan-out. |
| `metrics.py` | Read-only aggregations for `/health` and `/metrics` | Takes `(conn, cfg)` args — **must not import `dispatcher`** to avoid circular import. |
| `events.py` | In-memory decision-event broker | Pure stdlib (`queue`, `collections.deque`). Thread-safe. No Textual import. The SSE endpoint lives in `dispatcher.py` and calls `events.subscribe()`/`publish_decision()`. |
| `config.py` | Pydantic models + YAML loader | All config models inherit `StrictModel` (`extra="forbid"`). Unknown keys fail at load. |
| `capabilities.py` | Request-side capability detection (`tools`, `images`, `json_mode`, `reasoning`) | Reads from the OpenAI-format request body, not from the classifier. |
| `context_prune.py` | Relevance-based context pruning (`pinch`) | Ships **disabled** (`pinch.enabled: false`). Imports `PinchConfig` from `config.py` (no circular import: `config.py` doesn't import `context_prune`). |
| `logs.py` | Structured logging (logfmt, journald, ContextVar trace ids) | `logs.bind()` exists for StreamingResponse generators that lose the ContextVar. |
### Pure scoring/routing modules (no I/O)
| File | Role |
|---|---|
| `scoring.py` | `normalize_inverted` + weighted composite (quality-first, cost as tiebreak) |
| `routing.py` | Hard filters (`select_candidates`) + ranking (`rank_candidates`) |
| `tiering.py` | Pure tier resolver: 1=cheap+small, 2=mid, 3=frontier |
| `proficiency.py` | Score blending: leaderboard + self-eval → weighted composite |
| `iteration.py` | Retry budget per tier, matching retry to failure kind |
### TUI modules (import `textual`; never imported by the dispatch path)
| File | Role |
|---|---|
| `tui.py` | `DashboardApp` — main Textual app with CSS, bindings, compose, render |
| `tui_model.py` | Pure data layer: `build_model`, `build_category_breakdown`, `decision_row` — no Textual import, testable without a terminal |
| `tui_sse.py` | Background-thread SSE consumer (`DecisionStream`): reconnects on failure, marshals decisions to UI thread via `call_from_thread` |
| `tui_screens.py` | `DecisionDetailScreen``ModalScreen` showing full decision JSON (Enter or `e` key) |
### Other entrypoints
| File | Role |
|---|---|
| `poller.py` | Fetches Neuralwatt catalog, upserts `models` table |
| `seed_energy.py` | Reference workload sweep → `energy_observations` |
| `eval_proficiency.py` | Self-eval harness → `proficiency` table |
| `feedback.py` | Folds verification failures into `proficiency` |
| `tier.py` | DB tiering pass |
| `router_cli.py` | One-shot `/route` probe |
| `proficiency_store.py` | DB write path for `proficiency` |
## Conventions
### Code style
- The codebase uses `Optional[X]` (not `X | None`) and `dict` return types
throughout — match the existing style in the file you're editing.
- No `# type: ignore`, no `as any`, no `@ts-ignore` equivalent.
- `except Exception` is acceptable at top-level boundaries with
`# noqa: BLE001` comment (see `dispatcher._refresh`, `persist_route_decision`).
- Config is strict: every knob belongs in `config.yaml`, not only in a
Pydantic default. A default the file never mentions is invisible to a tuner.
- Named constants use `Final` in new code (`events.py`); existing code is
inconsistent — don't refactor just for this.
### Import discipline
- `metrics.py` **must not import `dispatcher`** (circular import).
- `events.py` imports nothing from the project (pure stdlib).
- `tui_model.py` imports `requests` but **not `textual`** — it's the testable data layer.
- `tui_sse.py` imports `requests` but **not `textual`** — it's a thread worker.
- `tui.py` and `tui_screens.py` import `textual` — that's fine, they're TUI.
- The service dispatch path (`dispatcher.py` → provider call) **never touches
`textual`**.
### Database
- SQLite, `PRAGMA foreign_keys = ON`.
- `_db()` in `dispatcher.py` returns a `sqlite3.Connection` with
`row_factory = sqlite3.Row`.
- Schema in `schema.sql` uses `CREATE TABLE IF NOT EXISTS` — safe to re-run.
- Code-side table creation (`ensure_route_decisions`, `proficiency_store.ensure_columns`)
mirrors the schema for live DBs that predate a feature.
### Testing
- All tests are offline. No test calls a provider or local model.
- Pure modules take rows and config as arguments, so they're testable without DB/network.
- Test files that exercise FastAPI use `starlette.testclient.TestClient` with a
temp SQLite DB (`tests/test_metrics_endpoint.py`).
- TUI tests use `App.run_test()` with a stubbed fetcher and a `_no_real_network`
safety-net fixture that guards both `requests.get` and the SSE consumer.
- **SSE tests must not use `TestClient.stream()` on the infinite endpoint** — it
hangs. Test the generator directly (`dispatcher._decision_event_stream()`)
or stub the generator to a bounded one for header/route checks.
## How to run things
```bash
# Setup
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
sqlite3 router.db < schema.sql
cp .env.example .env # fill in NEURALWATT_API_KEY
# Start the service (binds 127.0.0.1:8080)
python -m uvicorn dispatcher:app --reload
# Or via systemd:
systemctl --user start llm-router.service
# Run the TUI (service must be running)
python tui.py
# Run the full test suite
python -m pytest
# Quick routing probe (no spend)
python router_cli.py "Refactor this Django view"
# Populate the catalog
python poller.py && python tier.py
```
## What's NOT built yet (open items)
1. **Leaderboard priors unfilled**`leaderboards.yaml` ships empty.
2. **Three models unsettled** on eco stability (attribution noise).
3. **Retry does not reach streaming**`POST /outcome` is the answer for streamed traffic.
4. **Local energy not on the ledger** — local classifier/verifier electricity is unmeasured.
5. **Session-directory attribution picks the wrong directory** — heuristic resolves to a dependency's source dir instead of the project being edited.
See `CLAUDE.md` → "What's NOT built yet — pick up here" for the full list.
## After a code change
- Run `python -m pytest` — 562 tests, ~30s.
- If you changed `dispatcher.py`, restart the systemd service:
`systemctl --user restart llm-router.service` (it doesn't auto-reload code).
- If you changed the TUI, run `python tui.py` to verify it starts.
- Check `lsp_diagnostics` on changed files.
- Match the existing commit-message style: `fix:`, `feat:`, `docs:`, `test:`.