docs: fold AGENTS.md into CLAUDE.md and record the backend traps
One agent doc per repo, and it is CLAUDE.md. Unlike elsewhere, the existing CLAUDE.md was not a stub -- it carried seven hard-won gotchas, all of which survive intact. AGENTS.md supplied the deployment and release material, minus its feature-branch mandate and its `git add -A` snippet, and minus its pointer to portainer-core, which is deprecated and must not be used as a source of infra facts. README.md and docs/philosophy.md linked to the retired file, so those pointers move with it. The new material is two traps that both make the runtime look like the opposite of what it is. A cold import inside the container loads src/anthropic but not src/ollama, and Ollama is the primary backend. The only import of src/ollama is a function-body one at src/anthropic/model_selector.py:230, while PREFER_CLOUD_BACKEND=false keeps the Claude path off. Read the module list naively and the disabled fallback looks live while the hot path looks dead. This matters because the Claude migration is abandoned and its remnants are supposed to read as vestigial, not as unfinished work; the doc carries the decision id so that reasoning is fetchable. Second, get_household_registry() in a fresh `docker exec python` returns zero members while the running app serves two models from it. It is populated at startup, so importing the singleton from outside the app and reading it as empty is a measurement error, not a finding. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,34 +1,196 @@
|
||||
# CLAUDE.md
|
||||
# CLAUDE.md — tatlock
|
||||
|
||||
Claude Code-specific notes for this project. For general development instructions, architecture, coding standards, and deployment — see [AGENTS.md](AGENTS.md).
|
||||
Privacy-first homelab butler. An OpenAI-compatible orchestration API over local models, with
|
||||
household staff agents built on PydanticAI. Python 3.12 / FastAPI, `version = "2.4.3"`.
|
||||
Container `tatlock` on `docker-dataplane`, port **8000**. Redis DB **1** (memory), Qdrant for
|
||||
vectors.
|
||||
|
||||
## Setup & Commands
|
||||
## Ports
|
||||
|
||||
| | Port | How |
|
||||
|---|---|---|
|
||||
| Local dev | **8777** | `make run` — uvicorn reload, logs to `build/logs/server.log` |
|
||||
| Production | **8000** | container; `http://192.168.86.149:8000/health`, external `tatlock.schweitz.net` behind Authentik |
|
||||
|
||||
Test endpoints against `localhost:8777` while developing. `localhost:8000` is the *container*.
|
||||
|
||||
## Live contract
|
||||
|
||||
`http://localhost:8000/openapi.json` — **5 paths**, `title: OpenAI-Compatible API`, `version:
|
||||
2.4.3` (verified 2026-08-09): `/`, `/health`, `/v1/models`, `/v1/chat/completions`,
|
||||
`/v1/responses`. `/v1/responses` is primary; `/v1/chat/completions` exists for Open WebUI.
|
||||
|
||||
**The spec is the public surface, not the system.** The household capability registry is internal
|
||||
and appears nowhere in those 5 paths. Absence from the spec means "not exposed", not "does not
|
||||
exist".
|
||||
|
||||
## Two traps that make the runtime look like the opposite of what it is
|
||||
|
||||
**1. `src/anthropic` loads at startup; `src/ollama` does not — and Ollama is the primary
|
||||
backend.** A cold `import src.main` inside the container shows `agents, anthropic, chat, core,
|
||||
main, models, responses` — no `ollama`. The only import of it is a *function-body* one at
|
||||
`src/anthropic/model_selector.py:230`. Meanwhile `PREFER_CLOUD_BACKEND=false`, so every request
|
||||
actually goes to Ollama and the Claude path is off (see **D-11**). Reading the module list
|
||||
naively gives you exactly the wrong answer: the package that looks live is the disabled fallback,
|
||||
and the one that looks dead is the hot path. Do not conclude anything about backends from
|
||||
`sys.modules`; read the config.
|
||||
|
||||
**2. In-process singletons are empty outside the app.** `get_household_registry()`
|
||||
(`src/core/household_registry.py:334`) in a fresh `docker exec python` returns **0 members**,
|
||||
while the running app serves 2 models from it — it is populated at startup. Import the
|
||||
module-level definitions or ask the endpoint; never import a singleton and assume it is
|
||||
populated.
|
||||
|
||||
## Stack decisions that bind this repo
|
||||
|
||||
Recorded in the workspace vault, not here. Read before assuming anything about the LLM backend:
|
||||
|
||||
```bash
|
||||
make setup # Create venv and install all dependencies
|
||||
make test # Unit tests (no external services)
|
||||
make test-integration # Integration tests (needs Claude/Ollama)
|
||||
make test-contracts # Wire-level contract tests against live service boundaries
|
||||
make run # Start dev server on port 8777
|
||||
make lint # Ruff linter + formatter check
|
||||
make typecheck # Mypy
|
||||
make clean # Remove caches and build artifacts
|
||||
/home/jpmschweitzer/.local/bin/pql --vault /mnt/media/Projects decisions read D-11
|
||||
```
|
||||
|
||||
Dependencies are in `pyproject.toml` (`[project.dependencies]` and `[project.optional-dependencies.dev]`).
|
||||
**D-11 — the Claude migration is abandoned. Tatlock stays on Ollama.** Do not resume it and do
|
||||
not treat its remnants as unfinished work. What you will find, and why none of it is a TODO:
|
||||
`ANTHROPIC_MODEL` is set on the container (`claude-sonnet-4-20250514`) and never used because
|
||||
`PREFER_CLOUD_BACKEND=false`; `ANTHROPIC_API_KEY` is a variable reference whose literal was
|
||||
revoked 2026-08-09; `docs/claude-integration.md` documents a capability that exists but is
|
||||
switched off. The cost is deliberate: reasoning stays at `gemma4:e2b` scale because VRAM is
|
||||
shared with Speaches.
|
||||
|
||||
## Critical Gotchas
|
||||
`REDIS_BENCHMARK_DB=6` is allocated on the container but the benchmarking module was never
|
||||
implemented — see the gotcha below. Vestigial, like the Anthropic settings.
|
||||
|
||||
**ASGITransport does NOT trigger FastAPI lifespan events.** The session-scoped `_initialize_app` fixture in `tests/conftest.py` calls `initialize_application()` explicitly via `asyncio.run()`. Without this, the Ollama/Claude health checks never run: `_ollama_available` stays `None` (treated as available, so requests go to Ollama) and `_claude_available` stays `None` (treated as unavailable, so the Claude fallback never engages).
|
||||
## Critical gotchas
|
||||
|
||||
**AsyncIO scope mismatch.** `asyncio_default_fixture_loop_scope = function` is set in `pyproject.toml`. Session-scoped async fixtures cause `ScopeMismatch` errors. The fix is to use a sync fixture with `asyncio.run()` for session-scoped initialization.
|
||||
**ASGITransport does NOT trigger FastAPI lifespan events.** The session-scoped `_initialize_app`
|
||||
fixture in `tests/conftest.py` calls `initialize_application()` explicitly via `asyncio.run()`.
|
||||
Without it the Ollama/Claude health checks never run: `_ollama_available` stays `None` (treated
|
||||
as available, so requests go to Ollama) and `_claude_available` stays `None` (treated as
|
||||
unavailable, so the Claude fallback never engages).
|
||||
|
||||
**The butler persona prompt suppresses local-model tool calling.** With `TATLOCK_SYSTEM_PROMPT` attached, gemma4 reasons about calling the calculator, then answers from memory with wrong arithmetic (a different wrong product each run). `orchestrate_tool_calls()` therefore uses the terse `TATLOCK_ORCHESTRATION_PROMPT`; the persona is applied in `synthesize_from_results()`. Do not reattach the persona prompt to a tool-phase agent. `tool_choice: "required"` via extra_body does NOT force Ollama to call tools — it is advisory at best.
|
||||
**AsyncIO scope mismatch.** `asyncio_default_fixture_loop_scope = function` is set in
|
||||
`pyproject.toml`. Session-scoped async fixtures raise `ScopeMismatch`. Use a sync fixture with
|
||||
`asyncio.run()` for session-scoped initialization.
|
||||
|
||||
**Claude Sonnet 5+ rejects sampling parameters.** `temperature`/`top_p`/`top_k` return a 400. Use `get_sampling_settings()` from the model selector instead of passing `ModelSettings(temperature=...)` directly to agents that can run on the Claude fallback. The contract test suite pins this (`make test-contracts`).
|
||||
**The butler persona prompt suppresses local-model tool calling.** With `TATLOCK_SYSTEM_PROMPT`
|
||||
attached, gemma4 reasons about calling the calculator, then answers from memory with wrong
|
||||
arithmetic — a different wrong product each run. `orchestrate_tool_calls()` therefore uses the
|
||||
terse `TATLOCK_ORCHESTRATION_PROMPT`; the persona is applied in `synthesize_from_results()`. Do
|
||||
not reattach the persona prompt to a tool-phase agent. `tool_choice: "required"` via `extra_body`
|
||||
does **not** force Ollama to call tools — advisory at best.
|
||||
|
||||
**Integration test timeouts.** Set to 120s to match `OLLAMA_TIMEOUT` config (300s for the pure-Ollama fallback test, which cannot be rescued by Claude). Current GPU-resident numbers (measured 2026-08-07, gemma4:e2b at ~95 tok/s): full Steward → orchestrate → synthesize flow ~10–13s for simple turns; librarian-routed queries ~20-25s (not re-measured). A single turn costs **3 sequential Ollama calls and ~710 generated tokens** even for "what is 61 plus 12?" — most of it the model's own reasoning, paid three times. Cold model load is ~36s, avoided while the model is pinned with `keep_alive: -1`; the `OLLAMA_KEEP_ALIVE=2h` default otherwise reintroduces it. The old "~35s steward / ~2 min flow" and "11–25s flow" figures are superseded — do not plan against them. `STEWARD_TIMEOUT` defaults to 60s.
|
||||
**Claude Sonnet 5+ rejects sampling parameters.** `temperature`/`top_p`/`top_k` return 400. Use
|
||||
`get_sampling_settings()` from the model selector rather than passing `ModelSettings(temperature=…)`
|
||||
to agents that can run on the Claude fallback. `make test-contracts` pins this.
|
||||
|
||||
**`get_benchmark_store` does not exist.** The benchmarking module (`src/core/benchmarks.py`) was never implemented. `scripts/benchmark_analysis.py` also references it and is broken. Do not add mocks for it in tests.
|
||||
**Integration test timeouts** are 120s to match `OLLAMA_TIMEOUT` (300s for the pure-Ollama
|
||||
fallback test, which Claude cannot rescue). GPU-resident numbers measured 2026-08-07 with
|
||||
gemma4:e2b at ~95 tok/s: full Steward → orchestrate → synthesize ~10–13s for simple turns;
|
||||
librarian-routed ~20–25s (not re-measured). **A single turn costs 3 sequential Ollama calls and
|
||||
~710 generated tokens even for "what is 61 plus 12?"** — mostly the model's own reasoning, paid
|
||||
three times. Cold model load is ~36s, avoided while pinned with `keep_alive: -1`; the
|
||||
`OLLAMA_KEEP_ALIVE=2h` default reintroduces it. Older "~35s steward / ~2 min flow" and "11–25s"
|
||||
figures are superseded — do not plan against them. `STEWARD_TIMEOUT` defaults to 60s.
|
||||
|
||||
**Steward tests need household registry.** Use `register_household_members()` (sync) in fixtures, not `initialize_application()` (async). The steward extracts capabilities from the registry.
|
||||
**`get_benchmark_store` does not exist.** `src/core/benchmarks.py` was never implemented, and
|
||||
`scripts/benchmark_analysis.py` references it and is broken. Do not add mocks for it in tests.
|
||||
|
||||
**Steward tests need the household registry.** Use `register_household_members()` (sync) in
|
||||
fixtures, not `initialize_application()` (async). The steward extracts capabilities from the
|
||||
registry.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
make setup # venv + all dependencies
|
||||
make run # dev server on 8777, reload, logs to build/logs/server.log
|
||||
make test # unit tests, no external services
|
||||
make test-integration # needs Ollama (and Claude, if enabled)
|
||||
make test-contracts # wire-level contract tests against live service boundaries
|
||||
make lint # ruff linter + formatter check
|
||||
make typecheck # mypy
|
||||
make clean # remove caches and build artifacts
|
||||
```
|
||||
|
||||
Always run pytest through the venv explicitly, to avoid environment mismatch:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m pytest tests/
|
||||
.venv/bin/python -m pytest tests/core/ -v
|
||||
```
|
||||
|
||||
Dependencies live in `pyproject.toml` (`[project.dependencies]`, `[project.optional-dependencies.dev]`).
|
||||
Copy `.env.example` to `.env` and configure Ollama, Redis and Qdrant hosts.
|
||||
|
||||
**Contract tests before code review.** When the question is "do these two services still agree?",
|
||||
`make test-contracts` answers it by observing the live boundary; reading both codebases only tells
|
||||
you what should happen. Semantics: unreachable → skip, reachable-but-wrong-shape → fail.
|
||||
|
||||
## Architecture
|
||||
|
||||
Domain-first under `src/`: `agents/` (steward, librarian, biographer, housekeeper, tatlock_core),
|
||||
`core/`, `chat/`, `responses/`, `models/`, `ollama/`, `anthropic/`. Two tiers — the Steward routes,
|
||||
Tatlock coordinates. Group new work by domain, not by file type.
|
||||
|
||||
## Internal service access
|
||||
|
||||
`http://localhost:3002` reaches Gitea directly, bypassing Authentik SSO — verified returning
|
||||
`{"version":"1.27.1"}`. Useful for reading a sibling repo's raw files:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3002/jpmschweitzer/library-desk/raw/branch/main/README.md
|
||||
```
|
||||
|
||||
The old AGENTS.md pointed at **`portainer-core`** for full-stack documentation. That repo is
|
||||
**deprecated** and must not be used as a source of infra facts; it was merged into
|
||||
`system-admin-toj/containers/`, where `CONTAINERS.md` is the live inventory.
|
||||
|
||||
## Work tracking
|
||||
|
||||
Work lives in **pql**, not a markdown TODO. **This repo's vault is standalone** — its tickets and
|
||||
internal decisions live here in `.pql/` and `governance/`, and travel with a clone, because
|
||||
`.pql/changelog/` is committed and replayed by the git hooks (D-15). The databases are gitignored
|
||||
and rebuildable with `pql plan rebuild`.
|
||||
|
||||
`pql` is **not** on the non-interactive `PATH` — invoke it as `/home/jpmschweitzer/.local/bin/pql`.
|
||||
From inside this repo no `--vault` is needed; pql anchors at the nearest `.git/` ancestor.
|
||||
|
||||
```bash
|
||||
/home/jpmschweitzer/.local/bin/pql ticket list # this repo's open work
|
||||
/home/jpmschweitzer/.local/bin/pql plan whatsnext # next unblocked item, with context
|
||||
/home/jpmschweitzer/.local/bin/pql decisions list # this repo's own decisions
|
||||
```
|
||||
|
||||
Stack decisions that constrain this service need the flag:
|
||||
|
||||
```bash
|
||||
/home/jpmschweitzer/.local/bin/pql --vault /mnt/media/Projects decisions list --domain tatlock-api
|
||||
```
|
||||
|
||||
The workspace domain is `tatlock-api`, not `tatlock` — pql rejects a domain stem that prefixes
|
||||
another, and `tatlock` prefixes `tatlock-ui`. A `tatlock-api -> tatlock` symlink at the workspace
|
||||
root makes the directory answer to both (D-15).
|
||||
|
||||
Note `ticket new --decision D-N` resolves ids within **one** vault, so a ticket here cannot link
|
||||
to a workspace decision. Cite the id in the ticket body instead.
|
||||
|
||||
## Git
|
||||
|
||||
- **History is linear — no merge commits.** Work on `main`, or a short-lived branch that is
|
||||
fast-forwarded and deleted. This repo's AGENTS.md mandated a feature branch for every change;
|
||||
that rule was retired workspace-wide on 2026-08-08 and does not apply.
|
||||
- **Conventional Commits**: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`.
|
||||
- **Stage explicitly. Never `git add -A`** — denied by policy, and it sweeps in whatever else is
|
||||
dirty, including secrets.
|
||||
- Update `CHANGELOG.md` for every user-facing change, under `[Unreleased]`.
|
||||
|
||||
## Releasing
|
||||
|
||||
Test locally first — the build-deploy loop is slow. Deploy only when a feature is complete.
|
||||
|
||||
1. Ask whether a deploy is wanted; it is not automatic.
|
||||
2. Bump `version` in `pyproject.toml` (patch for fixes, minor for features).
|
||||
3. Move `[Unreleased]` entries into a dated section in `CHANGELOG.md`.
|
||||
4. Stage the changed files by name, commit, tag `vX.Y.Z`, `git push origin main --tags`.
|
||||
5. Gitea CI builds and pushes on the tag; Watchtower deploys.
|
||||
6. Verify: `curl http://192.168.86.149:8000/health`.
|
||||
|
||||
Reference in New Issue
Block a user