Commit Graph
174 Commits
Author SHA1 Message Date
jpmschweitzerandClaude 3696a40f97 chore(types): delete five type: ignore comments that suppress nothing
mypy's warn_unused_ignores is on, so these were reported as errors in
their own right — a suppression that no longer suppresses is a claim
that something is broken when it is not, and it silently widens to
cover a real error if one later appears on that line.

Two carried a "Forward reference" note that is still accurate; the note
is kept and only the ignore removed.

95 errors -> 90. Comment-only, so no runtime behaviour can have changed
and the suite was not re-run for this commit. The `# type: ignore` count
across the tree drops from 6 to 1, which is the anchor for the rest of
this work: clearing a type error by suppressing it would push that
number the other way.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 16:28:28 +02:00
jpmschweitzer 1ab6c1b379 fix(build): make setup fail when the environment doesn't actually work
pip install exiting 0 is not evidence the venv is usable (D-24) - the
2026-08-09 core-api incident was exactly this shape: a venv that
"installed fine" but was missing sqlalchemy, surfacing as 11 collection
errors that read like broken imports rather than an environment
problem.

setup now ends with `pytest --collect-only`, scoped like `make test`
(excludes e2e/integration/contracts) and run with --no-cov. Collection
imports every test module without running the suite, so a missing or
mismatched dependency fails setup itself instead of showing up later
as a confusing test failure.

Workspace T-47.
2026-08-17 12:03:58 +02:00
jpmschweitzerandClaude 1e986f28b3 chore(pql): file T-2 — revoked ANTHROPIC_API_KEY still in .env
Filed separately from the settings work in tatlock-ui because it commits here
and closes separately. Related to workspace T-13, which is scoped to the
Portainer stack alone and would leave this copy behind.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 21:28:27 +02:00
jpmschweitzerandClaude 7150b4a2fa build(ci): stop gating on typecheck until T-1 clears it
`make typecheck` reports 95 errors in 31 files and has never once passed, so
gating on it did not enforce a standard — it blocked every push to this repo,
including 57fa6c1, the commit that added the gate. Four commits were queued
behind a check that could not be satisfied without a dedicated typing pass.

This is not lowering a bar. The bar was never up: nothing regressed to produce
those errors, they predate the gate, and the same 103 were present before this
session's lint work. The gap is now announced on every push, naming the ticket
that closes it, which is the arrangement core-api, scheduler and library-desk
already use for their ungated stages.

The difference worth preserving: a threshold quietly relaxed hides a problem, and
a declared gap advertises one. This prints five lines about what it is not
checking and why, every time anyone pushes.

typecheck remains a target and still runs on demand. T-1 in this repo's vault
carries the measured breakdown — 29 missing annotations being the bulk — and
removing these lines is that ticket's definition of done.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 20:33:58 +02:00
jpmschweitzerandClaude dac259af1d refactor(agents): type the agent and its conversation history
Partial work on the typecheck gate: 103 mypy errors down to 95, and the two
shared roots in agents/tatlock.py are gone. The rest is genuine per-function
annotation work and is not attempted here.

Five conversation lists were declared bare. mypy infers the element type from
the first append, which is a ModelRequest, and then rejects every ModelResponse
that follows — five errors from five lists that all hold the same thing: a
conversation, which is both kinds of message. Annotated as list[ModelMessage],
which is pydantic_ai's own union for exactly this.

The agent had no deps type. It is built as Agent(model, system_prompt=...),
inferred Agent[None, str], while every tool it registers takes
RunContext[ToolCallTracker] and run() is called with a tracker. The declaration
now says what was already happening: Agent[ToolCallTracker, str]. Note this is a
runtime-visible change — pydantic_ai is now told the deps type it was being
handed anyway — so it was verified against the suite rather than reasoned about:
658 passed.

_register_tools carries an assert rather than a None check. It is called from
_ensure_agent immediately after the agent is constructed, so a None there is a
broken invariant, not a case to handle; an `if is None: return` would silently
register no tools.

Two corrections to my own work in this commit. Declaring `_agent: Agent | None`
first made things worse, not better — resolving the bare Agent to Agent[None, str]
surfaced four new argument-type errors that the Any had been hiding, which is
how the missing deps type became visible at all. And an import fix I thought I
had made was a no-op: the target was a multi-line import, my replace matched
nothing, and I had asserted the precondition without asserting the result. Ruff
caught it. That is the same mistake as a changelog edit earlier today, so the
assert now checks what landed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 17:45:48 +02:00
jpmschweitzerandClaude 5b67f5b66c fix: clear the ruff findings that needed a decision
The 21 the automatic pass could not make on its own. `ruff check` and
`ruff format --check` are both clean now; typecheck is still red and is next.

`in_reasoning` in chat/service.py was a complete state machine that nothing read:
initialised False, set True when a reasoning delta arrived, set False when the
summary ended — three assignments, zero reads. Ruff reported one at a time, and
removing each revealed the next, so what looked like a single stray variable took
three passes to bottom out. The branches themselves do real work and are
untouched; only the flag is gone.

Four `raise HTTPException` inside `except` blocks now chain with `from e`. Until
now a failure while handling an error was indistinguishable from the error, which
matters most in exactly the situation where the traceback is all you have.

In biographer/tools.py the binding was unused but the call is not: MemoryType()
is called for the ValueError it raises on an invalid name. The binding is gone
and the call and its comment stay, because dropping the line would have removed
the validation.

The rest are unused bindings in tests where the assertions are on something else
(call_args, mostly), plus three unused loop variables and an isinstance tuple.

One correction to my own work: removing a dead comprehension in
test_error_handling.py left an `if` block with nothing but comments in it, which
is a SyntaxError. Ruff caught it immediately. The block now says what the test
actually pins — that the stream parses without crashing, which reaching that line
demonstrates — rather than computing a list nobody asserts on.

`make test` is intermittent here, and it is not this change.
test_tatlock_tool_call_logging_calculator failed in two of five full runs across
both HEAD and this branch, and passes in the other three; it also fails in
isolation at HEAD while passing in isolation here. Order- or timing-dependent.
Recorded rather than chased, since tests are not gated in this repo yet.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 17:37:33 +02:00
jpmschweitzerandClaude 78066fab1b style: apply ruff's automatic fixes and formatter
Mechanical only, and separated from the judgment calls that follow so the
reviewable changes are not buried in a 98-file whitespace diff.

227 automatic fixes: 60 blank lines carrying whitespace, 60 unsorted import
blocks, 34 Optional[X] to X | None, 28 unused imports, 16 deprecated typing
imports, 12 datetime.timezone.utc to datetime.UTC, and assorted smaller
modernisations. Then `ruff format` over src and tests: 98 files reformatted,
35 already conforming.

No file among the unused-import findings defines __all__ or is an __init__.py,
so nothing here removes a re-export.

`make test`: 658 passed, unchanged from HEAD.

Two things observed while verifying, neither addressed here:

`pytest tests/` cannot collect — tests/e2e/test_orchestration_e2e.py uses an
`e2e` marker that is not registered, and the config is strict about markers.
This fails identically at HEAD, so it predates this change; `make test` passes
because it ignores tests/e2e, tests/integration and tests/contracts.

test_tatlock_tool_call_logging_calculator is flaky. It failed once in a full run
with these changes and passed on the next, passes in isolation with them, and
fails in isolation at HEAD. It is order- or timing-dependent, not a regression
from this commit — established by running the full suite both ways rather than
by reasoning about which change could have caused it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 17:25:18 +02:00
jpmschweitzerandClaude 57fa6c13fc build(ci): move the pre-push gate into the Makefile
The hook carried ~50 lines of gitleaks logic and a comment explaining it was
self-contained because "this repo has no Makefile". It has one now, so the
reason is gone and the arrangement is backwards: a hook is a trigger, and
logic belongs where it can be read, run by hand, and changed under review.

.githooks/pre-push is now a byte-identical shim onto `make pre-push` in every
repo in the workspace. The scan itself moves to ci/secrets.sh unchanged, and
`make secrets` runs it on its own.

The call surface is identical everywhere; what it runs is not, and should not
be — each repo gates what it actually has. That is the point of standardising
the name rather than the contents: nobody has to read a repo to find out how
to check it.

secrets runs first, deliberately. It is the only failure here that cannot be
undone by fixing it afterwards — a failed lint costs another commit, a pushed
credential is cached and indexed whether or not it is later deleted.

Some of these gates fail today, on lint debt that predates them, and they are
left wired anyway. The board was measured once and written down in T-56
instead of being worked around here. Narrowing each gate to whatever already
passes would produce a gate that reports success for doing nothing, which is
the failure this workspace keeps rediscovering.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 18:57:22 +02:00
jpmschweitzerandClaude 2fb2fab395 chore(claude): pin PQL_VAULT per project so cwd stops choosing the vault
pql is now a bare word on PATH, which removed the long incantation that had
been forcing --vault into every call by habit. Convenience lowered the cost
of the wrong thing without lowering the cost of the right one: a three-word
pql ticket new targets whichever vault the cwd happens to sit in, and there
are nine of them with colliding id sequences.

PQL_VAULT in each project settings file makes the vault a property of the
session rather than of the working directory — the same lesson Rule 3 records
for git -C, applied to pql. Verified the env var overrides cwd discovery,
that an explicit --vault still beats the env var, and that the harness
hot-reloads it without a restart.

This does not make provenance visible: no output says which vault answered,
so a forgotten --vault still returns a well-formed answer about the wrong
dataset. That remains T-37.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 13:49:02 +02:00
jpmschweitzerandClaude 5bb4013821 chore(claude): deny toj in the sub-repos
toj is now on the global PATH as /usr/local/bin/toj, so its scope boundary
had to stop being "the absolute path is inconvenient to type" and start
being a rule. Its repo and settings verbs operate on the workspace root; run
from inside this repo they answer about the wrong tree.

Both spellings are denied, bare and absolute, because a deny with one
spelling left open is decorative.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 13:42:08 +02:00
jpmschweitzerandClaude 6ab68d3971 ci: gate pushes on a gitleaks scan of the outgoing commits
No repo here scanned for committed credentials. The hook is self-contained
rather than delegating to a Makefile, because this repo has none and a hook
reaching into a sibling repo breaks the moment this one is cloned elsewhere.

Scans the outgoing range rather than full history: history carries settled
findings — test fixtures, vendored third-party code — and a gate that fails
on something unfixable gets bypassed within a week.

Setting core.hooksPath means pql init must replant its replication shims into
.githooks, which is why they are gitignored here alongside the tracked
pre-push. Same layout pql itself uses.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 12:48:57 +02:00
jpmschweitzerandClaude 31d09a6ed1 docs: qualify workspace decision ids cited from this repo
Decision ids are per-vault sequences, so they collide by construction
once there is more than one vault -- and every repo now has one. A bare
D-15 here will mean this repo's D-15 the moment this repo records one.
Cross-vault references are therefore qualified: workspace D-15.

Not hypothetical: pql holds D-1 through D-31 while the workspace holds
D-1 through D-21, so every workspace id currently collides with an
unrelated pql one. A bare id is not wrong the day it is written -- it
decays into wrong as the other vault grows, and nothing flags it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 04:17:07 +02:00
jpmschweitzerandClaude f8059771ce 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>
2026-08-09 03:16:29 +02:00
jpmschweitzerandClaude 6b1c892bc6 chore: adopt the workspace agent-config baseline
Commits a .claude/settings.json rather than leaving permissions to
per-developer local state, and initialises a pql vault for this repo's
tickets and internal decisions.

Every git deny rule appears in both the `git <verb>` and `git * <verb>`
forms. Only the second catches `git -C <path>`, and without it the whole
deny list is decorative -- it looks like a policy and stops nothing.

The allow list carries pql's absolute path alongside the bare name.
pql is installed to ~/.local/bin, which is on the login PATH but not the
one a non-interactive shell gets, so the bare-name rules match nothing on
their own and every call would prompt anyway.

.gitignore now covers .claude/settings.local.json, which is machine-local
and must never be shared. `pql init` contributed the .pql/* rules with an
exception for the changelog, which is the replication log of record and
has to be committed for tickets to travel with a clone.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 03:16:12 +02:00
jpmschweitzerandClaude 84467c121a chore: release v2.4.3
Build and Push / release (push) Successful in 2s
Build and Push / build (push) Successful in 1m14s
Ships the Steward capability-extraction fix (a905363), which has been on
main since earlier today while production continued to route on prose:
the running v2.4.2 still matches capability domains as substrings across
the Steward's whole response, so "description" selects housekeeper and
"acknowledge" selects librarian and biographer.

Patch rather than minor: no new capability, and the JSON on the wire is
unchanged. What changes is which agents get invoked, and only in the
cases that were already wrong.

Also carries the routing benchmark, its fixtures, the shared GPU
residency guard and the findings document, none of which are
user-visible.

Co-Authored-By: Claude <noreply@anthropic.com>
v2.4.3
2026-08-08 18:30:28 +02:00
jpmschweitzerandClaude 2290320e9c docs: record the Steward routing and thinking findings
No change shipped. The Steward stays on gemma4:e2b with thinking left at
its default, and this records why so the experiment is not repeated on
the premise that started it.

That premise was wrong. The Steward appeared to pay ~300 tokens per turn
for reasoning that was generated and discarded, since no `thinking` field
comes back. The reasoning is emitted inline in the response instead, and
it is what produces a correct DELEGATE line — suppressing it costs 12.5
points of routing accuracy, entirely on multi-capability queries where
the model stops decomposing and names one capability.

e4b is disqualified by memory rather than quality: Ollama predicts
10.6 GiB for it against ~7.9 GiB available, so it evicts every
co-resident before loading, including nomic-embed-text. Lowering context
length does not rescue it — an 8x reduction moved the prediction only
1.1 GiB — and per-request num_ctx reloads the shared runner, dropping the
keep_alive pin and evicting nomic.

Also records that the two axes are independent: model choice governs
VRAM and co-residency, think setting governs tokens and latency and
costs nothing in VRAM.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 18:01:11 +02:00
jpmschweitzerandClaude bf13f9f0de refactor(bench): share the GPU residency guard, and guard tool calling too
Extracts the residency snapshot/restore into scripts/ollama_residency.py
so the two benchmarks cannot drift, and applies it to
benchmark_tool_calling.py, which had no protection at all.

That script was the more dangerous of the two. It rewrites
OLLAMA_DEFAULT_MODEL in .env and lets uvicorn reload onto it, restoring
the original only after the loop — so any crash or interrupt left the
*running server* pointed at the benchmark model. Its DEFAULT_MODELS
begins with mistral-nemo-large, the 9.2G model implicated in the
2026-08-07 VRAM outage. Both the .env restore and the residency restore
now run from `finally`.

SIGTERM is handled explicitly in the shared module. Python runs `finally`
for SIGINT, which arrives as KeyboardInterrupt, but the default SIGTERM
action terminates outright, so `timeout` or a plain `kill` skipped the
guard entirely.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 17:03:53 +02:00
jpmschweitzerandClaude 4f42bc047a test(bench): restore GPU residency after a benchmark run
Benchmarking swaps models on the GPU production is serving from. Ollama
evicts to make room, so the first run unpinned gemma4:e2b and left
gemma4:e4b resident: the next voice turn would have paid a ~36s cold
load, and only the monitoring noticing unexpected_models caught it.

Snapshot residency and pinning before the run, then evict whatever the
benchmark loaded and re-pin what was pinned before.

The restore is wired to SIGTERM as well as the normal exit path. Python
runs `finally` for SIGINT, which arrives as KeyboardInterrupt, but the
default SIGTERM action terminates outright — so a `timeout`, a systemd
stop or a plain `kill` skipped the guard entirely. That was not
theoretical: the first SIGTERM after adding this bypassed it, and the
pinned model survived only because the run had not reached the second
model yet.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 16:08:03 +02:00
jpmschweitzerandClaude 738ff10b93 test(bench): add labelled routing fixtures and router benchmark
Measures Steward routing against model and thinking settings by talking
to Ollama directly. No server, no agents, nothing executed — the
mutating fixtures only ever produce a routing decision — so the run is
cheap, repeatable and isolates routing from everything downstream. The
request body mirrors StewardAgent._call_ollama, so the `unset` cell is
exactly what production sends today.

Three thinking settings rather than two. `unset` is production, and it
is not neutral: gemma4 reasons by default and returns no `thinking`
field, so those tokens are generated and discarded.

Scoring is asymmetric on purpose. Each fixture carries `forbid` as well
as `expect`, because over-routing is the predicted failure when thinking
is off and it is the expensive one — a spurious librarian is a real web
call on a query that asked for arithmetic.

The adversarial group is regression coverage for the extraction fix in
a905363: those queries invite the vocabulary that used to select agents
by substring, so they now assert that routing follows what the Steward
decided rather than the words it used while explaining.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 15:22:01 +02:00
jpmschweitzerandClaude a90536314e fix(steward): route on the declared DELEGATE line, not on prose
The prompt tells the Steward to state its choice on a DELEGATE line and
to explain itself on REASON, COMPLEXITY and CONTEXT lines. Extraction
ignored that structure and substring-matched capability domains across
the entire response, so ordinary English in the explanation selected
agents: "description" contains the housekeeper domain "script",
"discover" contains "cover", "acknowledge" contains "knowledge" and
"know", "economy" contains the biographer domain "my".

Every one of those was a real delegation. A spurious librarian is a
multi-second web call on a query that asked for arithmetic.

It also made prose length a routing input, which would have quietly
corrupted the thinking benchmark this was found during: anything that
shortened the Steward's output reduces accidental substring hits and so
reads as improved routing.

Resolution is now layered, most explicit first — a DELEGATE line opening
with a capability name, then a capability named anywhere on that line,
then a domain on that line. With no DELEGATE line at all the response is
matched on capability names only, never domains, so the conversational
path still answers with no capabilities. Matching is whole-word
throughout.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 14:37:59 +02:00
jpmschweitzerandClaude 19e32cfbd6 docs(tests): correct e2e prerequisites in module docstring
Missed in the previous sweep: this docstring still named wakeup.sh, which
the Makefile replaced, and mistral-nemo, which gemma4:e2b replaced.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 15:10:09 +02:00
jpmschweitzerandClaude 99569e786e docs: correct stale tooling and model references
Three migrations left their documentation behind:

wakeup.sh was replaced by the Makefile during the project structure
consolidation, but AGENTS.md and the e2e README still tell you to run it.
The log path moved to build/logs/server.log at the same time.

The local model moved to gemma4:e2b, but the e2e prerequisites and the
benchmark recommendation still name mistral-nemo.

The benchmark figures in CLAUDE.md predate the current model. Measured
2026-08-07: ~95 tok/s, full flow ~10-13s for simple turns, cold model load
~36s rather than ~8s. A turn costs three sequential Ollama calls and ~710
generated tokens regardless of how trivial the question is.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 15:07:10 +02:00
jpmschweitzerandClaude Fable 5 2cf3252a19 docs(claude-integration): registry is git.schweitz.net not git.schweitz.internal
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 17:11:18 +02:00
jpmschweitzerandClaude Fable 5 cdd5a55613 chore: release v2.4.2
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 1m3s
Fixes the v2.4.1 crash-loop: fresh image builds resolved
opentelemetry-api 1.44.0, which removed the private _events module
that pydantic-ai 1.27 imports at startup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v2.4.2
2026-07-19 13:10:57 +02:00
jpmschweitzerandClaude Fable 5 287d66fff7 chore: release v2.4.1
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m44s
Move the container-name network-defaults change from [Unreleased] into
the 2.4.1 section and bump pyproject.toml. Patch release: the change
corrects service-host defaults (SEARXNG_HOST, LIBRARY_DESK_HOST,
CORE_API_HOST) for the docker-dataplane deployment, including the
wrong CORE_API_HOST port.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v2.4.1
2026-07-19 12:32:10 +02:00
jpmschweitzerandClaude Fable 5 65debb6e44 fix(config): default service hosts to docker container names
The homelab is retiring *.schweitz.internal and will rebind host
ports to loopback; container-to-container traffic must use container
names on docker-dataplane.

- SEARXNG_HOST: http://localhost:8087 -> http://searxng:8080
  (SearXNG's internal port is 8080; 8087 was the host-published port)
- LIBRARY_DESK_HOST: http://localhost:8089 -> http://library-desk:8089
- CORE_API_HOST: http://localhost:8090 -> http://core-api:8083
  (8090 is the Scheduler's host port; Core-API serves 8083 internally,
  confirmed by the housekeeper client and test suite hitting :8083)
- scripts/test_housekeeper.sh: reach Core-API via localhost:8083
  instead of the LAN IP, which will refuse after loopback rebinding

Local development against host-published ports keeps working via .env
overrides (.env.example unchanged; localhost stays valid on the host).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:21:40 +02:00
jpmschweitzerandClaude Fable 5 0d7514b90e chore(ci): push images via git.schweitz.net registry
The .internal registry domain is being retired; git.schweitz.net now
serves the registry without SSO on /v2/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:10:04 +02:00
jpmschweitzerandClaude Fable 5 99683357d2 docs: replace CPU-era latency figures with measured GPU numbers
The ~35s steward / ~2 min flow figures dated from the driver-mismatch era
and were being inherited by downstream consumers (desklock architecture
doc) as planning baselines. Current measured: steward ~6s warm, full flow
11-25s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 17:17:13 +02:00
jpmschweitzerandClaude Fable 5 f0a08ede64 chore: release v2.4.0
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 1m55s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v2.4.0
2026-07-14 15:50:02 +02:00
jpmschweitzerandClaude Fable 5 a8bc282576 fix(librarian): expose page_id in wiki search results
search_wiki printed ordinally numbered results with no page ID while
get_wiki_page demands 'the page ID from search results' - the model
passed the list position (page 1) and 404'd. Results now carry
page_id and drop the ordinals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:37:00 +02:00
jpmschweitzerandClaude Fable 5 9184b48673 fix(ollama): sanitize null content on every message shape
gemma thinking-only assistant turns carry content: null with no
tool_calls, slipping past the tool-call-only sanitizer and 400ing the
whole agent run ('invalid message content type: <nil>'). Null content is
now blanked for any role.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:37:00 +02:00
jpmschweitzerandClaude Fable 5 fccbe65ecf fix(librarian): use terse tool-phase prompt so gemma4 calls tools
The scholarly persona prompt reproduced the exact pathology
TATLOCK_ORCHESTRATION_PROMPT fixed for the butler: gemma4 answered in
character ('please provide your request') without calling a single tool.
The research phase now uses a tool-discipline prompt; Tatlock's synthesis
supplies the voice. Anti-fabrication rules kept verbatim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:37:00 +02:00
jpmschweitzerandClaude Fable 5 2f6e444147 docs: drop deleted run_librarian_stream from unreleased changelog entry
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 12:45:48 +02:00
jpmschweitzerandClaude Fable 5 5376a34645 refactor(agents): reduce protocol.py to the live AgentError
Post-coordination-removal sweep: the coordination wire protocol
(AgentRequest, AgentResponse, DelegationIntent, CoordinationResult,
DelegationReason, TaskComplexity, ToolCallRecord, AgentTimeoutError,
AgentUnavailableError, DelegationError) had zero importers left in
src/ - only its own test module. AgentError stays (raised by
run_librarian, mapped to user-safe failures by delegation.py).
Also drops the stale coordination.py line from the README tree.

Import-cycle sanity: python -c 'import src.main' passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbFZyDvYksazX6nYQYZ67L
2026-07-14 12:03:31 +02:00
jpmschweitzerandClaude Fable 5 8cf3609948 fix(librarian): quiet healthy-search coverage notes, tag clearing, strict result pairing
Phase A review minors:

- Coverage note: source_status (when present) is now used exclusively;
  the source_counts-absence fallback only considers the optional legs
  the request explicitly enabled (web/documents/volatile). library-desk
  computes source_counts from the final top-N fused results only, so
  absence of the always-on vector/graph legs is normal ranking behavior
  - the old heuristic warned on virtually every healthy search
- update_wiki_page: the empty-list tags sentinel (leave unchanged) made
  clearing all tags impossible; pass exactly ["__CLEAR__"] to send an
  empty tag list, documented in the docstring for the local model
- Text-delegation parallel fallback: zip(..., strict=True) with an
  explicit count-mismatch guard so results can never be silently
  attributed to the wrong agent

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbFZyDvYksazX6nYQYZ67L
2026-07-14 11:59:15 +02:00
jpmschweitzerandClaude Fable 5 31b948a748 refactor(agents): delete dead coordination/streaming delegation stack
One delegation implementation remains (src/agents/delegation.py).
Removed, after verifying zero live importers post-Phase-A/B:

- src/agents/coordination.py: CoordinationEngine, duplicate
  delegate_to_librarian, AGENT_EXECUTORS/AGENT_STREAM_EXECUTORS
  (only importer was its own test module)
- run_librarian_stream: documented-broken path (Ollama streaming +
  tool call bug, PydanticAI #1292/#2256), only called by the deleted
  coordination engine
- stream_delegate_to_* wrappers + STREAMING_DELEGATION_WRAPPERS and
  the never-parsed __DELEGATION_RESULT__ marker in delegation.py
- HouseholdRegistry.get_streaming_delegation_tools() (no callers)
- tests/agents/test_coordination.py and the wrapper/stream tests

Note: the STREAMING_DELEGATION_WRAPPERS import in
src/responses/streaming.py was already removed by Phase A (7ce1c1a);
nothing to delete there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbFZyDvYksazX6nYQYZ67L
2026-07-14 11:52:36 +02:00
jpmschweitzerandClaude Fable 5 c00224222b fix(librarian): apply tenant guard to explicit user args in client
LibraryDeskClient._resolve_user only enforced non-empty: an explicit
user argument to any tenant-scoped method bypassed tatlock's tenant
guard entirely and went straight to library-desk, and padded values
were sent un-stripped on the wire.

Route the explicit-arg path through the same apply_tenant_guard() used
by context resolution and strip whitespace before the empty check, so
a non-production environment can never send the production tenant (or
a sanitization-collision variant) to library-desk, regardless of how
the user was supplied. Defense in depth - no in-repo caller passes an
explicit user today.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:29:14 +02:00
jpmschweitzerandClaude Fable 5 e8e5d367b6 fix(tenant): guard against sanitization collisions with production tenant
The request-level tenant guard compared the raw user string exactly
(user == PRODUCTION_TENANT), but all local namespaces (Qdrant
collections, Redis keys) are derived through sanitize_user_id(), which
lowercases and strips/maps punctuation. Case or punctuation variants
("JPMSchweitzer", "jpmschweitzer.", " jpmschweitzer") therefore passed
the guard yet resolved to the production namespaces, letting a dev
instance on the shared services read/write production tenant data.

- context.py: compare sanitize_user_id(user) against the sanitized
  production tenant; expose the guard as public apply_tenant_guard()
- config.py: startup refusal validator uses the same sanitized
  comparison, so a colliding DEFAULT_USER refuses startup loudly
  instead of relying on the allowlist fallback
- tests: variant matrix at both config and request-context level,
  plus a non-colliding passthrough case

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:23:36 +02:00
jpmschweitzerandClaude Fable 5 b47c5b9281 test: hard-fail the suite when the tenant resolves to production
Session-scoped autouse guard in tests/conftest.py refuses to run any
test (pytest.exit, returncode 1) when the effective tenant resolves
to the production tenant jpmschweitzer - the same guard library-desk
applies on its side. _initialize_app now depends on the guard so the
refusal happens before any initialization.

Suite-level assertions pin that the live session runs under the
llm_tester namespaces: Qdrant memories_llm_tester collection and
Redis session:llm_tester:* keys. The biographer/memory unit tests
already run fully mocked (no shared-service writes); the e2e
isolation tests already used llm_tester - their constants now derive
from the shared TEST_TENANT/PRODUCTION_TENANT config constants so a
drift fails loudly instead of silently splitting.

Verified: ENVIRONMENT=production pytest run exits 1 with the TENANT
GUARD message and zero tests executed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:08:39 +02:00
jpmschweitzerandClaude Fable 5 b9eae38556 feat(librarian): send explicit non-empty user on every library-desk request
Library-desk is removing its server-side default user, so a request
without an explicit tenant will 422 after its next deploy:

- New client-level _resolve_user() resolves the tenant (explicit arg
  or request context) and raises ValueError on an empty/whitespace
  value BEFORE any bytes hit the wire; all 15 tenant-scoped methods
  use it
- extract_content / extract_content_batch now accept and send the
  user (query param), matching the rest of the API surface
- search_web no longer falls back to a phantom "tatlock-librarian"
  tenant; it sends the resolved user
- health_check stays user-less (public, not tenant-scoped)

Tests: parametrized sweep pins the wire contract (user present in
params or payload) for every tenant-scoped method, for both context
and explicit users; empty-tenant calls are asserted to fail without
any HTTP call; the recorded-fixture hybrid contract test now pins
user as an explicit query param.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:02:46 +02:00
jpmschweitzerandClaude Fable 5 4b786a766c feat(core): enforce tenant isolation guard outside production
Non-production environments (development/testing) now force the
effective tenant to the reserved test tenant "llm_tester" (or a
test_-prefixed override) regardless of DEFAULT_USER misconfiguration:

- Config.effective_default_user only honors DEFAULT_USER outside
  production when it is llm_tester or test_-prefixed; anything else
  is forced to llm_tester (tenant_forced flags the override)
- Config refuses startup (validation error) when a non-production
  environment is explicitly configured with the production tenant
  jpmschweitzer
- get_user() applies the same guard at request-context resolution,
  so an explicit request for the production tenant in dev/test is
  forced to llm_tester with a warning log
- initialize_application() emits one loud startup log line
  (tenant_guard_active / tenant_guard_production) stating the
  effective tenant

Unit tests cover the dev/test/prod x default/explicit-user matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:56:29 +02:00
jpmschweitzerandClaude Fable 5 7ce1c1a314 feat(responses): pass conversation context and stream thinks in real time
- delegate_to_* now receives a trimmed conversation history (last ~6
  turns, 500 chars/turn) as context on both live direct-delegation
  paths (streaming and steward non-streaming), via new
  build_delegation_context helper
- _stream_direct_delegation restructured as an async generator: the
  butler 'start' think message streams BEFORE the expert runs and the
  success/error message right after it finishes, instead of all
  messages arriving after the research completed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:30:34 +02:00
jpmschweitzerandClaude Fable 5 0708c759fc fix(librarian): replace nullable tool params with sentinel defaults
Ollama's OpenAI-compatible API mishandles anyOf[X, null] parameter
schemas. update_wiki_page (content/title/tags/description) and
smart_create_wiki_page (path) now use empty-string/empty-list
sentinels translated to None inside the tool, following the
biographer pattern from 9d7ce39.

Adds a snapshot test that walks every registered librarian tool's
emitted JSON schema and fails on any anyOf[..., null].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:26:55 +02:00
jpmschweitzerandClaude Fable 5 24fed8814f feat(librarian): bounded retries, timeout wiring, and client reuse
- 2-attempt short-backoff retry for GETs and the read-only
  POST /query/* and /rag/search endpoints only; wiki writes are never
  retried (duplicate-page risk)
- honor the defined-but-ignored LIBRARY_DESK_TIMEOUT config instead of
  hardcoded 60s/30s per-call values
- hold ONE shared httpx.AsyncClient per librarian run via
  library_client_session (contextvar), instead of constructing a
  client per tool call; nested sessions are no-ops and custom targets
  still get their own client
- read tools raise ModelRetry on transient HTTP errors (transport
  errors, 5xx, 429) so Agent(retries=2) engages; write tools keep
  returning safe failure messages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:24:42 +02:00
jpmschweitzerandClaude Fable 5 18f2e0efbd feat(agents): enforce one librarian timeout budget
- add LIBRARIAN_TIMEOUT config (default 180s) and enforce it with
  asyncio.wait_for inside delegate_to_librarian, covering the live
  paths (steward direct delegation and SSE streaming) that had no cap
- timeouts fail honestly: success=False with a curated butler sentence,
  detail in logs
- set an explicit timeout on TatlockOllamaProvider's AsyncOpenAI client
  from OLLAMA_TIMEOUT instead of the SDK default (~600s per LLM call)
- remove the contradictory unused 60s default from
  AgentRequest.timeout_seconds; coordination falls back to the
  configured budget

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:19:16 +02:00
jpmschweitzerandClaude Fable 5 99e1fe33ca feat(librarian): signal degraded search coverage
- parse source_counts into HybridRAGResponse and additively parse the
  shared-contract source_status/degraded fields when present (absence
  tolerated, so deploy order between tatlock and library-desk never
  matters)
- hybrid_search appends a one-line coverage note when a leg reported
  'failed' (or degraded is set), falling back to inferring silent legs
  from source_counts on older library-desk versions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:15:09 +02:00
jpmschweitzerandClaude Fable 5 f853db8ccc fix(agents): make librarian failures structured and user-safe
- run_librarian / run_librarian_stream raise AgentError instead of
  returning/yielding error text as normal output; detail stays in logs
- delegate_to_* wrappers now put a curated butler-toned sentence in
  DelegationResult.output on failure and never expose str(e), so
  streaming's error branch is reachable and honest
- _execute_single_delegation propagates success; direct delegation only
  records delegate_to_* as called when the expert actually succeeded
- librarian tools return user-safe messages instead of
  'Error searching: {e}' strings that leaked internal URLs into
  synthesis; coordination stream errors are curated as well
- ruff cleanups (TYPE_CHECKING forward refs, B904, unused locals) in
  the touched files to keep them lint-clean

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:12:30 +02:00
jpmschweitzerandClaude Fable 5 59f5b54ac9 fix(librarian): map live HybridRAG response fields correctly
The client parsed field names the live library-desk service never
returns, so every result rendered as "unknown (score: 0.00)":

- source_type/sources -> source + sources (icons key off sources values)
- rrf_score -> score
- context -> formatted_context
- related_dossiers are per-result; top level aggregates unique titles
- synonyms live inside the keywords dict as a {term: [synonyms]} map

Also stop sending zero limits (service 422s on limit < 1); disabled
legs now rely on the enable_* flags with limits clamped to >= 1.

Adds a recorded live response as a fixture plus contract tests that
pin the mapping (non-unknown sources, non-zero scores, icon coverage).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:00:10 +02:00
jpmschweitzerandClaude Fable 5 6ec77091b6 style(librarian): apply ruff autofixes to client and tools
Mechanical Optional[X] -> X | None and f-string cleanups so subsequent
librarian changes lint clean against the dirty baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 09:58:43 +02:00
jpmschweitzerandClaude Fable 5 11405e0acb chore: release v2.3.0
Build and Push / release (push) Successful in 25s
Build and Push / build (push) Successful in 4m34s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v2.3.0
2026-07-13 23:54:32 +02:00