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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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
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
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
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>
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>
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>
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>
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>
- 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>
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>
- 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>
- 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>
- 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>
- 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>
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>
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>
tests/contracts sends the raw requests the code sends to Ollama (native API
and OpenAI-compat tool calling), Anthropic (including the pinned Sonnet 5
temperature-rejection contract), Qdrant, SearXNG, library-desk, and Redis.
Unreachable services skip; wrong response shapes fail. Run via
make test-contracts; excluded from the unit suite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With the butler persona prompt attached, gemma4 reasons about calling the
calculator and then answers from memory with a different wrong product every
run; tool_choice=required via extra_body is advisory at best on Ollama's
OpenAI-compat layer. orchestrate_tool_calls() now uses a terse
TATLOCK_ORCHESTRATION_PROMPT; synthesize_from_results() keeps the persona,
so the user-visible voice is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rolls back the claudification backend preference: PREFER_CLOUD_BACKEND now
defaults to false, resolve_backend() picks Ollama first and uses Claude when
explicitly preferred or when the new Ollama startup health check fails. The
Steward retries mid-request failures on the other backend in both directions.
Also hardens the fallback itself: Anthropic SDK imports are lazy so a broken
anthropic package degrades to Ollama-only instead of crashing at import time
(root cause of the production outage since April), anthropic is pinned to a
pydantic-ai-1.27-compatible range, ANTHROPIC_MODEL defaults to claude-sonnet-5
(sonnet-4-20250514 retired 2026-06-15), sampling parameters are stripped from
Claude calls (Sonnet 5 rejects them), and the Steward timeout is configurable
(STEWARD_TIMEOUT, default 60s) since gemma4 needs ~35s warm for analysis.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gemma4:e2b has native function calling with dedicated tool tokens,
achieving 100% tool selection accuracy in benchmarks vs 67% for
mistral-nemo-large, with 5-8x faster response times (2-4s vs 15-20s)
and lower VRAM usage (8GB vs 9.2GB).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests are run locally before tagging. Removes the slow CI test job
and its dependency gates on release and build jobs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
exclude_none was too aggressive — it stripped finish_reason: null from
intermediate chunks (which OpenAI includes). exclude_unset correctly
omits only fields never passed to the constructor (like reasoning_content
on content-only chunks) while preserving explicitly-set finish_reason: null.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Documents architecture, key file locations, test setup, and critical
gotchas discovered during development (ASGITransport lifespan, async
scope mismatch, Ollama fallback behavior, missing benchmark store).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove references to unimplemented get_benchmark_store from steward and
tool tracking tests
- Fix steward test fixture calling async initialize_application synchronously
by using sync register_household_members instead
- Rewrite tool tracking tests to assert actual logging behavior
- Change unit test fixture model from Tatlock to lorem-tester so unit tests
don't require external services
- Add session-scoped _initialize_app fixture to run Claude health check,
ensuring integration tests use Claude instead of falling back to Ollama
- Increase integration test timeouts from 30s to 120s to match OLLAMA_TIMEOUT
- Add Steward reasoning as ReasoningOutputItem in create_response_with_steward
so <think> tags appear in chat completion responses
- Add test_tatlock_ollama_fallback to verify Ollama fallback path works
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
OpenAI's API omits null fields in streaming chunks, but Tatlock was
including them (content: null, reasoning_content: null). This caused
parsing issues in Open WebUI's streaming handler.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
sse_starlette's EventSourceResponse added \r\n line endings that
Open WebUI couldn't parse. Switched to plain StreamingResponse with
manual SSE formatting matching OpenAI's exact format.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The Steward's internal routing analysis (DELEGATE, COMPLEXITY, etc.)
was being exposed in <think> blocks. This is implementation detail,
not useful reasoning for the user.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
PydanticAI handles tool_choice natively for Anthropic. The extra_body
hack caused an infinite tool call loop where Claude kept calling the
same tool because tool_choice was forced to "any".
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Changed workflow trigger from release:published to push:tags:v[0-9]*
so that pushing a version tag triggers the build pipeline.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
AnthropicModel doesn't accept api_key directly; it must be passed
through an AnthropicProvider instance.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
All agents now prefer Claude API when ANTHROPIC_API_KEY is configured,
with automatic fallback to Ollama when offline or unconfigured. New
src/anthropic/ module provides model selection via get_model() factory.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add Paperless document search to HybridRAG pipeline
- Add volatile cache (weather, forecast, news, stocks) to HybridRAG
- Add include_documents and include_volatile params to hybrid_search
- Add 📑 and ⚡ icons for document/volatile sources
- Update Librarian prompt with new data source awareness
- Fix Biographer routing: personal memory queries now route correctly
- Add location keywords to Steward pre-fetch logic
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Strengthened personality prompt to prevent unnecessary apologies after
successful Librarian delegations. Added explicit "do NOT apologize"
instructions to both system prompt and synthesis prompt.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Change uvicorn from localhost to 0.0.0.0 to allow connections
from other machines on the network.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add explicit instructions to the Librarian system prompt to never
invent data when tools fail or data sources are unavailable.
- Report what failed specifically
- Never provide placeholder or made-up data
- Better to return no information than fabricated information
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Instrument the full request flow with trace spans for debugging:
- Wrap expert delegations (librarian/biographer/housekeeper) in spans
- Add orchestrate and synthesize spans to TatlockAgent
- Trace Steward analysis in preprocessing
- Start/end traces in response service with context management
- Simplify router by moving context handling to service layer
- Include tracing router in debug mode
- Remove benchmark recording from tool_tracking and steward service
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Remove the Redis-backed performance benchmarking in favor of the new
lightweight file-based tracing system which provides better debugging
capabilities for local development.
- Delete src/core/benchmarks.py
- Remove ENABLE_BENCHMARKS, REDIS_BENCHMARK_DB, redis_url from config
- Update memory_cache comment (now uses DB 1)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Adds JSON-based tracing system for local development that captures
the full request flow through Tatlock's multi-agent architecture.
- Trace/Span dataclasses with automatic timing and nesting
- Context-var based propagation for async-safe tracing
- trace_span async context manager for clean instrumentation
- Traces written to logs/traces/{trace_id}.json
- REST API for listing and retrieving traces (/traces)
- Standalone HTML viewer with timeline visualization
Enabled via DEBUG=true environment variable.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Rewrite system prompt with negative constraints and step-by-step process
- Set temperature to 0.1 for deterministic tool calling
- Sort room groups to top of device list (address positional bias)
- Add [ROOM GROUP] marker in list_devices output
- Update tool docstrings with explicit entity_id= parameter examples
- Add optimization findings doc (experiment log: 0% → 100% success)
- Add test script for room group detection regression testing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Update all client endpoints to use /housekeeping/ prefix
- Add critical rule requiring list_devices() before control actions
- Add housekeeping API spec documentation
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Convert booleans to strings for Redis hset (Redis doesn't accept bool)
- Extract capability from delegate_to_X tool names for tracking
- Use loop_scope="module" for pytest-asyncio module-scoped fixtures
- Add note about using venv for tests in AGENTS.md
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Documents the version bump, changelog update, tagging, and
deployment verification steps.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Messages in reasoning_content should be plain text, not wrapped
in <think> tags. Removed wrappers from:
- delegation.py household think messages
- orchestration.py status messages
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Use DeepSeek R1 format (reasoning_content field) instead of <think>
tags in content. Open WebUI now renders thinking as proper
collapsible blocks instead of broken escaped HTML.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
library-desk now returns keywords as dict with core_keywords field.
Client now handles both list and dict formats for backwards compat.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Fix `invalid message content type: <nil>` error from Ollama
- Create TatlockOllamaProvider that sanitizes messages (null → "")
- Update all agents to use sanitized provider
- Fix repeating think messages by adding ReasoningSummaryDone signal
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Fixes several issues with the web search migration to Librarian:
- Update Steward routing guidelines for web search/weather → Librarian
- Register search_web, read_url, read_urls_batch tools with Librarian agent
- Update Librarian system prompt with web search documentation
- Fix query enrichment not being passed to delegations (location context)
- Add URL reading keywords to RESEARCH action type detection
Weather queries now automatically include user's stored location from
the Biographer, enabling location-aware search results.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Move web search functionality to The Librarian agent, integrating with
the library-desk /rag/search endpoint for enhanced search capabilities.
Changes:
- Add search_web, read_url, read_urls_batch tools to Librarian
- Add WebSearchResult, ContentExtractionResult models to client
- Add search_web, extract_content, extract_content_batch client methods
- Update Librarian capability with web/url/internet domains
- Remove search_web from tatlock_core tools and toolset
- Update Tatlock system prompt to delegate web search to Librarian
- Add comprehensive unit tests for new Librarian tools
- Clean up legacy src/agents/tools.py
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Two-Phase Tatlock Execution:
- orchestrate_tool_calls() for Phase 1 coordination
- synthesize_from_results() for Phase 2 butler-toned synthesis
- Guarantees butler personality in all responses
Automatic Think Slugs:
- Deterministic butler-perspective messages during expert delegation
- ActionType enum: RETRIEVE, RESEARCH, CREATE, CONTROL, RECORD
- HOUSEHOLD_THINK_MESSAGES mapping for all experts
- Streaming delegation wrappers with automatic think messages
Steward Query Enrichment:
- Auto-fill user context (location, timezone) when not specified
- _build_enriched_query() with regex word boundary matching
- enriched_query field in StewardRecommendation schema
Documentation:
- ORCHESTRATION_SCENARIOS.md rewritten with Mermaid diagrams
- New Housekeeper and Biographer scenarios
- TESTING_IMPROVEMENTS.md for future LLM testing patterns
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Implements The Housekeeper, a new expert agent for home automation
following the Librarian pattern. Communicates with core-api service
which wraps Home Assistant REST API.
New agent features:
- CoreAPIClient with 13 home automation methods
- 13 tools: list_areas, list_devices, get_device_state, turn_on,
turn_off, toggle, list_scenes, activate_scene, list_scripts,
run_script, list_automations, toggle_automation, get_history
- PydanticAI agent with butler-friendly system prompt
- HouseholdCapability registration for Steward coordination
- delegate_to_housekeeper() wrapper for orchestration
Also includes:
- Dev port changed from 8123 to 8777 (avoids Home Assistant conflict)
- Config: CORE_API_HOST, CORE_API_KEY, CORE_API_TIMEOUT
- 44 unit tests for client and capability
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
### Added
- Environment-aware configuration:
- Auto-selected logging (DEBUG for dev, WARNING for prod)
- Auto-selected default user (llm_tester for dev isolation)
- User context logging at request entry
- Direct delegation bypass:
- Pure memory/librarian requests skip Tatlock LLM
- Reduces latency for memory-only requests
- Text-based delegation fallback:
- Parse [DELEGATE:agent] patterns from LLM output
- Sequential and parallel execution support
- Comprehensive E2E test suite:
- 22 orchestration tests with QdrantVerifier
- assert_llm_behavior() for flexible pattern matching
- Tests for memory, delegation, isolation, scenarios
### Fixed
- Unit test mocks for streaming (async generator)
- Temporal context handling in tests
- LLM non-determinism with pytest.xfail()
- Streaming test timeouts increased
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Change `str | None` to `str` with empty default for memory_type
- Remove `keywords` parameter from store_insight (auto-generated anyway)
- Ollama's OpenAI API doesn't handle union types with None properly
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add delegate_to_biographer to household registry delegation map
- Was returning raw tools which caused Ollama "invalid message content type: nil"
- Add Qdrant host/port to .env.example
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Fix Qdrant client to use query_points API (qdrant-client >= 1.10)
- Rename REDIS_DB to REDIS_BENCHMARK_DB for clarity
- Update Redis defaults to match stack allocation (benchmark=6, memory=1)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
pydantic-ai-slim doesn't include pydantic-settings as a transitive
dependency like the full pydantic-ai package did.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Automatically notify Watchtower to pull and deploy the new image
after a successful registry push.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add provenance: false to docker/build-push-action to fix
"received unexpected HTTP status: 200 OK" error when pushing
to Gitea container registry.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Switch from pydantic-ai to pydantic-ai-slim[openai]
- Removes unused provider SDKs (anthropic, boto3, cohere, google, groq, huggingface)
- Production packages: 53 (down from ~158)
- Production footprint: 178MB
- Add DEPENDENCY_SLIM.md with rollback instructions
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>