Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
014afc960c | ||
|
|
f398a8ad80 | ||
|
|
eabf0f4a11 | ||
|
|
7c5fca06c3 | ||
|
|
3696a40f97 | ||
|
|
1ab6c1b379 | ||
|
|
1e986f28b3 | ||
|
|
7150b4a2fa | ||
|
|
dac259af1d | ||
|
|
5b67f5b66c | ||
|
|
78066fab1b | ||
|
|
57fa6c13fc | ||
|
|
2fb2fab395 | ||
|
|
5bb4013821 | ||
|
|
6ab68d3971 | ||
|
|
31d09a6ed1 | ||
|
|
f8059771ce | ||
|
|
6b1c892bc6 | ||
|
|
84467c121a | ||
|
|
2290320e9c | ||
|
|
bf13f9f0de | ||
|
|
4f42bc047a | ||
|
|
738ff10b93 | ||
|
|
a90536314e | ||
|
|
19e32cfbd6 | ||
|
|
99569e786e | ||
|
|
2cf3252a19 | ||
|
|
cdd5a55613 |
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"env": {
|
||||
"PQL_VAULT": "/mnt/media/Projects/tatlock"
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(pql)",
|
||||
"Bash(pql *)",
|
||||
"Bash(/home/jpmschweitzer/.local/bin/pql:*)",
|
||||
"Bash(git status:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git branch:*)",
|
||||
"Bash(make test:*)",
|
||||
"Bash(make test-unit:*)",
|
||||
"Bash(make test-contracts:*)",
|
||||
"Bash(make lint:*)",
|
||||
"Bash(make typecheck:*)",
|
||||
"Bash(.venv/bin/python -m pytest:*)",
|
||||
"Bash(.venv/bin/pytest:*)",
|
||||
"Bash(pytest:*)",
|
||||
"Bash(ruff check:*)",
|
||||
"Bash(mypy:*)",
|
||||
"Bash(docker logs tatlock:*)",
|
||||
"Bash(curl -s http://localhost:8000/*)",
|
||||
"Bash(curl -s http://localhost:8777/*)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(/mnt/media/Projects/cladmin/ops/bin/toj)",
|
||||
"Bash(/mnt/media/Projects/cladmin/ops/bin/toj:*)",
|
||||
"Bash(chmod -R 777 *)",
|
||||
"Bash(chmod 777 *)",
|
||||
"Bash(dd if=*)",
|
||||
"Bash(find * -delete*)",
|
||||
"Bash(find * -exec*)",
|
||||
"Bash(git * add --all*)",
|
||||
"Bash(git * add -A*)",
|
||||
"Bash(git * add .)",
|
||||
"Bash(git * branch -D *)",
|
||||
"Bash(git * checkout -- *)",
|
||||
"Bash(git * clean -fd*)",
|
||||
"Bash(git * clean -fdx*)",
|
||||
"Bash(git * commit --no-verify*)",
|
||||
"Bash(git * merge --no-ff*)",
|
||||
"Bash(git * push --force*)",
|
||||
"Bash(git * push -f*)",
|
||||
"Bash(git * reset --hard*)",
|
||||
"Bash(git * restore .*)",
|
||||
"Bash(git add --all*)",
|
||||
"Bash(git add -A*)",
|
||||
"Bash(git add .)",
|
||||
"Bash(git branch -D *)",
|
||||
"Bash(git checkout -- *)",
|
||||
"Bash(git clean -fd*)",
|
||||
"Bash(git clean -fdx*)",
|
||||
"Bash(git commit --no-verify*)",
|
||||
"Bash(git merge --no-ff*)",
|
||||
"Bash(git push --force*)",
|
||||
"Bash(git push -f*)",
|
||||
"Bash(git reset --hard*)",
|
||||
"Bash(git restore .*)",
|
||||
"Bash(mkfs*)",
|
||||
"Bash(ollama rm *)",
|
||||
"Bash(redis-cli * FLUSHALL*)",
|
||||
"Bash(redis-cli * FLUSHDB*)",
|
||||
"Bash(rm -rf $HOME)",
|
||||
"Bash(rm -rf /)",
|
||||
"Bash(rm -rf ~)",
|
||||
"Bash(su *)",
|
||||
"Bash(sudo *)",
|
||||
"Bash(toj)",
|
||||
"Bash(toj:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
.pql/changelog/*.sql merge=union
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Trigger only. The checks live in the Makefile, where they can be read, run by
|
||||
# hand (`make pre-push`), and changed under review.
|
||||
#
|
||||
# This file is identical in every repo in this workspace, deliberately: the call
|
||||
# surface is the same everywhere even though what each gate runs is not, so
|
||||
# nobody has to read a repo to find out how to check it (D-27).
|
||||
#
|
||||
# Enable per clone with: git config core.hooksPath .githooks
|
||||
# Never bypass with --no-verify. Suppress a specific finding deliberately
|
||||
# instead, with a reason — see `make pre-push`.
|
||||
set -euo pipefail
|
||||
exec make -C "$(git rev-parse --show-toplevel)" pre-push
|
||||
+13
@@ -103,3 +103,16 @@ ollama_data/
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Claude Code user-specific settings
|
||||
.claude/settings.local.json
|
||||
.pql/*
|
||||
!.pql/changelog/
|
||||
|
||||
# pql shims planted by `pql init` into the dir core.hooksPath points at.
|
||||
# Per-clone: each embeds the absolute path of the pql binary that planted it.
|
||||
# Only .githooks/pre-push is shared.
|
||||
.githooks/pre-commit
|
||||
.githooks/post-merge
|
||||
.githooks/post-checkout
|
||||
.githooks/post-rewrite
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Changelog format marker, written by pql. Comments only: this file
|
||||
-- is never executed — Import descends into the per-table directories
|
||||
-- and does not read the changelog root.
|
||||
--
|
||||
-- A changelog carrying no marker is format 1, the shape that existed
|
||||
-- before formats were versioned. An older format is migrated forward
|
||||
-- by `pql plan upgrade` (and automatically from the post-merge hook);
|
||||
-- a newer one is refused rather than replayed under rules this binary
|
||||
-- does not know. See D-28 and docs/versions.md.
|
||||
-- pql:changelog_format: 2.0.0
|
||||
-- pql:written_by: 2.2.0
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1,34 @@
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ44Z6HSN0RQS0QAYNTPEM5G', 'description', NULL, '`make typecheck` reports 95 errors in 31 files (was 103). This is a dedicated programming pass, not lint tidying, and it is what currently blocks `make pre-push`.
|
||||
|
||||
MEASURED 2026-08-11 so the next session does not re-derive it:
|
||||
|
||||
29 no-untyped-def functions with no annotations — the bulk, and genuine per-function work
|
||||
18 no-any-return mostly downstream of the above
|
||||
12 assignment
|
||||
11 arg-type
|
||||
5 unused-ignore `# type: ignore` comments mypy says are no longer needed
|
||||
5 union-attr
|
||||
4 var-annotated
|
||||
3 override
|
||||
remainder: misc, dict-item, attr-defined, return-value, call-overload
|
||||
|
||||
By file: agents/tatlock.py 15, core/memory_service.py 11, responses/streaming.py 8, responses/service.py 7, core/context.py 6.
|
||||
|
||||
THE TWO SHARED ROOTS ARE ALREADY FIXED (dac259a), so what is left has no lever in it. For reference, they were: five conversation lists declared bare, where mypy infers the element type from the first append (a ModelRequest) and then rejects every ModelResponse; and an agent built as Agent(model, system_prompt=...) with no deps_type, inferred Agent[None, str], while every tool it registers takes RunContext[ToolCallTracker].
|
||||
|
||||
WORTH KNOWING BEFORE STARTING. Annotating partially made mypy count go UP before it went down — declaring `_agent: Agent | None` took agents/tatlock.py from 22 to 24, because resolving the bare Agent to Agent[None, str] surfaced four argument-type errors the Any had been hiding. Expect that shape: a rising count during this work usually means concealment ending, not damage.
|
||||
|
||||
The mypy config is strict — disallow_untyped_defs, disallow_incomplete_defs, warn_return_any, check_untyped_defs, strict_equality — so there is no partial-credit setting to lean on, and weakening it would be the wrong trade for a codebase this central.
|
||||
|
||||
TWO PRE-EXISTING TEST FACTS, both confirmed at HEAD and neither caused by the lint work:
|
||||
- test_tatlock_tool_call_logging_calculator is flaky: failed 2 of 5 full runs, on HEAD and on the lint branch, and fails in isolation at HEAD while passing in isolation after the lint pass. Order- or timing-dependent.
|
||||
- `pytest tests/` cannot collect at all: tests/e2e/test_orchestration_e2e.py uses an `e2e` marker that is not registered and the config is strict about markers. `make test` passes only because it ignores tests/e2e, tests/integration and tests/contracts.', NULL, '2026-08-11 18:26:58', '2026-08-11 18:26:58.523', '2026-08-11 18:26:58.523', NULL, 'c8c9b6a20cf18ca903fc8c720f70e73d', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ4JX9DZB3XAB95EP23YWXMC', 'description', NULL, '`.env` still carries an `ANTHROPIC_API_KEY`. That key was revoked and no longer exists on Anthropic''s side, so this is dead weight rather than an exposure — but it is dead weight that reads exactly like a live credential to anyone who finds it.
|
||||
|
||||
The cost is confusion, not risk. Someone debugging a Claude fallback will find a key present, assume it is configured, and look elsewhere for the failure. The absence of a key is a clear signal; a revoked key is a misleading one.
|
||||
|
||||
`.env` is gitignored here and has never been committed, so nothing needs rewriting — the value simply needs removing from the local file, and the line dropping or blanking in `.env.example` if it appears there too.
|
||||
|
||||
RELATED, and the reason this is filed separately: the workspace vault holds T-13, "Clear the revoked ANTHROPIC_API_KEY from the live Portainer stack". That ticket is scoped to the Portainer stack only. Whoever closes it will reasonably believe the key is gone once the stack is clean, and this copy will survive. The two want doing together even though they commit separately.
|
||||
|
||||
Context on the revocation, since it explains why nobody removed this at the time: the key was revoked on 2026-08-09 after being printed into a transcript by a redaction filter that matched on `KEY` appearing after the `=`. In `ANTHROPIC_API_KEY=...` it appears before, so the filter never fired. The response was rotation, and the leftover copies were not swept.', NULL, '2026-08-11 19:27:52', '2026-08-11 19:27:52.823', '2026-08-11 19:27:52.823', NULL, 'c7834e46268029b74c25a25a83177b64', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1,2 @@
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ44Z6HSN0RQS0QAYNTPEM5G', 'T-1', '2026-08-11 18:26:58.365', '2026-08-11 18:26:58.365', NULL, 'f1508986553f1ee59145a0d099131a68', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ4JX9DZB3XAB95EP23YWXMC', 'T-2', '2026-08-11 19:27:52.688', '2026-08-11 19:27:52.688', NULL, 'a5b18acb4b9a7e2da8e47b6ee603a5da', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1,36 @@
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ44Z6HSN0RQS0QAYNTPEM5G', 'task', NULL, 'Type the codebase: 95 mypy errors across 31 files', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-08-11 18:26:58.318', '2026-08-11 18:26:58.318', NULL, 'd881f36d77e58aaa2f94dc08c5b5be3e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ44Z6HSN0RQS0QAYNTPEM5G', 'task', NULL, 'Type the codebase: 95 mypy errors across 31 files', '`make typecheck` reports 95 errors in 31 files (was 103). This is a dedicated programming pass, not lint tidying, and it is what currently blocks `make pre-push`.
|
||||
|
||||
MEASURED 2026-08-11 so the next session does not re-derive it:
|
||||
|
||||
29 no-untyped-def functions with no annotations — the bulk, and genuine per-function work
|
||||
18 no-any-return mostly downstream of the above
|
||||
12 assignment
|
||||
11 arg-type
|
||||
5 unused-ignore `# type: ignore` comments mypy says are no longer needed
|
||||
5 union-attr
|
||||
4 var-annotated
|
||||
3 override
|
||||
remainder: misc, dict-item, attr-defined, return-value, call-overload
|
||||
|
||||
By file: agents/tatlock.py 15, core/memory_service.py 11, responses/streaming.py 8, responses/service.py 7, core/context.py 6.
|
||||
|
||||
THE TWO SHARED ROOTS ARE ALREADY FIXED (dac259a), so what is left has no lever in it. For reference, they were: five conversation lists declared bare, where mypy infers the element type from the first append (a ModelRequest) and then rejects every ModelResponse; and an agent built as Agent(model, system_prompt=...) with no deps_type, inferred Agent[None, str], while every tool it registers takes RunContext[ToolCallTracker].
|
||||
|
||||
WORTH KNOWING BEFORE STARTING. Annotating partially made mypy count go UP before it went down — declaring `_agent: Agent | None` took agents/tatlock.py from 22 to 24, because resolving the bare Agent to Agent[None, str] surfaced four argument-type errors the Any had been hiding. Expect that shape: a rising count during this work usually means concealment ending, not damage.
|
||||
|
||||
The mypy config is strict — disallow_untyped_defs, disallow_incomplete_defs, warn_return_any, check_untyped_defs, strict_equality — so there is no partial-credit setting to lean on, and weakening it would be the wrong trade for a codebase this central.
|
||||
|
||||
TWO PRE-EXISTING TEST FACTS, both confirmed at HEAD and neither caused by the lint work:
|
||||
- test_tatlock_tool_call_logging_calculator is flaky: failed 2 of 5 full runs, on HEAD and on the lint branch, and fails in isolation at HEAD while passing in isolation after the lint pass. Order- or timing-dependent.
|
||||
- `pytest tests/` cannot collect at all: tests/e2e/test_orchestration_e2e.py uses an `e2e` marker that is not registered and the config is strict about markers. `make test` passes only because it ignores tests/e2e, tests/integration and tests/contracts.', 'backlog', 'high', NULL, NULL, NULL, '2026-08-11 18:26:58.318', '2026-08-11 18:26:58.523', NULL, 'fc047dd080d976c4ca0c551cac8fec93', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ4JX9DZB3XAB95EP23YWXMC', 'bug', NULL, 'A revoked ANTHROPIC_API_KEY is still sitting in .env', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-08-11 19:27:52.687', '2026-08-11 19:27:52.687', NULL, '3b00a4b729819e20a6425f971e6cb9da', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ4JX9DZB3XAB95EP23YWXMC', 'bug', NULL, 'A revoked ANTHROPIC_API_KEY is still sitting in .env', '`.env` still carries an `ANTHROPIC_API_KEY`. That key was revoked and no longer exists on Anthropic''s side, so this is dead weight rather than an exposure — but it is dead weight that reads exactly like a live credential to anyone who finds it.
|
||||
|
||||
The cost is confusion, not risk. Someone debugging a Claude fallback will find a key present, assume it is configured, and look elsewhere for the failure. The absence of a key is a clear signal; a revoked key is a misleading one.
|
||||
|
||||
`.env` is gitignored here and has never been committed, so nothing needs rewriting — the value simply needs removing from the local file, and the line dropping or blanking in `.env.example` if it appears there too.
|
||||
|
||||
RELATED, and the reason this is filed separately: the workspace vault holds T-13, "Clear the revoked ANTHROPIC_API_KEY from the live Portainer stack". That ticket is scoped to the Portainer stack only. Whoever closes it will reasonably believe the key is gone once the stack is clean, and this copy will survive. The two want doing together even though they commit separately.
|
||||
|
||||
Context on the revocation, since it explains why nobody removed this at the time: the key was revoked on 2026-08-09 after being printed into a transcript by a redaction filter that matched on `KEY` appearing after the `=`. In `ANTHROPIC_API_KEY=...` it appears before, so the filter never fired. The response was rotation, and the leftover copies were not swept.', 'backlog', 'medium', NULL, NULL, NULL, '2026-08-11 19:27:52.687', '2026-08-11 19:27:52.823', NULL, 'a9c2ca965b3d40a924720c7761c5338d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
@@ -1,105 +0,0 @@
|
||||
# LLM Agent Instructions
|
||||
|
||||
This document contains instructions and documentation references for AI assistants working with this codebase.
|
||||
|
||||
> **📖 Important**: Before working on this project, read [docs/philosophy.md](docs/philosophy.md) to understand the system vision, architectural patterns, and design goals. All development should work towards realizing those patterns.
|
||||
# AGENTS.md
|
||||
|
||||
> **Start every session by reading this file.**
|
||||
> This file outlines the operational protocols, coding standards, and architectural decisions for this FastAPI project.
|
||||
|
||||
## 1. Agent Operational Protocols
|
||||
|
||||
### 🧠 Work Patterns (Plan-Act-Reflect)
|
||||
* **Plan:** Before writing code, briefly outline your plan. Identify which files you will touch and what the side effects might be.
|
||||
* **Act:** Execute the changes in small, atomic steps.
|
||||
* **Reflect:** After coding, verify your work. Did you break existing tests? Did you add new tests?
|
||||
|
||||
### 🧪 Local Development Setup
|
||||
* **Always test locally first** before committing and deploying. The build-deploy loop is slow.
|
||||
* **Start the local server** with `./wakeup.sh` - logs are written to `logs/server.log` for easy tailing
|
||||
* **Auto-reload**: The wakeup script runs uvicorn in reload mode - code changes are picked up automatically without restart (except for requirements.txt changes)
|
||||
* **Test REST endpoints** against `http://localhost:8777` using curl or similar tools
|
||||
* **Only deploy** when a phase or feature is complete and tested locally
|
||||
* **Environment**: Copy `.env.example` to `.env` and configure for your local setup (Ollama, Redis, Qdrant hosts)
|
||||
* **Running tests**: Always use the venv explicitly to avoid environment mismatches:
|
||||
```bash
|
||||
.venv/bin/python -m pytest tests/ # All tests
|
||||
.venv/bin/python -m pytest tests/core/ -v # Core tests only
|
||||
```
|
||||
|
||||
### 🌐 Internal Service Access
|
||||
* **git.schweitz.net**: Access via `http://localhost:3002` (direct Gitea) to bypass Authentik SSO
|
||||
* Example: `curl http://localhost:3002/jpmschweitzer/library-desk/raw/branch/main/README.md`
|
||||
* Public repos are readable without authentication
|
||||
* Related repos: `library-desk`, `scheduler`, `core-api`, `portainer-core`
|
||||
|
||||
### 🐳 Deployment & Infrastructure
|
||||
* **Full stack documentation**: Available in the `portainer-core` repo
|
||||
* Access: `curl http://localhost:3002/jpmschweitzer/portainer-core/raw/branch/main/CONTAINERS.md`
|
||||
* Contains: All service ports, URLs, Redis DB allocations, external domains
|
||||
* **Tatlock deployment**:
|
||||
* LAN: `http://192.168.86.149:8000`
|
||||
* External: `tatlock.schweitz.net` (behind Authentik SSO)
|
||||
* Redis DBs: 1 (memory), 6 (benchmarks)
|
||||
* **Health check**: `curl http://192.168.86.149:8000/health`
|
||||
|
||||
### 🛡️ Git Discipline
|
||||
* **NEVER commit to `main` or `master` directly.** Always create a feature branch: `feature/your-feature-name` or `fix/issue-description`.
|
||||
* **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format.
|
||||
* `feat: add user login endpoint`
|
||||
* `fix: resolve database connection timeout`
|
||||
* `refactor: split monolith dependency file`
|
||||
* **Atomic Commits:** Keep commits small. One logical change = one commit.
|
||||
|
||||
### 📝 Changelog Maintenance
|
||||
* **Update `CHANGELOG.md`** with every user-facing change.
|
||||
* Format: `## [Unreleased] - YYYY-MM-DD` followed by `### Added`, `### Changed`, or `### Fixed`.
|
||||
|
||||
### 🚀 Release Flow
|
||||
When changes are ready for deployment:
|
||||
|
||||
1. **Ask user if deploy cycle is desired**
|
||||
|
||||
2. **Update version** in `pyproject.toml`:
|
||||
- Bug fixes: bump patch version (1.8.3 → 1.8.4)
|
||||
- New features: bump minor version (1.8.4 → 1.9.0)
|
||||
|
||||
3. **Update CHANGELOG.md**:
|
||||
- Move items from `[Unreleased]` to new version section
|
||||
- Add release date: `## [1.8.4] - 2025-12-16`
|
||||
|
||||
4. **Commit and tag**:
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: description of changes"
|
||||
git tag v1.8.4
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
5. **CI/CD triggers automatically**:
|
||||
- Gitea CI builds Docker image on new tag
|
||||
- Watchtower pulls and deploys to production
|
||||
- Verify deployment: `curl http://192.168.86.149:8000/health`
|
||||
|
||||
---
|
||||
|
||||
## 2. FastAPI Architecture & Best Practices
|
||||
*Reference: [FastAPI Best Practices](https://github.com/zhanymkanov/fastapi-best-practices)*
|
||||
|
||||
### 📂 Project Structure (Directory-based, NOT File-type based)
|
||||
Do **not** group files by type (e.g., one huge `routers` folder). Group by **domain/module** inside a `src/` directory.
|
||||
|
||||
**Correct Structure:**
|
||||
```text
|
||||
src/
|
||||
├── auth/
|
||||
│ ├── router.py # Endpoints
|
||||
│ ├── schemas.py # Pydantic models
|
||||
│ ├── service.py # Business logic (CRUD, etc.)
|
||||
│ ├── dependencies.py# Module-specific dependencies
|
||||
│ └── config.py # Module-specific settings
|
||||
├── posts/
|
||||
│ ├── router.py
|
||||
│ └── ...
|
||||
└── main.py # App entry point
|
||||
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- `make setup` now ends with a `pytest --collect-only` pass so a broken environment
|
||||
(missing or mismatched dependency) fails the target itself instead of exiting 0
|
||||
and surfacing later as a confusing test failure (T-47)
|
||||
|
||||
## [2.4.3] - 2026-08-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- Steward routing no longer triggers on words inside its own explanation. Capability
|
||||
extraction reads the declared `DELEGATE:` line instead of substring-matching
|
||||
capability domains across the whole response, where ordinary English routed
|
||||
requests — "description" contains the housekeeper domain "script", "acknowledge"
|
||||
contains "knowledge" and "know". A spurious capability meant a real agent call,
|
||||
including web searches, on queries that needed none.
|
||||
|
||||
## [2.4.2] - 2026-07-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- Container crash-loop on fresh builds: cap `opentelemetry-api` below 1.44,
|
||||
which removed the private `_events` module that pydantic-ai 1.27 imports
|
||||
|
||||
## [2.4.1] - 2026-07-19
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -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 **workspace 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 workspace D-11
|
||||
```
|
||||
|
||||
Dependencies are in `pyproject.toml` (`[project.dependencies]` and `[project.optional-dependencies.dev]`).
|
||||
**workspace 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 (2026-07-14, driver 570, gemma4:e2b at ~100 tok/s): Steward analysis ~6s warm, full Steward → orchestrate → synthesize flow 11–25s, librarian-routed queries ~20-25s. The old "~35s steward / ~2 min flow" figures were measured during the CPU-only era (driver mismatch, 13 tok/s) — do not plan against them. Cold start after 2h idle adds ~8s (`OLLAMA_KEEP_ALIVE=2h`). `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 (workspace 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 (workspace 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`.
|
||||
|
||||
@@ -18,6 +18,17 @@ setup: ## Create venv and install all dependencies
|
||||
python3 -m venv $(VENV)
|
||||
$(PIP) install --upgrade pip
|
||||
$(PIP) install -e ".[dev]"
|
||||
# Exit 0 from pip install is not evidence the environment works (D-24) - the
|
||||
# 2026-08-09 core-api incident was exactly this: a venv that "installed fine"
|
||||
# but was missing a declared dependency, surfacing as 11 collection errors
|
||||
# that read like broken imports rather than an environment problem. Collection
|
||||
# is the right cheap check here for that same reason: it imports every test
|
||||
# module (and everything they import) without running the suite, so a missing
|
||||
# or mismatched dependency fails setup itself instead of showing up later as a
|
||||
# mysterious test failure. Scoped like `make test` (excludes e2e/integration/
|
||||
# contracts, which need external services) and --no-cov since coverage
|
||||
# instrumentation is irrelevant to "does this collect".
|
||||
$(PYTEST) --collect-only -q --ignore=tests/e2e --ignore=tests/integration --ignore=tests/contracts --no-cov
|
||||
|
||||
run: ## Start the development server on port 8777
|
||||
@mkdir -p build/logs
|
||||
@@ -49,3 +60,30 @@ typecheck: ## Run mypy type checking
|
||||
clean: ## Remove build artifacts, caches, and coverage reports
|
||||
rm -rf .cache build
|
||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# git hands a hook a non-login shell, which never sees ~/.local/bin — where
|
||||
# gitleaks lands. Without this the scan reports "not installed" on every push,
|
||||
# which is a check that fails open (D-24).
|
||||
export PATH := $(HOME)/.local/bin:/usr/local/bin:$(PATH)
|
||||
|
||||
.PHONY: secrets
|
||||
secrets: ## Scan the commits about to be pushed for credentials
|
||||
@ci/secrets.sh
|
||||
|
||||
# The call surface is identical in every repo; what it runs is not.
|
||||
#
|
||||
# `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 fail today, and are left wired anyway. The state was measured
|
||||
# once and written down in T-56 rather than being worked around here — a gate
|
||||
# quietly narrowed to what already passes is a gate that reports success for
|
||||
# doing nothing, which is the failure this workspace keeps rediscovering.
|
||||
.PHONY: pre-push
|
||||
pre-push: secrets lint ## Everything the pre-push hook runs
|
||||
@echo " -- not gated here yet: typecheck (T-1), test (T-56)"
|
||||
@echo " typecheck reports 95 errors in 31 files and has never passed, so"
|
||||
@echo " gating on it blocked every push to this repo — including the commit"
|
||||
@echo " that added the gate. Run 'make typecheck' before pushing anything"
|
||||
@echo " that touches types; T-1 is the pass that earns this line's removal."
|
||||
|
||||
@@ -406,7 +406,7 @@ tatlock/
|
||||
|
||||
## Development
|
||||
|
||||
For LLM agent development guidelines and architectural decisions, see [AGENTS.md](AGENTS.md).
|
||||
For LLM agent development guidelines and architectural decisions, see [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -420,7 +420,7 @@ For LLM agent development guidelines and architectural decisions, see [AGENTS.md
|
||||
|
||||
- **System Philosophy**: [docs/philosophy.md](docs/philosophy.md) - Vision, goals, and architectural patterns
|
||||
- **Development Roadmap**: [docs/roadmap.md](docs/roadmap.md) - Open work and planned phases
|
||||
- **Developer Guidelines**: [AGENTS.md](AGENTS.md) - LLM agent development patterns
|
||||
- **Developer Guidelines**: [CLAUDE.md](CLAUDE.md) - LLM agent development patterns
|
||||
- **Version History**: [CHANGELOG.md](CHANGELOG.md) - Changes and releases
|
||||
|
||||
### External References
|
||||
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Secret scan over the commits about to be pushed.
|
||||
#
|
||||
# Lives here rather than inside .githooks/pre-push so it can be read, run by
|
||||
# hand (`make secrets`), and changed under review. A hook is a trigger; it is
|
||||
# not a home for logic. Identical in every repo in this workspace (D-27).
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
# A non-login shell — which is what git gives a hook — skips /etc/profile.d
|
||||
# and never sees ~/.local/bin, where the gitleaks release tarball lands.
|
||||
# Without this the scan reports "not installed" on every push.
|
||||
[ -d "$HOME/.local/bin" ] && PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
if ! command -v gitleaks >/dev/null 2>&1; then
|
||||
echo "FAIL secrets — gitleaks not installed, so this check would be a no-op pretending to pass." >&2
|
||||
echo " https://github.com/gitleaks/gitleaks/releases → ~/.local/bin/gitleaks" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Scan the outgoing range, not full history. History here carries findings
|
||||
# that are settled — test fixtures and vendored third-party code — and a gate
|
||||
# that fails on something unfixable gets bypassed within a week. What matters
|
||||
# is what is about to leave this machine.
|
||||
if upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null); then
|
||||
range="$upstream..HEAD"
|
||||
elif git rev-parse --verify --quiet origin/main >/dev/null; then
|
||||
range="origin/main..HEAD"
|
||||
else
|
||||
range=""
|
||||
fi
|
||||
|
||||
if [ -z "$range" ]; then
|
||||
gitleaks dir . --redact --no-banner --exit-code 1 || {
|
||||
echo "FAIL secrets — gitleaks found a credential in the working tree." >&2; exit 1; }
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[ -n "$(git log --oneline "$range" 2>/dev/null)" ] || exit 0
|
||||
|
||||
gitleaks git . --log-opts="$range" --redact --no-banner --exit-code 1 >/dev/null 2>&1 || {
|
||||
echo "FAIL secrets — gitleaks found a credential in the commits being pushed." >&2
|
||||
echo " inspect (values redacted): gitleaks git . --log-opts=\"$range\" --redact" >&2
|
||||
echo " then remove and rotate it, or suppress deliberately:" >&2
|
||||
echo " inline '# gitleaks:allow <reason>'" >&2
|
||||
echo " or add the fingerprint to .gitleaksignore WITH a reason" >&2
|
||||
exit 1
|
||||
}
|
||||
echo " ok secrets"
|
||||
@@ -28,7 +28,7 @@ src/mcp/
|
||||
|
||||
```yaml
|
||||
tatlock-mcp:
|
||||
image: git.schweitz.internal/jpmschweitzer/tatlock:latest
|
||||
image: git.schweitz.net/jpmschweitzer/tatlock:latest
|
||||
command: ["python", "-m", "src.mcp.server"]
|
||||
ports:
|
||||
- "8002:8002"
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ This document establishes the foundational philosophy and architectural patterns
|
||||
- When new architectural insights require rethinking core principles
|
||||
|
||||
**When NOT to modify this document**:
|
||||
- During implementation of these patterns (use README.md, AGENTS.md, or code comments for technical details)
|
||||
- During implementation of these patterns (use README.md, CLAUDE.md, or code comments for technical details)
|
||||
- For adding new household members or capabilities within the existing pattern
|
||||
- For tactical decisions about specific technologies or tools
|
||||
|
||||
@@ -270,7 +270,7 @@ The user never directly interacts with the Steward or individual expert agents
|
||||
|
||||
**Related Documents**:
|
||||
- **README.md**: User-facing documentation and usage guide
|
||||
- **AGENTS.md**: LLM agent development guidelines and technical patterns
|
||||
- **CLAUDE.md**: LLM agent development guidelines and technical patterns
|
||||
- **CHANGELOG.md**: Version history and implemented features
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# Steward Routing & Thinking — Findings
|
||||
|
||||
**Outcome: no change shipped.** The Steward stays on `gemma4:e2b` with model
|
||||
thinking left at its default (on). Every alternative was measured and every one
|
||||
loses. This document exists so the experiment is not repeated on the same
|
||||
premise.
|
||||
|
||||
Run 2026-08-08 with `scripts/benchmark_routing.py` and
|
||||
`scripts/fixtures/routing_fixtures.py` (40 labelled queries, one repeat per
|
||||
cell, temperature 0.3 as production sends).
|
||||
|
||||
---
|
||||
|
||||
## The premise was wrong
|
||||
|
||||
The experiment was designed around an observation that the Steward pays ~300
|
||||
tokens per turn for reasoning that is generated and thrown away: it calls
|
||||
`/api/generate`, gemma4 reasons by default, and **no `thinking` field comes back
|
||||
in the response**. Disabling thinking therefore looked close to free.
|
||||
|
||||
It is not. The reasoning is not discarded — it is emitted inline in `response`,
|
||||
and it is what produces a correct `DELEGATE:` line. Those tokens are the work,
|
||||
not waste. Suppressing them costs 12.5 points of routing accuracy.
|
||||
|
||||
## Results
|
||||
|
||||
| config | exact | under | over | tokens | latency | resident | predicted | co-resident with nomic |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| **e2b, thinking** *(production)* | **97.5%** | 2.5% | 0% | 361 | 5179 ms | 1778 MB | 7.8 GiB | yes |
|
||||
| e2b, `think: false` | 85.0% | 12.5% | 5.0% | 48 | 1435 ms | 1778 MB | 7.8 GiB | yes |
|
||||
| e4b, thinking | 100% | 0% | 0% | 192 | 4726 ms | 3089 MB | 10.6 GiB | **no** |
|
||||
| e4b, `think: false` | 97.5% | 2.5% | 0% | 52 | 2269 ms | 3089 MB | 10.6 GiB | **no** |
|
||||
|
||||
`think: true` was also measured and landed within one fixture of the default on
|
||||
both models, so production's implicit thinking is the same thing as asking for
|
||||
it explicitly. Format compliance was 100% in every cell — a `DELEGATE:` line is
|
||||
always emitted.
|
||||
|
||||
With 40 fixtures and one repeat, each result is worth 2.5 points, so the
|
||||
97.5-vs-100 gaps are single fixtures and inside the noise. The latency and token
|
||||
medians (40 calls each) and the e2b `think: false` degradation (6 failures with a
|
||||
consistent mechanism) are the parts worth trusting.
|
||||
|
||||
## Why each alternative loses
|
||||
|
||||
**`think: false` on e2b** — 85% exact, and the failures are not random. All three
|
||||
multi-capability fixtures under-route, each missing a second capability. Without
|
||||
reasoning the model names one capability and stops decomposing. It is not
|
||||
degraded across the board; it specifically stops handling compound requests,
|
||||
which is where a user would most notice the Butler quietly doing half the job.
|
||||
|
||||
**e4b, either setting** — disqualified by memory, not by quality. Ollama predicts
|
||||
**10.6 GiB** for it at 16k context. Maximum available on this card is ~7.9 GiB
|
||||
(10.4 free − 2.0 GPU overhead − 0.46 minimum), so e4b *always* exceeds the budget
|
||||
and evicts every co-resident before loading. Observed directly: loading it threw
|
||||
out both `gemma4:e2b` and `nomic-embed-text`. Losing nomic means Tatlock memory
|
||||
and library-desk thrash on every embedding call. Note this is not caused by the
|
||||
2 GiB reservation — without it, available would be ~9.7 GiB, still under 10.6.
|
||||
|
||||
**Lower `OLLAMA_CONTEXT_LENGTH`** — the obvious way to free headroom, and it does
|
||||
not work. Dropping 16384 → 2048, an 8× reduction, moved the prediction only from
|
||||
7.8 to 6.7 GiB. The prediction is dominated by weights and batch size, not KV
|
||||
cache. It would also truncate the Librarian's retrieved passages and webber's
|
||||
code context for a 14% saving that funds nothing.
|
||||
|
||||
**Per-request `num_ctx`** — worse. A single request with a different `num_ctx`
|
||||
reloads the shared runner, which **drops the `keep_alive: -1` pin** (expiry fell
|
||||
from year-2318 to a 2-hour default) and evicts nomic. Three services share this
|
||||
Ollama, so mixed context sizes are a thrash generator, and it fails silently.
|
||||
|
||||
**`OLLAMA_NUM_PARALLEL > 1`** — never viable here. e2b already predicts 7.8 GiB
|
||||
against ~7.9 available, so there is no room for a second slot at any context
|
||||
length. It is also set to 1 deliberately, to avoid batch overflow panics.
|
||||
|
||||
## What the two axes actually control
|
||||
|
||||
They do not interact, which is the useful part:
|
||||
|
||||
- **Model choice** governs VRAM and co-residency. e2b 1778 MB, e4b 3089 MB.
|
||||
- **Think setting** governs tokens, latency and routing quality — and costs
|
||||
**nothing** in VRAM. Verified: e2b is resident at 1778 MB with `think` unset,
|
||||
true and false alike, because the KV cache is allocated for the full context at
|
||||
load time and `think` is a per-request generation parameter.
|
||||
|
||||
So the only real question is whether 313 tokens and 3.7 seconds are worth 12.5
|
||||
points of compound-query routing. On a turn that is already three sequential
|
||||
Ollama calls, they are.
|
||||
|
||||
## Prerequisite: the extraction fix
|
||||
|
||||
These numbers are only meaningful because `_extract_capabilities` was fixed first
|
||||
(commit `a905363`). It previously substring-matched capability *domains* across
|
||||
the Steward's entire response, so ordinary English in the `REASON:` line selected
|
||||
agents — "description" contains the housekeeper domain "script", "acknowledge"
|
||||
contains "knowledge" and "know".
|
||||
|
||||
That made **prose length a routing input**. Benchmarking against it would have
|
||||
shown `think: false` improving routing purely because shorter output produces
|
||||
fewer accidental substring hits — a thinking policy derived from a parsing
|
||||
artefact. The `adversarial` fixture group is regression coverage for exactly this.
|
||||
|
||||
## If this is revisited
|
||||
|
||||
The constraint is the single 11 GB card, not the model. A second inference host
|
||||
(*forge*) removes it entirely, and e4b's 100% routing becomes reachable without
|
||||
evicting anything. Re-run then; on this card the answer is settled.
|
||||
|
||||
`scripts/benchmark_routing.py` takes `--models`, `--think` and `--repeats`, and
|
||||
restores GPU residency on exit — including on SIGTERM, which the first version
|
||||
did not.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Decisions, Questions, Rejected
|
||||
|
||||
This directory holds structured planning records that pql parses
|
||||
into pql.db. Each record is a `### [DQR]-N: Title` heading inside
|
||||
a markdown file. Files live in three per-type subdirectories:
|
||||
|
||||
- `decisions/<domain>.md` — confirmed design decisions
|
||||
- `questions/<domain>.md` — open questions that may resolve into
|
||||
decisions or rejected proposals
|
||||
- `rejected/<domain>.md` — rejected proposals (kept for the audit
|
||||
trail)
|
||||
|
||||
The parser infers domain from the filename stem and record type
|
||||
from the parent subdirectory.
|
||||
|
||||
D-records that propose implementation work link to `initiative`-type
|
||||
tickets via `decision_ref`. Run `pql decisions show <id>
|
||||
--with-tickets` to inspect implementation status.
|
||||
|
||||
## Recommended domains
|
||||
|
||||
Start with this canonical set; create files as records land in
|
||||
each domain:
|
||||
|
||||
- **architecture** — structural commitments (storage, layering,
|
||||
languages, libraries)
|
||||
- **process** — team workflow (commits, branches, releases, reviews)
|
||||
- **design** — user-facing surface (UX, UI, public APIs)
|
||||
- **coding-conventions** — team-internal code shape (style, lint,
|
||||
file layout)
|
||||
- **testing** — quality strategy (coverage, layers, gates)
|
||||
|
||||
You might also want, project-permitting:
|
||||
|
||||
- `accessibility` — if you ship user-facing software
|
||||
- `security` — if you handle user data or network surfaces
|
||||
- `licensing` — if you release open-source or commercial
|
||||
- `documentation` — if user-docs are non-trivial
|
||||
- `deployment` — if shipping is non-trivial
|
||||
- `performance` — if you have perf budgets / SLOs
|
||||
|
||||
<!-- pql:records (auto-generated; do not edit manually) -->
|
||||
|
||||
## Decisions
|
||||
|
||||
- _(none)_
|
||||
|
||||
## Open questions
|
||||
|
||||
- _(none)_
|
||||
|
||||
## Rejected
|
||||
|
||||
- _(none)_
|
||||
+4
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tatlock"
|
||||
version = "2.4.1"
|
||||
version = "2.4.3"
|
||||
description = "OpenAI-compatible API with Ollama backend"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
@@ -13,6 +13,9 @@ dependencies = [
|
||||
"pydantic>=2.11,<2.13",
|
||||
"pydantic-settings>=2.12,<2.13",
|
||||
"pydantic-ai-slim[openai,anthropic]>=1.27,<1.28",
|
||||
# pydantic-ai 1.27 imports the private opentelemetry._events module,
|
||||
# removed in opentelemetry-api 1.44 — cap until pydantic-ai is bumped
|
||||
"opentelemetry-api>=1.30,<1.44",
|
||||
"anthropic>=0.77,<1.0",
|
||||
"httpx>=0.28,<0.29",
|
||||
"sse-starlette>=3.0,<3.1",
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
Benchmark Steward routing quality against model and thinking settings.
|
||||
|
||||
Talks to Ollama directly. No Tatlock server, no agents, no tools, nothing is
|
||||
executed — the mutating fixtures ("turn on the lights", "update the wiki") only
|
||||
ever produce a routing decision. That makes this cheap and repeatable, and it
|
||||
isolates the question: does the Steward still pick the right capabilities when
|
||||
the model reasons less?
|
||||
|
||||
The request body is byte-identical to StewardAgent._call_ollama, plus the
|
||||
`think` flag under test, so a cell labelled `unset` is exactly what production
|
||||
sends today.
|
||||
|
||||
Three thinking settings, because "on vs off" hides the interesting case:
|
||||
|
||||
unset what production sends now. gemma4 reasons by default, and the
|
||||
response carries no `thinking` field, so those tokens are generated
|
||||
and discarded.
|
||||
true reasoning requested explicitly and returned in `thinking`.
|
||||
false reasoning suppressed.
|
||||
|
||||
Scoring is deliberately asymmetric. A missing capability under-routes and the
|
||||
Butler answers without a tool it needed; a spurious one over-routes, and that is
|
||||
a real agent call — a stray librarian is a multi-second web search on a query
|
||||
that asked for arithmetic. Over-routing is the predicted failure when thinking
|
||||
is off, so `forbid` violations are reported separately rather than folded into
|
||||
one accuracy number.
|
||||
|
||||
Usage:
|
||||
.venv/bin/python scripts/benchmark_routing.py
|
||||
.venv/bin/python scripts/benchmark_routing.py --models gemma4:e2b
|
||||
.venv/bin/python scripts/benchmark_routing.py --think false --repeats 3
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.fixtures.routing_fixtures import FIXTURES # noqa: E402
|
||||
from scripts.ollama_residency import ( # noqa: E402
|
||||
install_sigterm_handler,
|
||||
residency_guard,
|
||||
)
|
||||
from src.agents.steward.agent import build_steward_prompt # noqa: E402
|
||||
from src.agents.steward.service import _DELEGATE_LINE_RE, _extract_capabilities # noqa: E402
|
||||
from src.core.startup import register_household_members # noqa: E402
|
||||
|
||||
OLLAMA_URL = "http://localhost:11434"
|
||||
DEFAULT_MODELS = ["gemma4:e2b", "gemma4:e4b"]
|
||||
DEFAULT_THINK = ["unset", "true", "false"]
|
||||
RESULTS_DIR = PROJECT_ROOT / "logs"
|
||||
|
||||
|
||||
def build_body(model: str, prompt: str, think: str) -> dict[str, Any]:
|
||||
"""Mirror StewardAgent._call_ollama exactly, then add the flag under test."""
|
||||
body: dict[str, Any] = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": 0.3, # Lower = more consistent
|
||||
"top_p": 0.9,
|
||||
},
|
||||
}
|
||||
if think != "unset":
|
||||
body["think"] = think == "true"
|
||||
return body
|
||||
|
||||
|
||||
def call(client: httpx.Client, body: dict[str, Any]) -> dict[str, Any] | None:
|
||||
try:
|
||||
response = client.post(f"{OLLAMA_URL}/api/generate", json=body)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as exc: # noqa: BLE001 - a failed cell must not abort the run
|
||||
print(f" ! {exc}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def score(fixture: dict, found: list[str]) -> dict[str, Any]:
|
||||
expected = set(fixture["expect"])
|
||||
forbidden = set(fixture["forbid"])
|
||||
got = set(found)
|
||||
missing = sorted(expected - got)
|
||||
spurious = sorted(got & forbidden)
|
||||
return {
|
||||
"found": found,
|
||||
"missing": missing,
|
||||
"spurious": spurious,
|
||||
# Exact only when everything expected arrived and nothing forbidden did.
|
||||
"exact": not missing and not spurious,
|
||||
"under_routed": bool(missing),
|
||||
"over_routed": bool(spurious),
|
||||
}
|
||||
|
||||
|
||||
def run_cell(client: httpx.Client, model: str, think: str, repeats: int) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for fixture in FIXTURES:
|
||||
prompt = build_steward_prompt(fixture["query"], [])
|
||||
body = build_body(model, prompt, think)
|
||||
for rep in range(repeats):
|
||||
started = time.perf_counter()
|
||||
data = call(client, body)
|
||||
elapsed_ms = (time.perf_counter() - started) * 1000
|
||||
if data is None:
|
||||
rows.append({
|
||||
"id": fixture["id"], "group": fixture["group"], "rep": rep,
|
||||
"error": True, "exact": False, "under_routed": False, "over_routed": False,
|
||||
})
|
||||
continue
|
||||
|
||||
text = data.get("response", "") or ""
|
||||
found = _extract_capabilities(text)
|
||||
rows.append({
|
||||
"id": fixture["id"],
|
||||
"group": fixture["group"],
|
||||
"rep": rep,
|
||||
"error": False,
|
||||
"latency_ms": round(elapsed_ms, 1),
|
||||
"eval_tokens": data.get("eval_count"),
|
||||
"prompt_tokens": data.get("prompt_eval_count"),
|
||||
# Did the model obey the documented output shape at all?
|
||||
"has_delegate_line": bool(_DELEGATE_LINE_RE.search(text)),
|
||||
# Whether reasoning came back, as opposed to being generated and dropped.
|
||||
"thinking_returned": bool(data.get("thinking")),
|
||||
"response_chars": len(text),
|
||||
**score(fixture, found),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def summarise(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
ok = [r for r in rows if not r["error"]]
|
||||
if not ok:
|
||||
return {"n": 0, "errors": len(rows)}
|
||||
latencies = [r["latency_ms"] for r in ok]
|
||||
tokens = [r["eval_tokens"] for r in ok if r["eval_tokens"] is not None]
|
||||
return {
|
||||
"n": len(ok),
|
||||
"errors": len(rows) - len(ok),
|
||||
"exact_pct": round(100 * sum(r["exact"] for r in ok) / len(ok), 1),
|
||||
"under_routed_pct": round(100 * sum(r["under_routed"] for r in ok) / len(ok), 1),
|
||||
"over_routed_pct": round(100 * sum(r["over_routed"] for r in ok) / len(ok), 1),
|
||||
"format_ok_pct": round(100 * sum(r["has_delegate_line"] for r in ok) / len(ok), 1),
|
||||
"thinking_returned_pct": round(100 * sum(r["thinking_returned"] for r in ok) / len(ok), 1),
|
||||
"latency_ms_median": round(statistics.median(latencies), 1),
|
||||
"latency_ms_mean": round(statistics.fmean(latencies), 1),
|
||||
"eval_tokens_median": round(statistics.median(tokens), 1) if tokens else None,
|
||||
"eval_tokens_total": sum(tokens) if tokens else None,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
install_sigterm_handler()
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--models", default=",".join(DEFAULT_MODELS))
|
||||
parser.add_argument("--think", default=",".join(DEFAULT_THINK),
|
||||
help="comma-separated subset of unset,true,false")
|
||||
parser.add_argument("--repeats", type=int, default=1)
|
||||
parser.add_argument("--timeout", type=float, default=180.0)
|
||||
args = parser.parse_args()
|
||||
|
||||
models = [m.strip() for m in args.models.split(",") if m.strip()]
|
||||
think_modes = [t.strip() for t in args.think.split(",") if t.strip()]
|
||||
|
||||
# build_steward_prompt reads the registry, and the registry is populated at
|
||||
# application startup. Without this the prompt lists no capabilities and every
|
||||
# cell scores zero for reasons that have nothing to do with the model.
|
||||
register_household_members()
|
||||
|
||||
print(f"{len(FIXTURES)} fixtures x {len(models)} models x {len(think_modes)} think "
|
||||
f"x {args.repeats} repeats = {len(FIXTURES) * len(models) * len(think_modes) * args.repeats} calls\n")
|
||||
|
||||
cells: dict[str, Any] = {}
|
||||
# The guard restores production's pinned models however this exits — a
|
||||
# finished run, a failed cell, Ctrl-C or SIGTERM.
|
||||
with residency_guard(models_used=models), httpx.Client(timeout=args.timeout) as client:
|
||||
for model in models:
|
||||
# Absorb the cold load (~36s) outside the measurements.
|
||||
print(f"warming {model} ...", flush=True)
|
||||
call(client, build_body(model, "hi", "false"))
|
||||
for think in think_modes:
|
||||
key = f"{model}|think={think}"
|
||||
print(f" {key} ...", end=" ", flush=True)
|
||||
started = time.perf_counter()
|
||||
rows = run_cell(client, model, think, args.repeats)
|
||||
summary = summarise(rows)
|
||||
cells[key] = {"summary": summary, "rows": rows}
|
||||
print(f"exact={summary.get('exact_pct')}% "
|
||||
f"over={summary.get('over_routed_pct')}% "
|
||||
f"median={summary.get('latency_ms_median')}ms "
|
||||
f"({time.perf_counter() - started:.0f}s)")
|
||||
|
||||
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
out = RESULTS_DIR / f"routing-bench-{stamp}.json"
|
||||
out.write_text(json.dumps({
|
||||
"generated_at": datetime.now(UTC).isoformat(),
|
||||
"fixtures": len(FIXTURES),
|
||||
"repeats": args.repeats,
|
||||
"cells": cells,
|
||||
}, indent=2))
|
||||
|
||||
print(f"\n{'cell':28} {'exact':>7} {'under':>7} {'over':>7} {'fmt':>6} {'tok':>7} {'ms':>8}")
|
||||
print("-" * 76)
|
||||
for key, cell in cells.items():
|
||||
s = cell["summary"]
|
||||
print(f"{key:28} {s.get('exact_pct'):>6}% {s.get('under_routed_pct'):>6}% "
|
||||
f"{s.get('over_routed_pct'):>6}% {s.get('format_ok_pct'):>5}% "
|
||||
f"{str(s.get('eval_tokens_median')):>7} {s.get('latency_ms_median'):>8}")
|
||||
print(f"\nwritten to {out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
# The residency guard has already run by the time this is caught;
|
||||
# a traceback here would just bury its output.
|
||||
print("\ninterrupted", file=sys.stderr)
|
||||
sys.exit(130)
|
||||
@@ -268,7 +268,7 @@ async def run_benchmarks(iterations: int = 10, verbose: bool = False):
|
||||
print(f" Max: {overall_max:.3f}s (target: ≤5.0s)")
|
||||
print(f" Avg: {overall_avg:.3f}s (target: ≤1.67s)")
|
||||
print(f"\n Recommendations:")
|
||||
print(f" - Switch to a faster model (current: mistral-nemo)")
|
||||
print(f" - Switch to a faster model (current: gemma4:e2b)")
|
||||
print(f" - Reduce system prompt complexity")
|
||||
print(f" - Limit tool calls (currently limited to 3)")
|
||||
print(f" - Consider caching household registry responses")
|
||||
|
||||
@@ -17,12 +17,16 @@ import asyncio
|
||||
import json
|
||||
import re
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from scripts.ollama_residency import install_sigterm_handler, residency_guard
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -525,18 +529,31 @@ async def main():
|
||||
original_env = ENV_PATH.read_text()
|
||||
|
||||
all_stats = []
|
||||
async with httpx.AsyncClient() as client:
|
||||
for model in models:
|
||||
stats = await benchmark_model(client, model, args.iterations)
|
||||
all_stats.append(stats)
|
||||
|
||||
# Restore original .env
|
||||
ENV_PATH.write_text(original_env)
|
||||
print(f"\n .env restored to original")
|
||||
# Both restores must survive a crash or an interrupt. The .env one especially:
|
||||
# this script rewrites OLLAMA_DEFAULT_MODEL and lets uvicorn reload onto it,
|
||||
# so bailing out mid-run used to leave the *running server* pointed at the
|
||||
# benchmark model — and DEFAULT_MODELS starts at mistral-nemo-large, the 9.2G
|
||||
# model implicated in the 2026-08-07 VRAM outage.
|
||||
install_sigterm_handler()
|
||||
try:
|
||||
with residency_guard(models_used=models):
|
||||
async with httpx.AsyncClient() as client:
|
||||
for model in models:
|
||||
stats = await benchmark_model(client, model, args.iterations)
|
||||
all_stats.append(stats)
|
||||
finally:
|
||||
ENV_PATH.write_text(original_env)
|
||||
print("\n .env restored to original")
|
||||
|
||||
print_comparison(all_stats)
|
||||
save_results(all_stats, Path(args.output))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
# .env and GPU residency are both restored by now; do not bury that
|
||||
# output under a traceback.
|
||||
print("\ninterrupted", file=sys.stderr)
|
||||
raise SystemExit(130) from None
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Labelled queries for the Steward routing benchmark.
|
||||
|
||||
Each fixture carries both `expect` and `forbid`:
|
||||
|
||||
expect capabilities that must appear. Missing one is under-routing — the
|
||||
Butler answers without a tool it needed.
|
||||
forbid capabilities that must not appear. Over-routing is not cosmetic: a
|
||||
spurious librarian is a real multi-second web call, and a spurious
|
||||
housekeeper can actuate hardware.
|
||||
|
||||
`forbid` matters more than `expect` here, because over-recommendation is the
|
||||
predicted failure when model thinking is disabled and the Steward has less room
|
||||
to discriminate.
|
||||
|
||||
The `adversarial` group deserves explanation. Until 2026-08-08 the extractor
|
||||
substring-matched capability *domains* across the Steward's whole response, so
|
||||
ordinary English in its REASON line selected agents: "description" contains the
|
||||
housekeeper domain "script", "acknowledge" contains "knowledge" and "know",
|
||||
"economy" contains the biographer domain "my". Those queries invite exactly that
|
||||
vocabulary. They now serve as an end-to-end regression: routing must depend on
|
||||
what the Steward *decided*, not on the words it happened to use while explaining.
|
||||
|
||||
Expectations follow the routing rules stated in the Steward prompt itself
|
||||
(src/agents/steward/agent.py), not on what a capability could plausibly cover.
|
||||
"""
|
||||
|
||||
CORE = "tatlock_core"
|
||||
LIB = "librarian"
|
||||
BIO = "biographer"
|
||||
HOUSE = "housekeeper"
|
||||
ALL = [CORE, LIB, BIO, HOUSE]
|
||||
|
||||
|
||||
def _others(*keep: str) -> list[str]:
|
||||
return [c for c in ALL if c not in keep]
|
||||
|
||||
|
||||
FIXTURES: list[dict] = [
|
||||
# --- arithmetic and computation -> tatlock_core --------------------------
|
||||
{"id": "math_add", "group": "math", "query": "What is 61 plus 12?",
|
||||
"expect": [CORE], "forbid": _others(CORE)},
|
||||
{"id": "math_percent", "group": "math", "query": "What is 15% of 240?",
|
||||
"expect": [CORE], "forbid": _others(CORE)},
|
||||
{"id": "math_compound", "group": "math", "query": "If I save 200 a month for 3 years, how much is that?",
|
||||
"expect": [CORE], "forbid": _others(CORE)},
|
||||
{"id": "math_sqrt", "group": "math", "query": "What is the square root of 1764?",
|
||||
"expect": [CORE], "forbid": _others(CORE)},
|
||||
|
||||
# --- date and time -> tatlock_core ---------------------------------------
|
||||
{"id": "time_now", "group": "datetime", "query": "What time is it?",
|
||||
"expect": [CORE], "forbid": _others(CORE)},
|
||||
{"id": "time_date", "group": "datetime", "query": "What is today's date?",
|
||||
"expect": [CORE], "forbid": _others(CORE)},
|
||||
{"id": "time_delta", "group": "datetime", "query": "How many days until Christmas?",
|
||||
"expect": [CORE], "forbid": _others(CORE)},
|
||||
|
||||
# --- personal memory -> biographer ---------------------------------------
|
||||
{"id": "bio_location", "group": "biographer", "query": "Where do I live?",
|
||||
"expect": [BIO], "forbid": [LIB, HOUSE]},
|
||||
{"id": "bio_name", "group": "biographer", "query": "What's my name?",
|
||||
"expect": [BIO], "forbid": [LIB, HOUSE]},
|
||||
{"id": "bio_car", "group": "biographer", "query": "What car do I drive?",
|
||||
"expect": [BIO], "forbid": [LIB, HOUSE]},
|
||||
{"id": "bio_store", "group": "biographer", "query": "Remember that I prefer my coffee black.",
|
||||
"expect": [BIO], "forbid": [LIB, HOUSE]},
|
||||
{"id": "bio_list", "group": "biographer", "query": "What do you know about me?",
|
||||
"expect": [BIO], "forbid": [LIB, HOUSE]},
|
||||
{"id": "bio_forget", "group": "biographer", "query": "Forget my old address.",
|
||||
"expect": [BIO], "forbid": [LIB, HOUSE]},
|
||||
|
||||
# --- research and current information -> librarian ------------------------
|
||||
{"id": "lib_weather", "group": "librarian", "query": "What's the weather in Rotterdam tomorrow?",
|
||||
"expect": [LIB], "forbid": [HOUSE]},
|
||||
{"id": "lib_news", "group": "librarian", "query": "What's in the news today?",
|
||||
"expect": [LIB], "forbid": [HOUSE, BIO]},
|
||||
{"id": "lib_url", "group": "librarian", "query": "Read https://example.com/article and summarise it.",
|
||||
"expect": [LIB], "forbid": [HOUSE, BIO]},
|
||||
{"id": "lib_research", "group": "librarian", "query": "Research how tidal power stations work.",
|
||||
"expect": [LIB], "forbid": [HOUSE, BIO]},
|
||||
{"id": "lib_wiki_create", "group": "librarian", "query": "Create a wiki page about our network topology.",
|
||||
"expect": [LIB], "forbid": [HOUSE, BIO]},
|
||||
|
||||
# --- home automation -> housekeeper --------------------------------------
|
||||
{"id": "house_lights_on", "group": "housekeeper", "query": "Turn on the kitchen lights.",
|
||||
"expect": [HOUSE], "forbid": [LIB, BIO, CORE]},
|
||||
{"id": "house_lights_off", "group": "housekeeper", "query": "Switch off all the lights downstairs.",
|
||||
"expect": [HOUSE], "forbid": [LIB, BIO, CORE]},
|
||||
{"id": "house_thermostat", "group": "housekeeper", "query": "Set the thermostat to 20 degrees.",
|
||||
"expect": [HOUSE], "forbid": [LIB, BIO]},
|
||||
{"id": "house_blinds", "group": "housekeeper", "query": "Close the blinds in the living room.",
|
||||
"expect": [HOUSE], "forbid": [LIB, BIO, CORE]},
|
||||
|
||||
# --- conversational -> nothing at all -------------------------------------
|
||||
# The expensive failure mode: a greeting that triggers a web search.
|
||||
{"id": "chat_greeting", "group": "conversational", "query": "Hello!",
|
||||
"expect": [], "forbid": ALL},
|
||||
{"id": "chat_thanks", "group": "conversational", "query": "Thanks, that's helpful.",
|
||||
"expect": [], "forbid": ALL},
|
||||
{"id": "chat_joke", "group": "conversational", "query": "Tell me a joke.",
|
||||
"expect": [], "forbid": ALL},
|
||||
{"id": "chat_howareyou", "group": "conversational", "query": "How are you doing today?",
|
||||
"expect": [], "forbid": ALL},
|
||||
{"id": "chat_prior_turn", "group": "conversational", "query": "What did I just say?",
|
||||
"expect": [], "forbid": ALL},
|
||||
|
||||
# --- genuinely multi-capability -------------------------------------------
|
||||
{"id": "multi_weather_home", "group": "multi",
|
||||
"query": "What's the weather here, and remember that I like it warm?",
|
||||
"expect": [LIB, BIO], "forbid": []},
|
||||
{"id": "multi_recall_search", "group": "multi",
|
||||
"query": "Look up the best route from my home address to Utrecht.",
|
||||
"expect": [BIO, LIB], "forbid": []},
|
||||
{"id": "multi_math_memory", "group": "multi",
|
||||
"query": "Remember that my budget is 500 euro, then work out 12% of it.",
|
||||
"expect": [BIO, CORE], "forbid": [LIB, HOUSE]},
|
||||
|
||||
# --- adversarial: vocabulary that used to select agents by substring ------
|
||||
# "temperature" is a housekeeper domain, but this is a unit conversion.
|
||||
{"id": "adv_temperature", "group": "adversarial", "query": "Convert 98.6 Fahrenheit to Celsius.",
|
||||
"expect": [CORE], "forbid": [HOUSE, LIB, BIO]},
|
||||
# "description" contains "script"; "discover" contains "cover".
|
||||
{"id": "adv_description", "group": "adversarial",
|
||||
"query": "Give me a short description of what 17 times 23 comes to.",
|
||||
"expect": [CORE], "forbid": [HOUSE, LIB]},
|
||||
# "acknowledge" contains "knowledge" and "know".
|
||||
{"id": "adv_acknowledge", "group": "adversarial",
|
||||
"query": "Just acknowledge this and add 5 and 6 for me.",
|
||||
"expect": [CORE], "forbid": [LIB, BIO]},
|
||||
# "my" appears inside "economy".
|
||||
{"id": "adv_economy", "group": "adversarial",
|
||||
"query": "How many zeros are in one trillion?",
|
||||
"expect": [CORE], "forbid": [BIO, HOUSE]},
|
||||
# "fan" inside "fantastic"; also a climate word without a home-control intent.
|
||||
{"id": "adv_fantastic", "group": "adversarial",
|
||||
"query": "That's fantastic. What is 8 squared?",
|
||||
"expect": [CORE], "forbid": [HOUSE, LIB]},
|
||||
# "home" without any actuation intent.
|
||||
{"id": "adv_home_word", "group": "adversarial", "query": "What time do I usually get home?",
|
||||
"expect": [BIO], "forbid": [HOUSE]},
|
||||
# "search" as ordinary English, not a web-search request.
|
||||
{"id": "adv_search_word", "group": "adversarial",
|
||||
"query": "No need to search anything, just tell me what 9 times 9 is.",
|
||||
"expect": [CORE], "forbid": [LIB]},
|
||||
# "create"/"write" are librarian domains but this is conversational.
|
||||
{"id": "adv_write_word", "group": "adversarial", "query": "Can you write that more simply?",
|
||||
"expect": [], "forbid": [LIB, HOUSE]},
|
||||
|
||||
# --- mutating intents: routing only, nothing is ever executed -------------
|
||||
{"id": "mutate_wiki_update", "group": "mutating", "query": "Update the dossier page with today's findings.",
|
||||
"expect": [LIB], "forbid": [HOUSE, CORE]},
|
||||
{"id": "mutate_scene", "group": "mutating", "query": "Run the movie night scene.",
|
||||
"expect": [HOUSE], "forbid": [LIB, BIO, CORE]},
|
||||
]
|
||||
|
||||
|
||||
GROUPS = sorted({f["group"] for f in FIXTURES})
|
||||
|
||||
assert len({f["id"] for f in FIXTURES}) == len(FIXTURES), "duplicate fixture id"
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Guard production's GPU residency across a benchmark run.
|
||||
|
||||
Benchmarks swap models on the card production is serving from. Ollama evicts to
|
||||
make room, so a run leaves its own models resident and the production one gone:
|
||||
the next voice turn pays a ~36s cold load, and the pin that prevented it is
|
||||
silently lost. That happened on 2026-08-08 — a routing benchmark evicted
|
||||
gemma4:e2b and left gemma4:e4b behind, and only the monitoring noticing
|
||||
`unexpected_models` caught it.
|
||||
|
||||
Snapshot before, restore after, and wire the restore to SIGTERM as well as the
|
||||
normal path. Python runs `finally` for SIGINT, which arrives as
|
||||
KeyboardInterrupt, but the default SIGTERM action terminates outright — so
|
||||
`timeout`, a systemd stop or a plain `kill` would skip the guard entirely.
|
||||
|
||||
from scripts.ollama_residency import residency_guard, install_sigterm_handler
|
||||
|
||||
install_sigterm_handler()
|
||||
with residency_guard(models_used=["gemma4:e4b"]):
|
||||
...
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import signal
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
OLLAMA_URL = "http://localhost:11434"
|
||||
|
||||
# keep_alive:-1 yields a year-2318 expiry, so "pinned" is simply "expires more
|
||||
# than a day out". Matches check-ai-pipeline.sh in system-admin-toj.
|
||||
PINNED_THRESHOLD_SECONDS = 86400
|
||||
|
||||
|
||||
def install_sigterm_handler() -> None:
|
||||
"""Make SIGTERM raise, so `finally` blocks and context managers still run."""
|
||||
def _raise(signum, _frame):
|
||||
raise KeyboardInterrupt(f"signal {signum}")
|
||||
|
||||
signal.signal(signal.SIGTERM, _raise)
|
||||
|
||||
|
||||
def snapshot_residency(client: httpx.Client | None = None) -> dict[str, bool]:
|
||||
"""Resident models mapped to whether each is pinned."""
|
||||
owns = client is None
|
||||
client = client or httpx.Client(timeout=30)
|
||||
try:
|
||||
data = client.get(f"{OLLAMA_URL}/api/ps", timeout=10).json()
|
||||
except Exception: # noqa: BLE001 - a missing snapshot must not abort the run
|
||||
return {}
|
||||
finally:
|
||||
if owns:
|
||||
client.close()
|
||||
|
||||
resident: dict[str, bool] = {}
|
||||
now = datetime.now(UTC)
|
||||
for model in data.get("models", []):
|
||||
pinned = False
|
||||
try:
|
||||
expires = datetime.fromisoformat(model.get("expires_at", "").replace("Z", "+00:00"))
|
||||
pinned = (expires - now).total_seconds() > PINNED_THRESHOLD_SECONDS
|
||||
except ValueError:
|
||||
pass
|
||||
resident[model["name"]] = pinned
|
||||
return resident
|
||||
|
||||
|
||||
def set_keep_alive(model: str, keep_alive: Any, client: httpx.Client | None = None) -> bool:
|
||||
"""Load, unload or pin a model. Embedding models reject /api/generate."""
|
||||
owns = client is None
|
||||
client = client or httpx.Client(timeout=180)
|
||||
payload = {"model": model, "keep_alive": keep_alive}
|
||||
try:
|
||||
for endpoint in ("generate", "embed"):
|
||||
try:
|
||||
response = client.post(f"{OLLAMA_URL}/api/{endpoint}", json=payload, timeout=180)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
if response.status_code == 200:
|
||||
return True
|
||||
if response.status_code == 400 and "does not support generate" in response.text:
|
||||
continue # embedding-only model; try /api/embed
|
||||
return False
|
||||
return False
|
||||
finally:
|
||||
if owns:
|
||||
client.close()
|
||||
|
||||
|
||||
def restore_residency(before: dict[str, bool], used: list[str]) -> None:
|
||||
"""Evict what the benchmark loaded, then re-pin what was pinned before."""
|
||||
base = {name.split(":")[0] for name in before}
|
||||
with httpx.Client(timeout=180) as client:
|
||||
for model in used:
|
||||
if model not in before and model.split(":")[0] not in base:
|
||||
print(f" residency: unloading benchmark model {model}")
|
||||
set_keep_alive(model, 0, client)
|
||||
for name, pinned in before.items():
|
||||
if not pinned:
|
||||
continue
|
||||
ok = set_keep_alive(name, -1, client)
|
||||
print(f" residency: re-pinned {name}" if ok
|
||||
else f" residency: FAILED to re-pin {name} -- run warmup-ollama.sh")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def residency_guard(models_used: list[str]) -> Iterator[dict[str, bool]]:
|
||||
"""Snapshot residency on entry, restore it on exit however that happens."""
|
||||
before = snapshot_residency()
|
||||
pinned = [n for n, p in before.items() if p]
|
||||
print(f" residency: resident before {sorted(before)}"
|
||||
f"{f' (pinned: {pinned})' if pinned else ''}")
|
||||
try:
|
||||
yield before
|
||||
finally:
|
||||
print(" residency: restoring ...")
|
||||
restore_residency(before, models_used)
|
||||
+5
-9
@@ -6,7 +6,8 @@ must implement. The interface is designed around the Responses API format.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import AsyncGenerator, Any
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
|
||||
class OutputItem:
|
||||
@@ -19,12 +20,7 @@ class OutputItem:
|
||||
- message: Assistant response message
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
type: str,
|
||||
id: str,
|
||||
**kwargs: Any
|
||||
):
|
||||
def __init__(self, type: str, id: str, **kwargs: Any):
|
||||
self.type = type
|
||||
self.id = id
|
||||
self.data = kwargs
|
||||
@@ -40,7 +36,7 @@ class AgentInterface(ABC):
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def generate_response(
|
||||
def generate_response(
|
||||
self,
|
||||
messages: list[dict],
|
||||
reasoning: dict | None = None,
|
||||
@@ -48,7 +44,7 @@ class AgentInterface(ABC):
|
||||
temperature: float = 1.0,
|
||||
max_tokens: int | None = None,
|
||||
stop: list[str] | None = None,
|
||||
**kwargs: Any
|
||||
**kwargs: Any,
|
||||
) -> AsyncGenerator[OutputItem, None]:
|
||||
"""
|
||||
Generate streaming response as output items.
|
||||
|
||||
@@ -11,6 +11,7 @@ For direct key-based lookups (location, timezone, preferences),
|
||||
use the memory_service instead - it's faster and doesn't require LLM.
|
||||
The Biographer handles semantic, fuzzy queries.
|
||||
"""
|
||||
|
||||
from src.agents.biographer.agent import (
|
||||
get_biographer_agent,
|
||||
run_biographer,
|
||||
|
||||
@@ -7,7 +7,8 @@ A PydanticAI agent that serves as the household's memory keeper:
|
||||
- Manages user profile and preferences
|
||||
- Forgets information when requested
|
||||
"""
|
||||
from typing import Any, Optional
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic_ai import Agent
|
||||
|
||||
@@ -19,7 +20,6 @@ from src.agents.biographer.tools import (
|
||||
update_preference,
|
||||
update_profile,
|
||||
)
|
||||
from src.core.config import config
|
||||
from src.core.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -97,7 +97,7 @@ When recalling:
|
||||
"""
|
||||
|
||||
# Lazy initialization to avoid connection issues during imports
|
||||
_biographer_agent: Optional[Agent[None, str]] = None
|
||||
_biographer_agent: Agent[None, str] | None = None
|
||||
|
||||
|
||||
def _create_biographer_agent() -> Agent[None, str]:
|
||||
@@ -126,6 +126,7 @@ def _create_biographer_agent() -> Agent[None, str]:
|
||||
agent.tool_plain(forget_memory)
|
||||
|
||||
from src.anthropic.model_selector import get_model_info
|
||||
|
||||
model_info = get_model_info()
|
||||
logger.info(
|
||||
"biographer_agent_created",
|
||||
@@ -153,7 +154,7 @@ def get_biographer_agent() -> Agent[None, str]:
|
||||
async def run_biographer(
|
||||
task: str,
|
||||
context: str = "",
|
||||
message_history: Optional[list[Any]] = None,
|
||||
message_history: list[Any] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Execute a memory task with The Biographer.
|
||||
@@ -216,7 +217,7 @@ async def run_biographer(
|
||||
async def run_biographer_stream(
|
||||
task: str,
|
||||
context: str = "",
|
||||
message_history: Optional[list[Any]] = None,
|
||||
message_history: list[Any] | None = None,
|
||||
):
|
||||
"""
|
||||
Execute a memory task with streaming output.
|
||||
|
||||
@@ -4,6 +4,7 @@ Biographer capability registration for the Household Registry.
|
||||
Defines The Biographer's capabilities and registers it as a
|
||||
household member for coordination by the Steward and Tatlock.
|
||||
"""
|
||||
|
||||
from src.agents.biographer.agent import get_biographer_agent
|
||||
from src.agents.biographer.tools import BIOGRAPHER_TOOLS
|
||||
from src.core.household_registry import (
|
||||
|
||||
@@ -10,6 +10,7 @@ These tools enable The Biographer to record and recall the user's story:
|
||||
For direct key-based access (get/set profile, preferences),
|
||||
use memory_service directly - these tools are for semantic queries.
|
||||
"""
|
||||
|
||||
from src.core.context import get_user
|
||||
from src.core.embeddings import get_embedding_client
|
||||
from src.core.logging_config import get_logger
|
||||
@@ -23,6 +24,7 @@ logger = get_logger(__name__)
|
||||
# Semantic Recall
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def recall_semantic(
|
||||
query: str,
|
||||
memory_type: str = "",
|
||||
@@ -108,6 +110,7 @@ async def recall_semantic(
|
||||
# Store Memory
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def store_insight(
|
||||
key: str,
|
||||
value: str,
|
||||
@@ -158,7 +161,7 @@ async def store_insight(
|
||||
f"**Keywords:** {', '.join(keywords)}",
|
||||
f"**Importance:** {importance:.1f}",
|
||||
"",
|
||||
"_Memory is now searchable via semantic recall._"
|
||||
"_Memory is now searchable via semantic recall._",
|
||||
]
|
||||
|
||||
logger.info(
|
||||
@@ -216,7 +219,7 @@ async def update_profile(
|
||||
"## Profile Updated",
|
||||
f"**{key}:** {value}",
|
||||
"",
|
||||
"_Profile data is automatically included in context._"
|
||||
"_Profile data is automatically included in context._",
|
||||
]
|
||||
|
||||
logger.info(
|
||||
@@ -271,7 +274,7 @@ async def update_preference(
|
||||
"## Preference Updated",
|
||||
f"**{key}:** {value}",
|
||||
"",
|
||||
"_Preference will be applied to future responses._"
|
||||
"_Preference will be applied to future responses._",
|
||||
]
|
||||
|
||||
logger.info(
|
||||
@@ -293,6 +296,7 @@ async def update_preference(
|
||||
# List Memories
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def list_memories(
|
||||
memory_type: str = "learned_fact",
|
||||
limit: int = 20,
|
||||
@@ -321,7 +325,7 @@ async def list_memories(
|
||||
|
||||
# Convert string to MemoryType
|
||||
try:
|
||||
mem_type = MemoryType(memory_type)
|
||||
MemoryType(memory_type) # validated for its ValueError; the value is unused
|
||||
except ValueError:
|
||||
return f"Invalid memory type '{memory_type}'. Use: user_profile, preference, or learned_fact"
|
||||
|
||||
@@ -378,6 +382,7 @@ async def list_memories(
|
||||
# Forget Memory
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def forget_memory(
|
||||
key: str,
|
||||
memory_type: str = "learned_fact",
|
||||
@@ -420,7 +425,7 @@ async def forget_memory(
|
||||
f"**Key:** {key}",
|
||||
f"**Type:** {memory_type}",
|
||||
"",
|
||||
"_Memory has been removed._"
|
||||
"_Memory has been removed._",
|
||||
]
|
||||
|
||||
logger.info(
|
||||
|
||||
+14
-11
@@ -8,6 +8,7 @@ returns a structured result for synthesis.
|
||||
This implements the agent-as-tool pattern recommended by PydanticAI:
|
||||
agents call other agents via tool wrappers, keeping each agent focused.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
@@ -23,6 +24,7 @@ logger = get_logger(__name__)
|
||||
# Action Types for Think Slug Selection
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ActionType(Enum):
|
||||
"""
|
||||
Categories of actions for selecting appropriate think messages.
|
||||
@@ -30,11 +32,12 @@ class ActionType(Enum):
|
||||
Each expert has different action types that warrant different
|
||||
butler-perspective messages to the user.
|
||||
"""
|
||||
RETRIEVE = "retrieve" # Looking up existing information
|
||||
RESEARCH = "research" # Conducting new research (web search, etc.)
|
||||
CREATE = "create" # Creating new content (pages, notes)
|
||||
CONTROL = "control" # Controlling devices/automations
|
||||
RECORD = "record" # Recording memories/notes
|
||||
|
||||
RETRIEVE = "retrieve" # Looking up existing information
|
||||
RESEARCH = "research" # Conducting new research (web search, etc.)
|
||||
CREATE = "create" # Creating new content (pages, notes)
|
||||
CONTROL = "control" # Controlling devices/automations
|
||||
RECORD = "record" # Recording memories/notes
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -159,8 +162,7 @@ def build_delegation_context(
|
||||
if isinstance(content, list):
|
||||
# Tolerate structured content parts
|
||||
content = " ".join(
|
||||
part.get("text", "") if isinstance(part, dict) else str(part)
|
||||
for part in content
|
||||
part.get("text", "") if isinstance(part, dict) else str(part) for part in content
|
||||
)
|
||||
content = str(content).strip()
|
||||
if content:
|
||||
@@ -206,6 +208,7 @@ class DelegationTask:
|
||||
depends_on: List of task IDs this task depends on
|
||||
result: Result from expert after execution
|
||||
"""
|
||||
|
||||
expert_name: str
|
||||
task: str
|
||||
context: str = ""
|
||||
@@ -215,10 +218,11 @@ class DelegationTask:
|
||||
result: str | None = None
|
||||
task_id: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
def __post_init__(self) -> None:
|
||||
"""Generate task ID if not provided."""
|
||||
if not self.task_id:
|
||||
import uuid
|
||||
|
||||
self.task_id = f"{self.expert_name}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
@@ -236,6 +240,7 @@ class DelegationResult:
|
||||
error: Short user-safe error label if failed. Exception detail
|
||||
stays in the logs only
|
||||
"""
|
||||
|
||||
expert_name: str
|
||||
task: str
|
||||
success: bool
|
||||
@@ -335,9 +340,7 @@ async def delegate_to_librarian(
|
||||
|
||||
if span:
|
||||
span.metadata["success"] = False
|
||||
span.details["error"] = (
|
||||
f"timed out after {config.LIBRARIAN_TIMEOUT}s"
|
||||
)
|
||||
span.details["error"] = f"timed out after {config.LIBRARIAN_TIMEOUT}s"
|
||||
|
||||
return DelegationResult(
|
||||
expert_name="librarian",
|
||||
|
||||
@@ -4,6 +4,7 @@ The Housekeeper - Home Automation Agent.
|
||||
Provides home automation capabilities through the core-api service,
|
||||
which wraps the Home Assistant REST API into LLM-friendly endpoints.
|
||||
"""
|
||||
|
||||
from src.agents.housekeeper.agent import run_housekeeper, run_housekeeper_stream
|
||||
from src.agents.housekeeper.capability import (
|
||||
HOUSEKEEPER_CAPABILITY,
|
||||
|
||||
@@ -8,7 +8,8 @@ the core-api service, which wraps Home Assistant REST API, offering:
|
||||
- Script execution
|
||||
- Automation management
|
||||
"""
|
||||
from typing import Any, Optional
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic_ai import Agent
|
||||
|
||||
@@ -27,7 +28,6 @@ from src.agents.housekeeper.tools import (
|
||||
turn_off,
|
||||
turn_on,
|
||||
)
|
||||
from src.core.config import config
|
||||
from src.core.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -98,7 +98,7 @@ After completing actions, briefly confirm:
|
||||
"""
|
||||
|
||||
# Lazy initialization to avoid connection issues during imports
|
||||
_housekeeper_agent: Optional[Agent[None, str]] = None
|
||||
_housekeeper_agent: Agent[None, str] | None = None
|
||||
|
||||
|
||||
def _create_housekeeper_agent() -> Agent[None, str]:
|
||||
@@ -140,6 +140,7 @@ def _create_housekeeper_agent() -> Agent[None, str]:
|
||||
agent.tool_plain(get_history)
|
||||
|
||||
from src.anthropic.model_selector import get_model_info
|
||||
|
||||
model_info = get_model_info()
|
||||
logger.info(
|
||||
"housekeeper_agent_created",
|
||||
@@ -167,7 +168,7 @@ def get_housekeeper_agent() -> Agent[None, str]:
|
||||
async def run_housekeeper(
|
||||
task: str,
|
||||
context: str = "",
|
||||
message_history: Optional[list[Any]] = None,
|
||||
message_history: list[Any] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Execute a home automation task with The Housekeeper.
|
||||
@@ -234,7 +235,7 @@ async def run_housekeeper(
|
||||
async def run_housekeeper_stream(
|
||||
task: str,
|
||||
context: str = "",
|
||||
message_history: Optional[list[Any]] = None,
|
||||
message_history: list[Any] | None = None,
|
||||
):
|
||||
"""
|
||||
Execute a home automation task with streaming output.
|
||||
|
||||
@@ -4,6 +4,7 @@ Housekeeper capability registration for the Household Registry.
|
||||
Defines The Housekeeper's capabilities and registers it as a
|
||||
household member for coordination by the Steward and Tatlock.
|
||||
"""
|
||||
|
||||
from src.agents.housekeeper.agent import get_housekeeper_agent
|
||||
from src.agents.housekeeper.tools import HOUSEKEEPER_TOOLS
|
||||
from src.core.household_registry import (
|
||||
|
||||
@@ -5,7 +5,8 @@ Provides async methods for home automation operations via Home Assistant.
|
||||
Core-API is a separate service that wraps the Home Assistant REST API
|
||||
into LLM-friendly endpoints.
|
||||
"""
|
||||
from typing import Any, Optional
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -28,7 +29,7 @@ class Device(BaseModel):
|
||||
name: str
|
||||
state: str
|
||||
domain: str
|
||||
area: Optional[str] = None
|
||||
area: str | None = None
|
||||
attributes: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -38,8 +39,8 @@ class DeviceState(BaseModel):
|
||||
entity_id: str
|
||||
state: str
|
||||
attributes: dict[str, Any] = Field(default_factory=dict)
|
||||
last_changed: Optional[str] = None
|
||||
last_updated: Optional[str] = None
|
||||
last_changed: str | None = None
|
||||
last_updated: str | None = None
|
||||
|
||||
|
||||
class Scene(BaseModel):
|
||||
@@ -47,7 +48,7 @@ class Scene(BaseModel):
|
||||
|
||||
entity_id: str
|
||||
name: str
|
||||
friendly_name: Optional[str] = None
|
||||
friendly_name: str | None = None
|
||||
|
||||
|
||||
class Script(BaseModel):
|
||||
@@ -55,8 +56,8 @@ class Script(BaseModel):
|
||||
|
||||
entity_id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
last_triggered: Optional[str] = None
|
||||
description: str | None = None
|
||||
last_triggered: str | None = None
|
||||
|
||||
|
||||
class Automation(BaseModel):
|
||||
@@ -64,8 +65,8 @@ class Automation(BaseModel):
|
||||
|
||||
entity_id: str
|
||||
name: str
|
||||
state: str = "on"
|
||||
last_triggered: Optional[str] = None
|
||||
state: str = "on"
|
||||
last_triggered: str | None = None
|
||||
|
||||
|
||||
class HistoryEntry(BaseModel):
|
||||
@@ -109,8 +110,8 @@ class CoreAPIClient:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
base_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
timeout: int = 30,
|
||||
):
|
||||
"""
|
||||
@@ -124,7 +125,7 @@ class CoreAPIClient:
|
||||
self.base_url = base_url or str(config.CORE_API_HOST)
|
||||
self.api_key = api_key or config.CORE_API_KEY
|
||||
self.timeout = timeout
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
async def __aenter__(self) -> "CoreAPIClient":
|
||||
"""Create HTTP client on context entry."""
|
||||
@@ -159,8 +160,8 @@ class CoreAPIClient:
|
||||
|
||||
async def list_devices(
|
||||
self,
|
||||
domain: Optional[str] = None,
|
||||
area: Optional[str] = None,
|
||||
domain: str | None = None,
|
||||
area: str | None = None,
|
||||
) -> list[Device]:
|
||||
"""
|
||||
List devices, optionally filtered by domain or area.
|
||||
@@ -231,9 +232,9 @@ class CoreAPIClient:
|
||||
async def turn_on(
|
||||
self,
|
||||
entity_id: str,
|
||||
brightness: Optional[int] = None,
|
||||
color_temp: Optional[int] = None,
|
||||
rgb_color: Optional[tuple[int, int, int]] = None,
|
||||
brightness: int | None = None,
|
||||
color_temp: int | None = None,
|
||||
rgb_color: tuple[int, int, int] | None = None,
|
||||
) -> ControlResult:
|
||||
"""
|
||||
Turn on a device.
|
||||
@@ -399,7 +400,7 @@ class CoreAPIClient:
|
||||
async def run_script(
|
||||
self,
|
||||
script_id: str,
|
||||
variables: Optional[dict[str, Any]] = None,
|
||||
variables: dict[str, Any] | None = None,
|
||||
) -> ControlResult:
|
||||
"""
|
||||
Run a script.
|
||||
|
||||
@@ -4,6 +4,7 @@ Housekeeper tools for PydanticAI agent.
|
||||
These tools wrap the core-api service and are registered with
|
||||
The Housekeeper agent for home automation tasks.
|
||||
"""
|
||||
|
||||
from src.agents.housekeeper.client import CoreAPIClient
|
||||
from src.core.logging_config import get_logger
|
||||
|
||||
@@ -72,14 +73,24 @@ async def list_devices(
|
||||
return True
|
||||
return False
|
||||
|
||||
sorted_devices = sorted(dom_devices, key=lambda d: (not is_room_group(d), d.entity_id))
|
||||
sorted_devices = sorted(
|
||||
dom_devices, key=lambda d: (not is_room_group(d), d.entity_id)
|
||||
)
|
||||
|
||||
for device in sorted_devices:
|
||||
state_icon = "on" if device.state == "on" else "off" if device.state == "off" else device.state
|
||||
state_icon = (
|
||||
"on"
|
||||
if device.state == "on"
|
||||
else "off"
|
||||
if device.state == "off"
|
||||
else device.state
|
||||
)
|
||||
area_str = f" ({device.area})" if device.area else ""
|
||||
# Mark room groups clearly using actual HA data
|
||||
group_marker = " [ROOM GROUP]" if is_room_group(device) else ""
|
||||
output_parts.append(f"- **{device.name}**{area_str}{group_marker}: {state_icon}")
|
||||
output_parts.append(
|
||||
f"- **{device.name}**{area_str}{group_marker}: {state_icon}"
|
||||
)
|
||||
output_parts.append(f" ID: `{device.entity_id}`")
|
||||
output_parts.append("")
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ Connects to the library-desk API to provide:
|
||||
- Knowledge graph queries
|
||||
- Semantic search
|
||||
"""
|
||||
|
||||
from src.agents.librarian.agent import (
|
||||
get_librarian_agent,
|
||||
run_librarian,
|
||||
|
||||
@@ -7,6 +7,7 @@ the library-desk API, offering:
|
||||
- Wiki and document management
|
||||
- Semantic search and knowledge graph exploration
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic_ai import Agent
|
||||
@@ -202,6 +203,7 @@ def _create_librarian_agent() -> Agent[None, str]:
|
||||
agent.tool_plain(smart_create_wiki_page)
|
||||
|
||||
from src.anthropic.model_selector import get_model_info
|
||||
|
||||
model_info = get_model_info()
|
||||
logger.info(
|
||||
"librarian_agent_created",
|
||||
@@ -294,6 +296,4 @@ async def run_librarian(
|
||||
error=str(e),
|
||||
exc_info=True,
|
||||
)
|
||||
raise AgentError(
|
||||
"Research task failed", agent_name="librarian"
|
||||
) from e
|
||||
raise AgentError("Research task failed", agent_name="librarian") from e
|
||||
|
||||
@@ -4,6 +4,7 @@ Librarian capability registration for the Household Registry.
|
||||
Defines The Librarian's capabilities and registers it as a
|
||||
household member for coordination by the Steward and Tatlock.
|
||||
"""
|
||||
|
||||
from src.agents.librarian.agent import get_librarian_agent
|
||||
from src.agents.librarian.tools import LIBRARIAN_TOOLS
|
||||
from src.core.household_registry import (
|
||||
|
||||
@@ -7,6 +7,7 @@ Provides async methods for all relevant library-desk endpoints:
|
||||
- Vector search
|
||||
- Knowledge graph queries
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -38,8 +39,10 @@ _shared_http_client: ContextVar[httpx.AsyncClient | None] = ContextVar(
|
||||
# Response Models
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class WikiPage(BaseModel):
|
||||
"""Wiki page from library-desk."""
|
||||
|
||||
id: int
|
||||
path: str
|
||||
title: str
|
||||
@@ -52,6 +55,7 @@ class WikiPage(BaseModel):
|
||||
|
||||
class WikiSearchResult(BaseModel):
|
||||
"""Search result from wiki search."""
|
||||
|
||||
id: int
|
||||
path: str
|
||||
title: str
|
||||
@@ -61,6 +65,7 @@ class WikiSearchResult(BaseModel):
|
||||
|
||||
class VectorSearchResult(BaseModel):
|
||||
"""Result from semantic vector search."""
|
||||
|
||||
page_id: int
|
||||
page_path: str
|
||||
page_title: str
|
||||
@@ -71,8 +76,11 @@ class VectorSearchResult(BaseModel):
|
||||
|
||||
class HybridSearchResult(BaseModel):
|
||||
"""Result from HybridRAG search."""
|
||||
|
||||
source: str # source_type: "wiki", "web", "volatile", "document"
|
||||
sources: list[str] = Field(default_factory=list) # legs that found it: "vector", "graph", "web", ...
|
||||
sources: list[str] = Field(
|
||||
default_factory=list
|
||||
) # legs that found it: "vector", "graph", "web", ...
|
||||
title: str
|
||||
content: str
|
||||
url: str | None = None
|
||||
@@ -84,6 +92,7 @@ class HybridSearchResult(BaseModel):
|
||||
|
||||
class HybridRAGResponse(BaseModel):
|
||||
"""Full response from HybridRAG query."""
|
||||
|
||||
results: list[HybridSearchResult] = Field(default_factory=list)
|
||||
keywords: list[str] = Field(default_factory=list)
|
||||
synonyms: list[str] = Field(default_factory=list)
|
||||
@@ -102,6 +111,7 @@ class HybridRAGResponse(BaseModel):
|
||||
|
||||
class GraphNode(BaseModel):
|
||||
"""Node from knowledge graph."""
|
||||
|
||||
id: str
|
||||
labels: list[str] = Field(default_factory=list)
|
||||
properties: dict[str, Any] = Field(default_factory=dict)
|
||||
@@ -109,12 +119,14 @@ class GraphNode(BaseModel):
|
||||
|
||||
class Dossier(BaseModel):
|
||||
"""A dossier (tag-based collection)."""
|
||||
|
||||
name: str
|
||||
page_count: int
|
||||
|
||||
|
||||
class ResearchSummary(BaseModel):
|
||||
"""Summary of research performed during smart-create."""
|
||||
|
||||
wiki_results: int = 0
|
||||
web_results: int = 0
|
||||
graph_entities: int = 0
|
||||
@@ -124,6 +136,7 @@ class ResearchSummary(BaseModel):
|
||||
|
||||
class WebSearchResult(BaseModel):
|
||||
"""Result from web search via /rag/search."""
|
||||
|
||||
title: str
|
||||
url: str
|
||||
content: str = "" # Full extracted text via Trafilatura
|
||||
@@ -134,6 +147,7 @@ class WebSearchResult(BaseModel):
|
||||
|
||||
class WebSearchResponse(BaseModel):
|
||||
"""Response from /rag/search endpoint."""
|
||||
|
||||
query: str
|
||||
search_type: str
|
||||
results: list[WebSearchResult] = Field(default_factory=list)
|
||||
@@ -144,6 +158,7 @@ class WebSearchResponse(BaseModel):
|
||||
|
||||
class ContentExtractionResult(BaseModel):
|
||||
"""Result from content extraction."""
|
||||
|
||||
url: str
|
||||
title: str | None = None
|
||||
content: str = ""
|
||||
@@ -156,6 +171,7 @@ class ContentExtractionResult(BaseModel):
|
||||
|
||||
class BatchExtractionResponse(BaseModel):
|
||||
"""Response from batch content extraction."""
|
||||
|
||||
results: list[ContentExtractionResult] = Field(default_factory=list)
|
||||
total_urls: int = 0
|
||||
successful: int = 0
|
||||
@@ -165,6 +181,7 @@ class BatchExtractionResponse(BaseModel):
|
||||
|
||||
class EntityLinking(BaseModel):
|
||||
"""Entity linking results from smart-create."""
|
||||
|
||||
forward_links: int = 0
|
||||
backward_links: int = 0
|
||||
pages_updated: int = 0
|
||||
@@ -172,6 +189,7 @@ class EntityLinking(BaseModel):
|
||||
|
||||
class SmartCreateResponse(BaseModel):
|
||||
"""Response from smart-create wiki page endpoint."""
|
||||
|
||||
page: WikiPage
|
||||
research_summary: ResearchSummary = Field(default_factory=ResearchSummary)
|
||||
sources_used: int = 0
|
||||
@@ -183,6 +201,7 @@ class SmartCreateResponse(BaseModel):
|
||||
# Client
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class LibraryDeskClient:
|
||||
"""
|
||||
Async HTTP client for Library-Desk API.
|
||||
@@ -398,17 +417,19 @@ class LibraryDeskClient:
|
||||
# related_dossiers; older names kept as fallbacks)
|
||||
results = []
|
||||
for r in data.get("results", []):
|
||||
results.append(HybridSearchResult(
|
||||
source=r.get("source_type") or r.get("source", "unknown"),
|
||||
sources=r.get("sources", []),
|
||||
title=r.get("title", ""),
|
||||
content=r.get("content", ""),
|
||||
url=r.get("url"),
|
||||
score=r.get("rrf_score", r.get("score", 0.0)),
|
||||
page_id=r.get("page_id"),
|
||||
related_dossiers=r.get("related_dossiers", []),
|
||||
metadata=r.get("metadata", {}),
|
||||
))
|
||||
results.append(
|
||||
HybridSearchResult(
|
||||
source=r.get("source_type") or r.get("source", "unknown"),
|
||||
sources=r.get("sources", []),
|
||||
title=r.get("title", ""),
|
||||
content=r.get("content", ""),
|
||||
url=r.get("url"),
|
||||
score=r.get("rrf_score", r.get("score", 0.0)),
|
||||
page_id=r.get("page_id"),
|
||||
related_dossiers=r.get("related_dossiers", []),
|
||||
metadata=r.get("metadata", {}),
|
||||
)
|
||||
)
|
||||
|
||||
# Handle keywords being either a list or a dict with core_keywords;
|
||||
# the live service nests synonyms inside the keywords dict as a
|
||||
|
||||
@@ -4,6 +4,7 @@ Librarian tools for PydanticAI agent.
|
||||
These tools wrap the library-desk API and are registered with
|
||||
The Librarian agent for research and knowledge management tasks.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from pydantic_ai import ModelRetry
|
||||
|
||||
@@ -27,9 +28,8 @@ def _retry_if_transient(e: Exception, what: str) -> None:
|
||||
status = e.response.status_code
|
||||
retryable = status >= 500 or status == 429
|
||||
if retryable:
|
||||
raise ModelRetry(
|
||||
f"{what} is temporarily unavailable; please retry."
|
||||
) from e
|
||||
raise ModelRetry(f"{what} is temporarily unavailable; please retry.") from e
|
||||
|
||||
|
||||
# Icons keyed by the values library-desk emits in each result's `sources`
|
||||
# list (search legs) and `source_type` (result origin).
|
||||
@@ -64,11 +64,7 @@ def _coverage_note(
|
||||
results, so their absence is normal ranking behavior, not an outage.
|
||||
"""
|
||||
if response.source_status:
|
||||
failed = sorted(
|
||||
leg
|
||||
for leg, status in response.source_status.items()
|
||||
if status == "failed"
|
||||
)
|
||||
failed = sorted(leg for leg, status in response.source_status.items() if status == "failed")
|
||||
if failed:
|
||||
return (
|
||||
"⚠️ *Coverage note: results are partial - "
|
||||
@@ -111,6 +107,7 @@ def _coverage_note(
|
||||
# HybridRAG Search
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def hybrid_search(
|
||||
query: str,
|
||||
include_web: bool = True,
|
||||
@@ -165,9 +162,7 @@ async def hybrid_search(
|
||||
|
||||
# Add related dossiers
|
||||
if response.related_dossiers:
|
||||
output_parts.append(
|
||||
f"**Related Dossiers:** {', '.join(response.related_dossiers)}"
|
||||
)
|
||||
output_parts.append(f"**Related Dossiers:** {', '.join(response.related_dossiers)}")
|
||||
|
||||
output_parts.append("")
|
||||
|
||||
@@ -216,6 +211,7 @@ async def hybrid_search(
|
||||
# Wiki Operations
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def search_wiki(
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
@@ -331,9 +327,7 @@ async def list_dossiers() -> str:
|
||||
output_parts = ["## Research Dossiers\n"]
|
||||
|
||||
for dossier in dossiers:
|
||||
output_parts.append(
|
||||
f"- **{dossier.name}** ({dossier.page_count} pages)"
|
||||
)
|
||||
output_parts.append(f"- **{dossier.name}** ({dossier.page_count} pages)")
|
||||
|
||||
return "\n".join(output_parts)
|
||||
|
||||
@@ -389,6 +383,7 @@ async def get_dossier_pages(
|
||||
# Semantic Search
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def semantic_search(
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
@@ -420,9 +415,7 @@ async def semantic_search(
|
||||
output_parts = [f"## Semantic Search: {query}\n"]
|
||||
|
||||
for i, result in enumerate(results, 1):
|
||||
output_parts.append(
|
||||
f"{i}. **{result.page_title}** (score: {result.score:.2f})"
|
||||
)
|
||||
output_parts.append(f"{i}. **{result.page_title}** (score: {result.score:.2f})")
|
||||
output_parts.append(f" Path: {result.page_path}")
|
||||
output_parts.append(f" {result.chunk_text[:200]}...")
|
||||
output_parts.append("")
|
||||
@@ -439,6 +432,7 @@ async def semantic_search(
|
||||
# Knowledge Graph
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def explore_knowledge_graph(
|
||||
entity_type: str = "Document",
|
||||
limit: int = 20,
|
||||
@@ -565,6 +559,7 @@ async def find_related_entities(
|
||||
# Web Search & Content Extraction
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def search_web(
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
@@ -605,7 +600,9 @@ async def search_web(
|
||||
return f"No results found for '{query}'"
|
||||
|
||||
output_parts = [f"## Web Search: {query}\n"]
|
||||
output_parts.append(f"*Found {response.total_results} results in {response.search_time_ms}ms*\n")
|
||||
output_parts.append(
|
||||
f"*Found {response.total_results} results in {response.search_time_ms}ms*\n"
|
||||
)
|
||||
|
||||
for i, result in enumerate(response.results, 1):
|
||||
output_parts.append(f"### {i}. {result.title}")
|
||||
@@ -880,7 +877,9 @@ async def update_wiki_page(
|
||||
if page.tags:
|
||||
output_parts.append(f"**Tags:** {', '.join(page.tags)}")
|
||||
|
||||
output_parts.append("\n*Vector embeddings and knowledge graph will be updated automatically.*")
|
||||
output_parts.append(
|
||||
"\n*Vector embeddings and knowledge graph will be updated automatically.*"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"librarian_update_page",
|
||||
@@ -954,7 +953,9 @@ async def create_wiki_page(
|
||||
if page.description:
|
||||
output_parts.append(f"**Description:** {page.description}")
|
||||
|
||||
output_parts.append("\n*Vector embeddings and knowledge graph will be updated automatically.*")
|
||||
output_parts.append(
|
||||
"\n*Vector embeddings and knowledge graph will be updated automatically.*"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"librarian_create_page",
|
||||
|
||||
+25
-48
@@ -12,16 +12,16 @@ infrastructure is real production code.
|
||||
import asyncio
|
||||
import random
|
||||
import secrets
|
||||
from typing import AsyncGenerator, Any
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from src.agents.base import AgentInterface, OutputItem
|
||||
from src.core.exceptions import (
|
||||
RateLimitError,
|
||||
ContextLengthError,
|
||||
APIError,
|
||||
ContextLengthError,
|
||||
RateLimitError,
|
||||
)
|
||||
|
||||
|
||||
# Mock lorem ipsum content
|
||||
LOREM_PARAGRAPHS = [
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
@@ -46,33 +46,27 @@ MOCK_TOOLS = [
|
||||
"description": "Search the knowledge base for relevant information",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
"properties": {"query": {"type": "string", "description": "Search query"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "calculate",
|
||||
"description": "Perform mathematical calculations",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expression": {"type": "string", "description": "Math expression"}
|
||||
},
|
||||
"required": ["expression"]
|
||||
}
|
||||
"properties": {"expression": {"type": "string", "description": "Math expression"}},
|
||||
"required": ["expression"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "Get current weather for a location",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {"type": "string", "description": "City name"}
|
||||
},
|
||||
"required": ["location"]
|
||||
}
|
||||
"properties": {"location": {"type": "string", "description": "City name"}},
|
||||
"required": ["location"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -109,7 +103,7 @@ class LoremTesterAgent(AgentInterface):
|
||||
temperature: float = 1.0,
|
||||
max_tokens: int | None = None,
|
||||
stop: list[str] | None = None,
|
||||
**kwargs: Any
|
||||
**kwargs: Any,
|
||||
) -> AsyncGenerator[OutputItem, None]:
|
||||
"""
|
||||
Generate mock response with reasoning, tools, and content.
|
||||
@@ -123,8 +117,7 @@ class LoremTesterAgent(AgentInterface):
|
||||
# 1. Yield reasoning item if requested
|
||||
if reasoning and reasoning.get("summary") == "auto":
|
||||
yield await self._create_reasoning_item(
|
||||
messages,
|
||||
effort=reasoning.get("effort", "medium")
|
||||
messages, effort=reasoning.get("effort", "medium")
|
||||
)
|
||||
|
||||
# 2. Randomly yield function calls if tools available (30% chance)
|
||||
@@ -150,7 +143,7 @@ class LoremTesterAgent(AgentInterface):
|
||||
"reasoning": True,
|
||||
"tools": True,
|
||||
"vision": False, # Not yet
|
||||
"audio": False, # Not yet
|
||||
"audio": False, # Not yet
|
||||
}
|
||||
|
||||
# Private helper methods
|
||||
@@ -179,9 +172,7 @@ class LoremTesterAgent(AgentInterface):
|
||||
raise APIError("Invalid tool call: tool 'nonexistent' not found (mock trigger)")
|
||||
|
||||
async def _create_reasoning_item(
|
||||
self,
|
||||
messages: list[dict],
|
||||
effort: str = "medium"
|
||||
self, messages: list[dict], effort: str = "medium"
|
||||
) -> OutputItem:
|
||||
"""Create a reasoning output item with mock thinking steps."""
|
||||
|
||||
@@ -200,23 +191,17 @@ class LoremTesterAgent(AgentInterface):
|
||||
steps = random.sample(REASONING_STEPS, min(num_steps, len(REASONING_STEPS)))
|
||||
|
||||
return OutputItem(
|
||||
type="reasoning",
|
||||
id=f"rs_{generate_id()}",
|
||||
summary=steps,
|
||||
status="completed"
|
||||
type="reasoning", id=f"rs_{generate_id()}", summary=steps, status="completed"
|
||||
)
|
||||
|
||||
async def _create_tool_calls(
|
||||
self,
|
||||
tools: list[dict]
|
||||
) -> AsyncGenerator[OutputItem, None]:
|
||||
async def _create_tool_calls(self, tools: list[dict]) -> AsyncGenerator[OutputItem, None]:
|
||||
"""Create mock function call output items."""
|
||||
|
||||
# Randomly select 1-2 tools to "call"
|
||||
num_calls = random.randint(1, 2)
|
||||
selected_tools = random.sample(
|
||||
MOCK_TOOLS[:min(len(MOCK_TOOLS), len(tools))],
|
||||
min(num_calls, len(MOCK_TOOLS), len(tools))
|
||||
MOCK_TOOLS[: min(len(MOCK_TOOLS), len(tools))],
|
||||
min(num_calls, len(MOCK_TOOLS), len(tools)),
|
||||
)
|
||||
|
||||
for tool in selected_tools:
|
||||
@@ -228,7 +213,7 @@ class LoremTesterAgent(AgentInterface):
|
||||
id=f"fc_{generate_id()}",
|
||||
name=tool["name"],
|
||||
arguments=args,
|
||||
status="completed"
|
||||
status="completed",
|
||||
)
|
||||
|
||||
def _generate_mock_args(self, tool: dict) -> str:
|
||||
@@ -254,11 +239,7 @@ class LoremTesterAgent(AgentInterface):
|
||||
# Generic mock arguments
|
||||
return json.dumps({"input": "mock_value"})
|
||||
|
||||
async def _create_message_item(
|
||||
self,
|
||||
messages: list[dict],
|
||||
temperature: float
|
||||
) -> OutputItem:
|
||||
async def _create_message_item(self, messages: list[dict], temperature: float) -> OutputItem:
|
||||
"""Create final message output item with lorem ipsum content."""
|
||||
|
||||
# Select random lorem ipsum paragraphs
|
||||
@@ -270,10 +251,6 @@ class LoremTesterAgent(AgentInterface):
|
||||
type="message",
|
||||
id=f"msg_{generate_id()}",
|
||||
role="assistant",
|
||||
content=[{
|
||||
"type": "output_text",
|
||||
"text": content,
|
||||
"annotations": []
|
||||
}],
|
||||
status="completed"
|
||||
content=[{"type": "output_text", "text": content, "annotations": []}],
|
||||
status="completed",
|
||||
)
|
||||
|
||||
+20
-11
@@ -14,12 +14,13 @@ Supports:
|
||||
- Result aggregation from multiple experts
|
||||
- Partial failure handling
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import AsyncGenerator, Optional, Callable, Any
|
||||
|
||||
from src.agents.delegation import DelegationTask, DelegationResult, delegate_to_librarian
|
||||
from src.agents.delegation import DelegationResult, DelegationTask, delegate_to_librarian
|
||||
from src.core.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -27,8 +28,9 @@ logger = get_logger(__name__)
|
||||
|
||||
class ExecutionMode(str, Enum):
|
||||
"""Execution mode for multi-expert coordination."""
|
||||
|
||||
SEQUENTIAL = "sequential" # One at a time, in order
|
||||
PARALLEL = "parallel" # All at once, concurrently
|
||||
PARALLEL = "parallel" # All at once, concurrently
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -38,12 +40,13 @@ class OrchestrationContext:
|
||||
|
||||
Tracks the user's request, delegation tasks, and results.
|
||||
"""
|
||||
|
||||
user_message: str
|
||||
steward_note: str
|
||||
conversation_id: Optional[str] = None
|
||||
conversation_id: str | None = None
|
||||
|
||||
|
||||
def parse_delegation_from_steward_note(steward_note: str) -> Optional[DelegationTask]:
|
||||
def parse_delegation_from_steward_note(steward_note: str) -> DelegationTask | None:
|
||||
"""
|
||||
Parse a delegation task from Steward's note.
|
||||
|
||||
@@ -68,9 +71,9 @@ def parse_delegation_from_steward_note(steward_note: str) -> Optional[Delegation
|
||||
# Look for DELEGATE: pattern
|
||||
# Match: "DELEGATE: expert_name to action description"
|
||||
match = re.search(
|
||||
r'DELEGATE:\s*(\w+)\s+to\s+(.+?)(?:\n|REASON:|COMPLEXITY:|CONTEXT:|$)',
|
||||
r"DELEGATE:\s*(\w+)\s+to\s+(.+?)(?:\n|REASON:|COMPLEXITY:|CONTEXT:|$)",
|
||||
steward_note,
|
||||
re.IGNORECASE | re.MULTILINE
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
if match:
|
||||
@@ -135,7 +138,7 @@ async def execute_delegation(
|
||||
async def orchestrate_with_think_updates(
|
||||
user_message: str,
|
||||
steward_note: str,
|
||||
delegation_task: Optional[DelegationTask] = None,
|
||||
delegation_task: DelegationTask | None = None,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
Orchestrate expert delegation with streaming think updates.
|
||||
@@ -218,17 +221,21 @@ def extract_delegation_context(
|
||||
}
|
||||
|
||||
# Extract REASON:
|
||||
reason_match = re.search(r'REASON:\s*(.+?)(?:\n|COMPLEXITY:|CONTEXT:|$)', steward_note, re.IGNORECASE)
|
||||
reason_match = re.search(
|
||||
r"REASON:\s*(.+?)(?:\n|COMPLEXITY:|CONTEXT:|$)", steward_note, re.IGNORECASE
|
||||
)
|
||||
if reason_match:
|
||||
result["reason"] = reason_match.group(1).strip()
|
||||
|
||||
# Extract COMPLEXITY:
|
||||
complexity_match = re.search(r'COMPLEXITY:\s*(.+?)(?:\n|CONTEXT:|$)', steward_note, re.IGNORECASE)
|
||||
complexity_match = re.search(
|
||||
r"COMPLEXITY:\s*(.+?)(?:\n|CONTEXT:|$)", steward_note, re.IGNORECASE
|
||||
)
|
||||
if complexity_match:
|
||||
result["complexity"] = complexity_match.group(1).strip()
|
||||
|
||||
# Extract CONTEXT:
|
||||
context_match = re.search(r'CONTEXT:\s*(.+?)$', steward_note, re.IGNORECASE | re.MULTILINE)
|
||||
context_match = re.search(r"CONTEXT:\s*(.+?)$", steward_note, re.IGNORECASE | re.MULTILINE)
|
||||
if context_match:
|
||||
result["context"] = context_match.group(1).strip()
|
||||
|
||||
@@ -239,6 +246,7 @@ def extract_delegation_context(
|
||||
# Multi-Expert Coordination
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class MultiExpertResult:
|
||||
"""
|
||||
@@ -250,6 +258,7 @@ class MultiExpertResult:
|
||||
failed_experts: List of expert names that failed
|
||||
combined_output: Aggregated output from all successful experts
|
||||
"""
|
||||
|
||||
results: dict[str, DelegationResult] = field(default_factory=dict)
|
||||
all_succeeded: bool = True
|
||||
failed_experts: list[str] = field(default_factory=list)
|
||||
|
||||
+11
-12
@@ -8,9 +8,6 @@ It provides a central place to:
|
||||
- Check model capabilities
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Type
|
||||
|
||||
from src.agents.base import AgentInterface
|
||||
from src.agents.lorem_tester import LoremTesterAgent
|
||||
from src.agents.tatlock import TatlockAgent
|
||||
@@ -63,7 +60,7 @@ class ModelRegistry:
|
||||
if model_id not in cls.MODELS:
|
||||
raise ModelNotFoundError(model_id)
|
||||
|
||||
agent_class: Type[AgentInterface] = cls.MODELS[model_id]["agent_class"]
|
||||
agent_class: type[AgentInterface] = cls.MODELS[model_id]["agent_class"]
|
||||
return agent_class()
|
||||
|
||||
@classmethod
|
||||
@@ -106,14 +103,16 @@ class ModelRegistry:
|
||||
agent = cls.get_agent(model_id)
|
||||
capabilities = await agent.get_capabilities()
|
||||
|
||||
models.append({
|
||||
"id": model_id,
|
||||
"object": "model",
|
||||
"created": config["created"],
|
||||
"owned_by": config["owned_by"],
|
||||
"capabilities": capabilities,
|
||||
"description": config["description"],
|
||||
})
|
||||
models.append(
|
||||
{
|
||||
"id": model_id,
|
||||
"object": "model",
|
||||
"created": config["created"],
|
||||
"owned_by": config["owned_by"],
|
||||
"capabilities": capabilities,
|
||||
"description": config["description"],
|
||||
}
|
||||
)
|
||||
|
||||
return models
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Steward agent package.
|
||||
The Steward analyzes incoming requests and recommends relevant household
|
||||
capabilities, creating a two-tier architecture with the Butler.
|
||||
"""
|
||||
|
||||
from .agent import StewardAgent, get_steward_agent
|
||||
from .schemas import ConversationContext, StewardRecommendation
|
||||
from .service import analyze_request, format_steward_note
|
||||
|
||||
@@ -8,8 +8,8 @@ This creates a two-tier architecture that prevents cognitive overload.
|
||||
Uses plain text output (not JSON) for reliability. Supports both Claude
|
||||
(preferred) and Ollama (fallback) backends via direct API calls.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from typing import Optional
|
||||
|
||||
from src.anthropic.model_selector import get_model_info, is_claude_available, resolve_backend
|
||||
from src.core.config import config
|
||||
@@ -29,9 +29,7 @@ def build_steward_prompt(query: str, conversation_history: list[dict]) -> str:
|
||||
|
||||
cap_list = []
|
||||
for cap in capabilities:
|
||||
cap_list.append(
|
||||
f"• {cap.name} - {cap.description} (domains: {', '.join(cap.domains)})"
|
||||
)
|
||||
cap_list.append(f"• {cap.name} - {cap.description} (domains: {', '.join(cap.domains)})")
|
||||
capabilities_text = "\n".join(cap_list)
|
||||
|
||||
# Format conversation history if present
|
||||
@@ -111,10 +109,10 @@ class StewardAgent:
|
||||
(preferred) and Ollama (fallback) backends via direct API calls.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize Steward with backend selection based on availability."""
|
||||
# Ollama config (primary)
|
||||
self.ollama_host = str(config.OLLAMA_HOST).rstrip('/')
|
||||
self.ollama_host = str(config.OLLAMA_HOST).rstrip("/")
|
||||
self.ollama_model = config.OLLAMA_DEFAULT_MODEL
|
||||
|
||||
# Claude config (fallback)
|
||||
@@ -139,6 +137,7 @@ class StewardAgent:
|
||||
"""Get or create Anthropic client (lazy initialization)."""
|
||||
if self._anthropic_client is None:
|
||||
from anthropic import AsyncAnthropic
|
||||
|
||||
self._anthropic_client = AsyncAnthropic(api_key=config.ANTHROPIC_API_KEY)
|
||||
return self._anthropic_client
|
||||
|
||||
@@ -167,20 +166,16 @@ class StewardAgent:
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": 0.3, # Lower = more consistent
|
||||
"top_p": 0.9
|
||||
}
|
||||
}
|
||||
"top_p": 0.9,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result["response"].strip()
|
||||
|
||||
async def analyze(
|
||||
self,
|
||||
query: str,
|
||||
conversation_history: Optional[list[dict]] = None
|
||||
) -> str:
|
||||
async def analyze(self, query: str, conversation_history: list[dict] | None = None) -> str:
|
||||
"""
|
||||
Analyze query and return plain text recommendation.
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ Steward agent schemas.
|
||||
Defines the structured output models for Steward's request analysis
|
||||
and capability recommendations.
|
||||
"""
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -16,16 +17,16 @@ class ConversationContext(BaseModel):
|
||||
The Steward analyzes the full conversation to identify references
|
||||
to previous topics, helping the Butler maintain context.
|
||||
"""
|
||||
|
||||
has_previous_context: bool = Field(
|
||||
description="Whether the current request references previous conversation turns"
|
||||
)
|
||||
relevant_turns: list[int] = Field(
|
||||
default_factory=list,
|
||||
description="0-indexed turn numbers that are relevant to the current request"
|
||||
description="0-indexed turn numbers that are relevant to the current request",
|
||||
)
|
||||
context_summary: str = Field(
|
||||
default="",
|
||||
description="Brief summary of relevant context for the Butler"
|
||||
default="", description="Brief summary of relevant context for the Butler"
|
||||
)
|
||||
|
||||
|
||||
@@ -40,29 +41,28 @@ class StewardRecommendation(BaseModel):
|
||||
- Conversation context
|
||||
- Missing capabilities (if any)
|
||||
"""
|
||||
|
||||
recommended_capabilities: list[str] = Field(
|
||||
description="List of household member names to include (e.g., ['tatlock_core'])"
|
||||
)
|
||||
reasoning: str = Field(
|
||||
description="Explanation of why these capabilities were recommended"
|
||||
)
|
||||
reasoning: str = Field(description="Explanation of why these capabilities were recommended")
|
||||
estimated_complexity: Literal["simple", "moderate", "complex"] = Field(
|
||||
description="Complexity assessment: simple (1 tool), moderate (2-3 tools), complex (multiple tools/steps)"
|
||||
)
|
||||
conversation_context: ConversationContext = Field(
|
||||
description="Contextual information from conversation history"
|
||||
)
|
||||
missing_capabilities: Optional[str] = Field(
|
||||
missing_capabilities: str | None = Field(
|
||||
default=None,
|
||||
description="Description of capabilities that would be helpful but aren't available"
|
||||
description="Description of capabilities that would be helpful but aren't available",
|
||||
)
|
||||
memory_context: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Pre-fetched user context from memory (profile, preferences)"
|
||||
description="Pre-fetched user context from memory (profile, preferences)",
|
||||
)
|
||||
enriched_query: str = Field(
|
||||
default="",
|
||||
description="User query with auto-filled context (location, timezone) when not specified"
|
||||
description="User query with auto-filled context (location, timezone) when not specified",
|
||||
)
|
||||
|
||||
def format_for_butler(self) -> str:
|
||||
@@ -114,8 +114,9 @@ class StewardRecommendation(BaseModel):
|
||||
lines.append(f" • preferences: {prefs_str}")
|
||||
|
||||
# Add delegation instructions when expert agents are recommended
|
||||
delegation_agents = [c for c in self.recommended_capabilities
|
||||
if c in ("biographer", "librarian")]
|
||||
delegation_agents = [
|
||||
c for c in self.recommended_capabilities if c in ("biographer", "librarian")
|
||||
]
|
||||
if delegation_agents:
|
||||
lines.append("-" * 40)
|
||||
lines.append("DELEGATION REQUIRED:")
|
||||
|
||||
+150
-59
@@ -7,47 +7,102 @@ and error handling.
|
||||
Parses plain text recommendations into structured data.
|
||||
Includes memory pre-fetch for user context injection.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
from src.core.household_registry import get_household_registry
|
||||
from src.core.logging_config import get_logger, log_operation
|
||||
from src.core.memory_service import memory_service
|
||||
|
||||
from .agent import get_steward_agent
|
||||
from .schemas import ConversationContext, StewardRecommendation
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
_DELEGATE_LINE_RE = re.compile(r"^[ \t]*DELEGATE:[ \t]*(.+)$", re.IGNORECASE | re.MULTILINE)
|
||||
|
||||
|
||||
def _mentions(needle: str, haystack: str) -> bool:
|
||||
"""Whole-word containment. Substring matching is what made this go wrong."""
|
||||
return re.search(rf"(?<!\w){re.escape(needle)}(?!\w)", haystack) is not None
|
||||
|
||||
|
||||
def _extract_capabilities(text: str) -> list[str]:
|
||||
"""
|
||||
Extract capability names from Steward's text response.
|
||||
Extract capability names from the Steward's declared delegation.
|
||||
|
||||
Uses keyword matching to find mentioned capabilities.
|
||||
The prompt instructs the Steward to answer in a fixed shape::
|
||||
|
||||
DELEGATE: <capability> to <action> <task>
|
||||
REASON: ...
|
||||
COMPLEXITY: ...
|
||||
CONTEXT: ...
|
||||
|
||||
Only the DELEGATE line states intent; the rest is free prose. An earlier
|
||||
version substring-matched capability *domains* across the whole response,
|
||||
which routed on ordinary English: "description" contains "script" and
|
||||
"discover" contains "cover" (both housekeeper domains), "acknowledge"
|
||||
contains "knowledge" and "know" (librarian, biographer), and "economy"
|
||||
contains "my" (biographer). Any REASON line could therefore summon agents
|
||||
the Steward never asked for, and a spurious librarian is a real
|
||||
multi-second web call.
|
||||
|
||||
It also made prose length a routing input, so anything that shortened the
|
||||
Steward's output — such as disabling model thinking — would look like it had
|
||||
improved routing.
|
||||
|
||||
Resolution is layered, most explicit first:
|
||||
1. a DELEGATE line beginning with a capability name — the documented shape
|
||||
2. a capability named anywhere on a DELEGATE line
|
||||
3. a capability *domain* on a DELEGATE line, for a loosely worded answer
|
||||
4. no DELEGATE line: capability names only, never domains
|
||||
|
||||
Args:
|
||||
text: Steward's plain text analysis
|
||||
|
||||
Returns:
|
||||
List of capability names (e.g., ['tatlock_core'])
|
||||
List of capability names (e.g. ['tatlock_core']), de-duplicated.
|
||||
"""
|
||||
text_lower = text.lower()
|
||||
registry = get_household_registry()
|
||||
capabilities = registry.get_all_capabilities()
|
||||
delegate_lines = [line.strip().lower() for line in _DELEGATE_LINE_RE.findall(text or "")]
|
||||
|
||||
found_caps = []
|
||||
found_caps: list[str] = []
|
||||
|
||||
for cap in capabilities:
|
||||
# Check if capability name is mentioned
|
||||
if cap.name.lower() in text_lower:
|
||||
found_caps.append(cap.name)
|
||||
def _add(name: str) -> None:
|
||||
if name not in found_caps:
|
||||
found_caps.append(name)
|
||||
|
||||
if not delegate_lines:
|
||||
# Either the Steward judged no capability necessary — the prompt's
|
||||
# conversational path, whose correct answer is [] — or it ignored the
|
||||
# format. Names only: domain words are ordinary English and would fire
|
||||
# on any prose, which is the bug described above.
|
||||
haystack = (text or "").lower()
|
||||
for cap in capabilities:
|
||||
if _mentions(cap.name.lower(), haystack):
|
||||
_add(cap.name)
|
||||
return found_caps
|
||||
|
||||
for line in delegate_lines:
|
||||
leading = next((c for c in capabilities if line.startswith(c.name.lower())), None)
|
||||
if leading is not None:
|
||||
_add(leading.name)
|
||||
continue
|
||||
|
||||
# Check if any domains are mentioned
|
||||
for domain in cap.domains:
|
||||
if domain.lower() in text_lower:
|
||||
found_caps.append(cap.name)
|
||||
break
|
||||
named = [c for c in capabilities if _mentions(c.name.lower(), line)]
|
||||
if named:
|
||||
for cap in named:
|
||||
_add(cap.name)
|
||||
continue
|
||||
|
||||
# Last resort. Scoped to this line, so the REASON and CONTEXT prose that
|
||||
# caused the original misrouting can no longer reach it.
|
||||
for cap in capabilities:
|
||||
if any(_mentions(domain.lower(), line) for domain in cap.domains):
|
||||
_add(cap.name)
|
||||
|
||||
return found_caps
|
||||
|
||||
@@ -73,8 +128,7 @@ def _extract_complexity(text: str) -> str:
|
||||
|
||||
|
||||
def _extract_conversation_context(
|
||||
text: str,
|
||||
conversation_history: list[dict]
|
||||
text: str, conversation_history: list[dict]
|
||||
) -> ConversationContext:
|
||||
"""
|
||||
Extract conversation context analysis from text.
|
||||
@@ -89,13 +143,15 @@ def _extract_conversation_context(
|
||||
text_lower = text.lower()
|
||||
|
||||
# Check if conversation history is referenced
|
||||
has_context = bool(conversation_history) and any([
|
||||
"previous" in text_lower,
|
||||
"earlier" in text_lower,
|
||||
"context" in text_lower,
|
||||
"turn" in text_lower,
|
||||
"history" in text_lower,
|
||||
])
|
||||
has_context = bool(conversation_history) and any(
|
||||
[
|
||||
"previous" in text_lower,
|
||||
"earlier" in text_lower,
|
||||
"context" in text_lower,
|
||||
"turn" in text_lower,
|
||||
"history" in text_lower,
|
||||
]
|
||||
)
|
||||
|
||||
# Extract turn numbers if mentioned (e.g., "turn 0", "turn 1")
|
||||
relevant_turns = []
|
||||
@@ -107,21 +163,23 @@ def _extract_conversation_context(
|
||||
context_summary = ""
|
||||
if has_context:
|
||||
# Extract sentence(s) mentioning context
|
||||
sentences = text.split('.')
|
||||
context_sentences = [s for s in sentences if any(
|
||||
word in s.lower() for word in ["previous", "earlier", "context", "history"]
|
||||
)]
|
||||
sentences = text.split(".")
|
||||
context_sentences = [
|
||||
s
|
||||
for s in sentences
|
||||
if any(word in s.lower() for word in ["previous", "earlier", "context", "history"])
|
||||
]
|
||||
if context_sentences:
|
||||
context_summary = context_sentences[0].strip()
|
||||
|
||||
return ConversationContext(
|
||||
has_previous_context=has_context,
|
||||
relevant_turns=relevant_turns,
|
||||
context_summary=context_summary
|
||||
context_summary=context_summary,
|
||||
)
|
||||
|
||||
|
||||
def _extract_missing_capabilities(text: str) -> Optional[str]:
|
||||
def _extract_missing_capabilities(text: str) -> str | None:
|
||||
"""
|
||||
Extract missing capability notes from text.
|
||||
|
||||
@@ -134,15 +192,16 @@ def _extract_missing_capabilities(text: str) -> Optional[str]:
|
||||
text_lower = text.lower()
|
||||
|
||||
# Look for indicators of missing capabilities
|
||||
if any(word in text_lower for word in [
|
||||
"missing", "unavailable", "not available", "don't have", "doesn't have"
|
||||
]):
|
||||
if any(
|
||||
word in text_lower
|
||||
for word in ["missing", "unavailable", "not available", "don't have", "doesn't have"]
|
||||
):
|
||||
# Find the sentence mentioning missing capabilities
|
||||
sentences = text.split('.')
|
||||
sentences = text.split(".")
|
||||
for sentence in sentences:
|
||||
if any(word in sentence.lower() for word in [
|
||||
"missing", "unavailable", "not available"
|
||||
]):
|
||||
if any(
|
||||
word in sentence.lower() for word in ["missing", "unavailable", "not available"]
|
||||
):
|
||||
return sentence.strip()
|
||||
|
||||
return None
|
||||
@@ -183,7 +242,7 @@ def _build_enriched_query(user_request: str, memory_context: dict[str, Any]) ->
|
||||
# Check if location is needed and not specified
|
||||
location_keywords = ["weather", "temperature", "forecast", "nearby", "local", "here"]
|
||||
# Use word boundary pattern to avoid false positives like "at" in "what"
|
||||
location_prepositions = [r'\bin\b', r'\bat\b', r'\bnear\b', r'\baround\b', r'\bfor\b']
|
||||
location_prepositions = [r"\bin\b", r"\bat\b", r"\bnear\b", r"\baround\b", r"\bfor\b"]
|
||||
location_specified = any(re.search(p, request_lower) for p in location_prepositions)
|
||||
|
||||
if any(word in request_lower for word in location_keywords):
|
||||
@@ -234,32 +293,65 @@ async def _prefetch_memory_context(user_request: str) -> dict[str, Any]:
|
||||
profile_keys = []
|
||||
|
||||
# Location-related queries
|
||||
if any(word in request_lower for word in [
|
||||
"weather", "temperature", "forecast", "nearby", "local",
|
||||
"directions", "distance", "map", "here",
|
||||
# Direct location questions
|
||||
"live", "where", "home", "reside", "location", "address",
|
||||
]):
|
||||
if any(
|
||||
word in request_lower
|
||||
for word in [
|
||||
"weather",
|
||||
"temperature",
|
||||
"forecast",
|
||||
"nearby",
|
||||
"local",
|
||||
"directions",
|
||||
"distance",
|
||||
"map",
|
||||
"here",
|
||||
# Direct location questions
|
||||
"live",
|
||||
"where",
|
||||
"home",
|
||||
"reside",
|
||||
"location",
|
||||
"address",
|
||||
]
|
||||
):
|
||||
profile_keys.append("location")
|
||||
|
||||
# Time-related queries
|
||||
if any(word in request_lower for word in [
|
||||
"time", "schedule", "meeting", "appointment", "reminder",
|
||||
"alarm", "when", "today", "tomorrow"
|
||||
]):
|
||||
if any(
|
||||
word in request_lower
|
||||
for word in [
|
||||
"time",
|
||||
"schedule",
|
||||
"meeting",
|
||||
"appointment",
|
||||
"reminder",
|
||||
"alarm",
|
||||
"when",
|
||||
"today",
|
||||
"tomorrow",
|
||||
]
|
||||
):
|
||||
profile_keys.append("timezone")
|
||||
|
||||
# Personal queries
|
||||
if any(word in request_lower for word in [
|
||||
"my name", "who am i", "about me"
|
||||
]):
|
||||
if any(word in request_lower for word in ["my name", "who am i", "about me"]):
|
||||
profile_keys.append("name")
|
||||
|
||||
# Always fetch preferences if they might affect response format
|
||||
include_preferences = any(word in request_lower for word in [
|
||||
"temperature", "weather", "convert", "unit", "format",
|
||||
"celsius", "fahrenheit", "metric", "imperial"
|
||||
])
|
||||
include_preferences = any(
|
||||
word in request_lower
|
||||
for word in [
|
||||
"temperature",
|
||||
"weather",
|
||||
"convert",
|
||||
"unit",
|
||||
"format",
|
||||
"celsius",
|
||||
"fahrenheit",
|
||||
"metric",
|
||||
"imperial",
|
||||
]
|
||||
)
|
||||
|
||||
try:
|
||||
return await memory_service.prefetch_context(
|
||||
@@ -278,7 +370,7 @@ async def _prefetch_memory_context(user_request: str) -> dict[str, Any]:
|
||||
async def analyze_request(
|
||||
user_request: str,
|
||||
conversation_history: list[dict],
|
||||
conversation_id: Optional[str] = None,
|
||||
conversation_id: str | None = None,
|
||||
) -> StewardRecommendation:
|
||||
"""
|
||||
Analyze user request with full conversation context.
|
||||
@@ -310,7 +402,7 @@ async def analyze_request(
|
||||
"request_preview": user_request[:100],
|
||||
"conversation_id": conversation_id,
|
||||
"history_length": len(conversation_history),
|
||||
}
|
||||
},
|
||||
) as log_ctx:
|
||||
try:
|
||||
# Pre-fetch user context from memory (fast, no LLM)
|
||||
@@ -329,8 +421,7 @@ async def analyze_request(
|
||||
|
||||
# Get plain text analysis from Steward
|
||||
analysis_text = await steward.analyze(
|
||||
user_request,
|
||||
conversation_history=conversation_history
|
||||
user_request, conversation_history=conversation_history
|
||||
)
|
||||
|
||||
# Parse plain text into structured recommendation
|
||||
|
||||
+115
-85
@@ -6,24 +6,25 @@ The agent embodies a witty, capable British butler personality.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from typing import AsyncGenerator, Any
|
||||
from collections.abc import AsyncGenerator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from pydantic_ai import Agent, RunContext
|
||||
|
||||
from src.agents.base import AgentInterface, OutputItem
|
||||
from src.agents.tatlock_core.tools import (
|
||||
calculate,
|
||||
get_current_datetime,
|
||||
calculate_time_offset,
|
||||
get_current_datetime,
|
||||
time_difference,
|
||||
)
|
||||
from src.core.config import config
|
||||
from src.core.logging_config import get_logger
|
||||
from src.core.tracing import (
|
||||
start_span, end_span, get_current_span,
|
||||
SpanType,
|
||||
add_tool_spans_from_messages,
|
||||
SpanType, SpanStatus,
|
||||
end_span,
|
||||
start_span,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -32,9 +33,10 @@ logger = get_logger(__name__)
|
||||
@dataclass
|
||||
class ToolCallTracker:
|
||||
"""Tracks tool calls for reporting to reasoning output."""
|
||||
|
||||
calls: list[str] = field(default_factory=list)
|
||||
|
||||
def log_call(self, message: str):
|
||||
def log_call(self, message: str) -> None:
|
||||
"""Log a tool call."""
|
||||
self.calls.append(message)
|
||||
|
||||
@@ -153,11 +155,14 @@ class TatlockAgent(AgentInterface):
|
||||
currently in Phase 1 (basic LLM integration without expert agents).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize Tatlock (lazy agent creation)."""
|
||||
self._agent = None # Lazy initialization
|
||||
# Deps are a ToolCallTracker: every registered tool takes
|
||||
# RunContext[ToolCallTracker], and run() is called with one. Saying so
|
||||
# is what lets the tool registrations below type-check at all.
|
||||
self._agent: Agent[ToolCallTracker, str] | None = None # Lazy initialization
|
||||
|
||||
def _ensure_agent(self):
|
||||
def _ensure_agent(self) -> None:
|
||||
"""Ensure the PydanticAI agent is initialized (lazy initialization)."""
|
||||
if self._agent is not None:
|
||||
return
|
||||
@@ -178,13 +183,19 @@ class TatlockAgent(AgentInterface):
|
||||
self._agent = Agent(
|
||||
model,
|
||||
system_prompt=TATLOCK_SYSTEM_PROMPT,
|
||||
deps_type=ToolCallTracker,
|
||||
)
|
||||
|
||||
# Register tools with the agent
|
||||
self._register_tools()
|
||||
|
||||
def _register_tools(self):
|
||||
"""Register permanent tools with the PydanticAI agent."""
|
||||
def _register_tools(self) -> None:
|
||||
"""Register permanent tools with the PydanticAI agent.
|
||||
|
||||
Called only from _ensure_agent, immediately after the agent is built, so
|
||||
the assert documents an invariant rather than guarding a real case.
|
||||
"""
|
||||
assert self._agent is not None, "_register_tools called before the agent exists"
|
||||
|
||||
# Calculator tool
|
||||
@self._agent.tool
|
||||
@@ -239,7 +250,9 @@ class TatlockAgent(AgentInterface):
|
||||
|
||||
# Time difference calculator
|
||||
@self._agent.tool
|
||||
def calculate_time_difference(ctx: RunContext[ToolCallTracker], date1_str: str, date2_str: str = "now") -> str:
|
||||
def calculate_time_difference(
|
||||
ctx: RunContext[ToolCallTracker], date1_str: str, date2_str: str = "now"
|
||||
) -> str:
|
||||
"""
|
||||
Calculate the difference between two dates.
|
||||
|
||||
@@ -251,7 +264,9 @@ class TatlockAgent(AgentInterface):
|
||||
Human-readable description of the time difference
|
||||
"""
|
||||
if ctx.deps:
|
||||
ctx.deps.log_call(f"🕐 Calculating time difference between {date1_str} and {date2_str}")
|
||||
ctx.deps.log_call(
|
||||
f"🕐 Calculating time difference between {date1_str} and {date2_str}"
|
||||
)
|
||||
return time_difference(date1_str, date2_str)
|
||||
|
||||
# NOTE: Web search has been moved to The Librarian agent.
|
||||
@@ -271,7 +286,7 @@ class TatlockAgent(AgentInterface):
|
||||
temperature: float = 1.0,
|
||||
max_tokens: int | None = None,
|
||||
stop: list[str] | None = None,
|
||||
**kwargs: Any
|
||||
**kwargs: Any,
|
||||
) -> AsyncGenerator[OutputItem, None]:
|
||||
"""
|
||||
Generate response using PydanticAI with Ollama.
|
||||
@@ -305,20 +320,28 @@ class TatlockAgent(AgentInterface):
|
||||
type="message",
|
||||
id=f"msg_{generate_id()}",
|
||||
role="assistant",
|
||||
content=[{
|
||||
"type": "output_text",
|
||||
"text": "I'm afraid I didn't receive a message, sir. How may I assist you?",
|
||||
"annotations": []
|
||||
}],
|
||||
status="completed"
|
||||
content=[
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "I'm afraid I didn't receive a message, sir. How may I assist you?",
|
||||
"annotations": [],
|
||||
}
|
||||
],
|
||||
status="completed",
|
||||
)
|
||||
return
|
||||
|
||||
# Build message history (all messages except the last user message)
|
||||
# PydanticAI expects history as list of ModelRequest/ModelResponse objects
|
||||
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
|
||||
from pydantic_ai.messages import (
|
||||
ModelMessage,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
TextPart,
|
||||
UserPromptPart,
|
||||
)
|
||||
|
||||
message_history = []
|
||||
message_history: list[ModelMessage] = []
|
||||
for i, msg in enumerate(messages[:-1]): # All messages except the last one
|
||||
role = msg.get("role")
|
||||
content = msg.get("content", "")
|
||||
@@ -330,7 +353,9 @@ class TatlockAgent(AgentInterface):
|
||||
|
||||
# Debug: Check for problematic content
|
||||
if '"' in content or "'" in content:
|
||||
logger.debug(f"Message {i} ({role}) contains quotes. Content preview: {content[:100]}...")
|
||||
logger.debug(
|
||||
f"Message {i} ({role}) contains quotes. Content preview: {content[:100]}..."
|
||||
)
|
||||
|
||||
# Convert to PydanticAI message format
|
||||
try:
|
||||
@@ -339,9 +364,7 @@ class TatlockAgent(AgentInterface):
|
||||
ModelRequest(parts=[UserPromptPart(content=content)])
|
||||
)
|
||||
elif role == "assistant":
|
||||
message_history.append(
|
||||
ModelResponse(parts=[TextPart(content=content)])
|
||||
)
|
||||
message_history.append(ModelResponse(parts=[TextPart(content=content)]))
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating message history item {i}: {e}")
|
||||
logger.error(f"Problematic content: {repr(content)}")
|
||||
@@ -352,7 +375,9 @@ class TatlockAgent(AgentInterface):
|
||||
if message_history:
|
||||
for i, hist_msg in enumerate(message_history):
|
||||
msg_type = type(hist_msg).__name__
|
||||
content_preview = str(hist_msg.parts[0].content)[:50] if hist_msg.parts else "no parts"
|
||||
content_preview = (
|
||||
str(hist_msg.parts[0].content)[:50] if hist_msg.parts else "no parts"
|
||||
)
|
||||
logger.info(f" History[{i}]: {msg_type} - {content_preview}...")
|
||||
|
||||
# Generate reasoning output if requested
|
||||
@@ -362,10 +387,10 @@ class TatlockAgent(AgentInterface):
|
||||
id=f"reasoning_{generate_id()}",
|
||||
summary=[
|
||||
"Analyzing your request, sir...",
|
||||
"Formulating response based on available knowledge..."
|
||||
"Formulating response based on available knowledge...",
|
||||
],
|
||||
thinking="", # PydanticAI doesn't expose internal reasoning yet
|
||||
status="completed"
|
||||
status="completed",
|
||||
)
|
||||
|
||||
# Create a tool call tracker for this request
|
||||
@@ -382,7 +407,7 @@ class TatlockAgent(AgentInterface):
|
||||
result = await self.agent.run(
|
||||
user_message,
|
||||
message_history=message_history if message_history else None,
|
||||
deps=tracker
|
||||
deps=tracker,
|
||||
)
|
||||
final_text = result.output
|
||||
|
||||
@@ -393,7 +418,7 @@ class TatlockAgent(AgentInterface):
|
||||
id=f"reasoning_tools_{generate_id()}",
|
||||
summary=tracker.calls,
|
||||
thinking="",
|
||||
status="completed"
|
||||
status="completed",
|
||||
)
|
||||
|
||||
# Yield the complete message
|
||||
@@ -402,12 +427,8 @@ class TatlockAgent(AgentInterface):
|
||||
type="message",
|
||||
id=msg_id,
|
||||
role="assistant",
|
||||
content=[{
|
||||
"type": "output_text",
|
||||
"text": final_text,
|
||||
"annotations": []
|
||||
}],
|
||||
status="completed"
|
||||
content=[{"type": "output_text", "text": final_text, "annotations": []}],
|
||||
status="completed",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -416,12 +437,14 @@ class TatlockAgent(AgentInterface):
|
||||
type="message",
|
||||
id=f"msg_{generate_id()}",
|
||||
role="assistant",
|
||||
content=[{
|
||||
"type": "output_text",
|
||||
"text": f"My apologies, sir. I encountered an error: {str(e)}",
|
||||
"annotations": []
|
||||
}],
|
||||
status="failed"
|
||||
content=[
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": f"My apologies, sir. I encountered an error: {str(e)}",
|
||||
"annotations": [],
|
||||
}
|
||||
],
|
||||
status="failed",
|
||||
)
|
||||
|
||||
async def supports_tools(self) -> bool:
|
||||
@@ -490,9 +513,15 @@ class TatlockAgent(AgentInterface):
|
||||
enriched_message = f"{steward_note}\n\n{user_message}"
|
||||
|
||||
# Convert message history to PydanticAI format
|
||||
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
|
||||
from pydantic_ai.messages import (
|
||||
ModelMessage,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
TextPart,
|
||||
UserPromptPart,
|
||||
)
|
||||
|
||||
pydantic_history = []
|
||||
pydantic_history: list[ModelMessage] = []
|
||||
for msg in message_history:
|
||||
role = msg.get("role")
|
||||
content = msg.get("content", "")
|
||||
@@ -501,17 +530,14 @@ class TatlockAgent(AgentInterface):
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
pydantic_history.append(
|
||||
ModelRequest(parts=[UserPromptPart(content=content)])
|
||||
)
|
||||
pydantic_history.append(ModelRequest(parts=[UserPromptPart(content=content)]))
|
||||
elif role == "assistant":
|
||||
pydantic_history.append(
|
||||
ModelResponse(parts=[TextPart(content=content)])
|
||||
)
|
||||
pydantic_history.append(ModelResponse(parts=[TextPart(content=content)]))
|
||||
|
||||
# Run with scoped tools and tracker
|
||||
# Force tool_choice to make LLM actually call tools
|
||||
from src.anthropic.model_selector import get_tool_choice_settings
|
||||
|
||||
result = await scoped_agent.run(
|
||||
enriched_message,
|
||||
message_history=pydantic_history if pydantic_history else None,
|
||||
@@ -575,9 +601,15 @@ class TatlockAgent(AgentInterface):
|
||||
enriched_message = f"{steward_note}\n\n{user_message}"
|
||||
|
||||
# Convert message history to PydanticAI format
|
||||
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
|
||||
from pydantic_ai.messages import (
|
||||
ModelMessage,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
TextPart,
|
||||
UserPromptPart,
|
||||
)
|
||||
|
||||
pydantic_history = []
|
||||
pydantic_history: list[ModelMessage] = []
|
||||
for msg in message_history:
|
||||
role = msg.get("role")
|
||||
content = msg.get("content", "")
|
||||
@@ -586,13 +618,9 @@ class TatlockAgent(AgentInterface):
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
pydantic_history.append(
|
||||
ModelRequest(parts=[UserPromptPart(content=content)])
|
||||
)
|
||||
pydantic_history.append(ModelRequest(parts=[UserPromptPart(content=content)]))
|
||||
elif role == "assistant":
|
||||
pydantic_history.append(
|
||||
ModelResponse(parts=[TextPart(content=content)])
|
||||
)
|
||||
pydantic_history.append(ModelResponse(parts=[TextPart(content=content)]))
|
||||
|
||||
# Use run() instead of run_stream() to avoid Ollama 400 bug
|
||||
# with streaming + tool calls (PydanticAI issues #1292, #2256)
|
||||
@@ -600,7 +628,7 @@ class TatlockAgent(AgentInterface):
|
||||
result = await scoped_agent.run(
|
||||
enriched_message,
|
||||
message_history=pydantic_history if pydantic_history else None,
|
||||
deps=tool_tracker
|
||||
deps=tool_tracker,
|
||||
)
|
||||
|
||||
# Stream the final response in chunks to maintain UX
|
||||
@@ -608,7 +636,7 @@ class TatlockAgent(AgentInterface):
|
||||
chunk_size = 50 # characters per chunk
|
||||
|
||||
for i in range(0, len(response_text), chunk_size):
|
||||
yield response_text[i:i + chunk_size]
|
||||
yield response_text[i : i + chunk_size]
|
||||
|
||||
logger.info("tatlock_scoped_run_complete")
|
||||
|
||||
@@ -641,13 +669,15 @@ class TatlockAgent(AgentInterface):
|
||||
- raw_output: The agent's raw text output
|
||||
"""
|
||||
from pydantic_ai.messages import (
|
||||
ModelMessage,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
UserPromptPart,
|
||||
TextPart,
|
||||
ToolCallPart,
|
||||
ToolReturnPart,
|
||||
UserPromptPart,
|
||||
)
|
||||
|
||||
from src.anthropic.model_selector import get_model
|
||||
|
||||
logger.info(
|
||||
@@ -663,7 +693,7 @@ class TatlockAgent(AgentInterface):
|
||||
SpanType.TATLOCK,
|
||||
metadata={
|
||||
"scoped_tool_count": len(scoped_tools),
|
||||
"tool_names": [getattr(t, '__name__', str(t)) for t in scoped_tools[:5]],
|
||||
"tool_names": [getattr(t, "__name__", str(t)) for t in scoped_tools[:5]],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -681,7 +711,7 @@ class TatlockAgent(AgentInterface):
|
||||
enriched_message = f"{steward_note}\n\n{user_message}"
|
||||
|
||||
# Convert message history to PydanticAI format
|
||||
pydantic_history = []
|
||||
pydantic_history: list[ModelMessage] = []
|
||||
for msg in message_history:
|
||||
role = msg.get("role")
|
||||
content = msg.get("content", "")
|
||||
@@ -690,16 +720,13 @@ class TatlockAgent(AgentInterface):
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
pydantic_history.append(
|
||||
ModelRequest(parts=[UserPromptPart(content=content)])
|
||||
)
|
||||
pydantic_history.append(ModelRequest(parts=[UserPromptPart(content=content)]))
|
||||
elif role == "assistant":
|
||||
pydantic_history.append(
|
||||
ModelResponse(parts=[TextPart(content=content)])
|
||||
)
|
||||
pydantic_history.append(ModelResponse(parts=[TextPart(content=content)]))
|
||||
|
||||
# Run with scoped tools and tracker
|
||||
from src.anthropic.model_selector import get_tool_choice_settings
|
||||
|
||||
result = await scoped_agent.run(
|
||||
enriched_message,
|
||||
message_history=pydantic_history if pydantic_history else None,
|
||||
@@ -709,8 +736,8 @@ class TatlockAgent(AgentInterface):
|
||||
|
||||
# Extract tool calls and results from the agent's messages
|
||||
tools_called = []
|
||||
expert_results = {}
|
||||
tool_outputs = {}
|
||||
expert_results: dict[str, Any] = {}
|
||||
tool_outputs: dict[str, Any] = {}
|
||||
|
||||
# Parse through new messages to find tool calls and returns
|
||||
for msg in result.new_messages():
|
||||
@@ -782,7 +809,14 @@ class TatlockAgent(AgentInterface):
|
||||
Returns:
|
||||
str: Butler-toned response synthesized from all results
|
||||
"""
|
||||
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
|
||||
from pydantic_ai.messages import (
|
||||
ModelMessage,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
TextPart,
|
||||
UserPromptPart,
|
||||
)
|
||||
|
||||
from src.anthropic.model_selector import get_model
|
||||
|
||||
logger.info(
|
||||
@@ -840,7 +874,7 @@ class TatlockAgent(AgentInterface):
|
||||
)
|
||||
|
||||
# Convert message history to PydanticAI format
|
||||
pydantic_history = []
|
||||
pydantic_history: list[ModelMessage] = []
|
||||
for msg in message_history:
|
||||
role = msg.get("role")
|
||||
content = msg.get("content", "")
|
||||
@@ -849,13 +883,9 @@ class TatlockAgent(AgentInterface):
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
pydantic_history.append(
|
||||
ModelRequest(parts=[UserPromptPart(content=content)])
|
||||
)
|
||||
pydantic_history.append(ModelRequest(parts=[UserPromptPart(content=content)]))
|
||||
elif role == "assistant":
|
||||
pydantic_history.append(
|
||||
ModelResponse(parts=[TextPart(content=content)])
|
||||
)
|
||||
pydantic_history.append(ModelResponse(parts=[TextPart(content=content)]))
|
||||
|
||||
# Run synthesis
|
||||
result = await synthesis_agent.run(
|
||||
@@ -885,9 +915,9 @@ class TatlockAgent(AgentInterface):
|
||||
async def get_capabilities(self) -> dict:
|
||||
"""Return current capabilities."""
|
||||
return {
|
||||
"streaming": True, # Streaming implemented
|
||||
"reasoning": True, # Basic reasoning summaries
|
||||
"tools": True, # Permanent tools: calculator, date/time, search
|
||||
"vision": False, # Future
|
||||
"audio": False, # Future
|
||||
"streaming": True, # Streaming implemented
|
||||
"reasoning": True, # Basic reasoning summaries
|
||||
"tools": True, # Permanent tools: calculator, date/time, search
|
||||
"vision": False, # Future
|
||||
"audio": False, # Future
|
||||
}
|
||||
|
||||
@@ -5,14 +5,15 @@ Provides calculator and date/time capabilities.
|
||||
Web search has been moved to The Librarian agent.
|
||||
Organized as a household member with toolset and capability registration.
|
||||
"""
|
||||
|
||||
from .capability import TATLOCK_CORE_CAPABILITY, get_capability
|
||||
from .toolset import get_core_tools, tatlock_core_tools
|
||||
from .tools import (
|
||||
calculate,
|
||||
calculate_time_offset,
|
||||
get_current_datetime,
|
||||
time_difference,
|
||||
)
|
||||
from .toolset import get_core_tools, tatlock_core_tools
|
||||
|
||||
__all__ = [
|
||||
# Tools
|
||||
|
||||
@@ -4,8 +4,8 @@ Household capability definition for Tatlock's core tools.
|
||||
Provides the executive summary that the Steward and Butler see
|
||||
for coordinating household capabilities.
|
||||
"""
|
||||
from src.core.household_registry import HouseholdCapability
|
||||
|
||||
from src.core.household_registry import HouseholdCapability
|
||||
|
||||
TATLOCK_CORE_CAPABILITY = HouseholdCapability(
|
||||
name="tatlock_core",
|
||||
|
||||
@@ -6,13 +6,11 @@ These tools are always available to the butler agent:
|
||||
- Date/Time toolkit: For current time and time calculations
|
||||
- SearXNG search: For searching the web for current information
|
||||
"""
|
||||
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.config import config
|
||||
from src.core.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -22,6 +20,7 @@ logger = get_logger(__name__)
|
||||
# Calculator Tool
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def calculate(expression: str) -> str:
|
||||
"""
|
||||
Safely evaluate mathematical expressions.
|
||||
@@ -50,33 +49,29 @@ def calculate(expression: str) -> str:
|
||||
# Create safe namespace with math functions
|
||||
safe_dict = {
|
||||
# Basic math functions
|
||||
'sqrt': math.sqrt,
|
||||
'pow': math.pow,
|
||||
'abs': abs,
|
||||
'round': round,
|
||||
|
||||
"sqrt": math.sqrt,
|
||||
"pow": math.pow,
|
||||
"abs": abs,
|
||||
"round": round,
|
||||
# Trigonometric
|
||||
'sin': math.sin,
|
||||
'cos': math.cos,
|
||||
'tan': math.tan,
|
||||
'asin': math.asin,
|
||||
'acos': math.acos,
|
||||
'atan': math.atan,
|
||||
|
||||
"sin": math.sin,
|
||||
"cos": math.cos,
|
||||
"tan": math.tan,
|
||||
"asin": math.asin,
|
||||
"acos": math.acos,
|
||||
"atan": math.atan,
|
||||
# Logarithmic
|
||||
'log': math.log,
|
||||
'log10': math.log10,
|
||||
'log2': math.log2,
|
||||
'exp': math.exp,
|
||||
|
||||
"log": math.log,
|
||||
"log10": math.log10,
|
||||
"log2": math.log2,
|
||||
"exp": math.exp,
|
||||
# Other
|
||||
'ceil': math.ceil,
|
||||
'floor': math.floor,
|
||||
'factorial': math.factorial,
|
||||
|
||||
"ceil": math.ceil,
|
||||
"floor": math.floor,
|
||||
"factorial": math.factorial,
|
||||
# Constants
|
||||
'pi': math.pi,
|
||||
'e': math.e,
|
||||
"pi": math.pi,
|
||||
"e": math.e,
|
||||
}
|
||||
|
||||
# Evaluate the expression safely
|
||||
@@ -101,6 +96,7 @@ def calculate(expression: str) -> str:
|
||||
# Date/Time Toolkit
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_current_datetime(format_str: str = "full") -> str:
|
||||
"""
|
||||
Get the current date and time.
|
||||
@@ -161,7 +157,7 @@ def calculate_time_offset(offset_description: str) -> str:
|
||||
|
||||
# Parse the offset description
|
||||
# Pattern: "N unit(s) ago/from now"
|
||||
pattern = r'(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+(ago|from\s+now)'
|
||||
pattern = r"(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+(ago|from\s+now)"
|
||||
match = re.match(pattern, offset_description.lower().strip())
|
||||
|
||||
if not match:
|
||||
|
||||
@@ -4,11 +4,11 @@ PydanticAI toolset for Tatlock's core tools.
|
||||
Converts the core tool functions into PydanticAI tool definitions
|
||||
that can be registered with agents and the household registry.
|
||||
"""
|
||||
|
||||
from pydantic_ai.tools import Tool
|
||||
|
||||
from . import tools
|
||||
|
||||
|
||||
# Create tool definitions for PydanticAI
|
||||
calculator_tool = Tool(
|
||||
function=tools.calculate,
|
||||
|
||||
+22
-25
@@ -13,11 +13,11 @@ import math
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Calculator Tool
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def calculate(expression: str) -> str:
|
||||
"""
|
||||
Safely evaluate mathematical expressions.
|
||||
@@ -46,33 +46,29 @@ def calculate(expression: str) -> str:
|
||||
# Create safe namespace with math functions
|
||||
safe_dict = {
|
||||
# Basic math functions
|
||||
'sqrt': math.sqrt,
|
||||
'pow': math.pow,
|
||||
'abs': abs,
|
||||
'round': round,
|
||||
|
||||
"sqrt": math.sqrt,
|
||||
"pow": math.pow,
|
||||
"abs": abs,
|
||||
"round": round,
|
||||
# Trigonometric
|
||||
'sin': math.sin,
|
||||
'cos': math.cos,
|
||||
'tan': math.tan,
|
||||
'asin': math.asin,
|
||||
'acos': math.acos,
|
||||
'atan': math.atan,
|
||||
|
||||
"sin": math.sin,
|
||||
"cos": math.cos,
|
||||
"tan": math.tan,
|
||||
"asin": math.asin,
|
||||
"acos": math.acos,
|
||||
"atan": math.atan,
|
||||
# Logarithmic
|
||||
'log': math.log,
|
||||
'log10': math.log10,
|
||||
'log2': math.log2,
|
||||
'exp': math.exp,
|
||||
|
||||
"log": math.log,
|
||||
"log10": math.log10,
|
||||
"log2": math.log2,
|
||||
"exp": math.exp,
|
||||
# Other
|
||||
'ceil': math.ceil,
|
||||
'floor': math.floor,
|
||||
'factorial': math.factorial,
|
||||
|
||||
"ceil": math.ceil,
|
||||
"floor": math.floor,
|
||||
"factorial": math.factorial,
|
||||
# Constants
|
||||
'pi': math.pi,
|
||||
'e': math.e,
|
||||
"pi": math.pi,
|
||||
"e": math.e,
|
||||
}
|
||||
|
||||
# Evaluate the expression safely
|
||||
@@ -97,6 +93,7 @@ def calculate(expression: str) -> str:
|
||||
# Date/Time Toolkit
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_current_datetime(format_str: str = "full") -> str:
|
||||
"""
|
||||
Get the current date and time.
|
||||
@@ -157,7 +154,7 @@ def calculate_time_offset(offset_description: str) -> str:
|
||||
|
||||
# Parse the offset description
|
||||
# Pattern: "N unit(s) ago/from now"
|
||||
pattern = r'(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+(ago|from\s+now)'
|
||||
pattern = r"(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+(ago|from\s+now)"
|
||||
match = re.match(pattern, offset_description.lower().strip())
|
||||
|
||||
if not match:
|
||||
|
||||
+2
-1
@@ -2,9 +2,10 @@
|
||||
Chat completion router.
|
||||
OpenAI-compatible /v1/chat/completions endpoint.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import APIRouter
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
OpenAI-compatible chat completion schemas.
|
||||
Following OpenAI API specification for compatibility.
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
@@ -11,6 +12,7 @@ from src.core.models import CustomBaseModel
|
||||
|
||||
class ChatMessage(CustomBaseModel):
|
||||
"""OpenAI-compatible chat message."""
|
||||
|
||||
role: Literal["system", "user", "assistant"]
|
||||
content: str
|
||||
name: str | None = None
|
||||
@@ -18,6 +20,7 @@ class ChatMessage(CustomBaseModel):
|
||||
|
||||
class ChatCompletionRequest(CustomBaseModel):
|
||||
"""OpenAI-compatible chat completion request."""
|
||||
|
||||
model: str = Field(..., description="Model to use for completion")
|
||||
messages: list[ChatMessage] = Field(..., description="List of messages")
|
||||
temperature: float | None = Field(default=0.7, ge=0.0, le=2.0)
|
||||
@@ -29,6 +32,7 @@ class ChatCompletionRequest(CustomBaseModel):
|
||||
|
||||
class ChatCompletionChoice(CustomBaseModel):
|
||||
"""Choice in chat completion response."""
|
||||
|
||||
index: int
|
||||
message: ChatMessage
|
||||
finish_reason: str | None
|
||||
@@ -36,6 +40,7 @@ class ChatCompletionChoice(CustomBaseModel):
|
||||
|
||||
class ChatCompletionUsage(CustomBaseModel):
|
||||
"""Token usage information."""
|
||||
|
||||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
total_tokens: int
|
||||
@@ -43,6 +48,7 @@ class ChatCompletionUsage(CustomBaseModel):
|
||||
|
||||
class ChatCompletionResponse(CustomBaseModel):
|
||||
"""OpenAI-compatible chat completion response."""
|
||||
|
||||
id: str
|
||||
object: str = "chat.completion"
|
||||
created: int
|
||||
@@ -53,6 +59,7 @@ class ChatCompletionResponse(CustomBaseModel):
|
||||
|
||||
class ChatCompletionChunkDelta(CustomBaseModel):
|
||||
"""Delta in streaming chunk."""
|
||||
|
||||
role: str | None = None
|
||||
content: str | None = None
|
||||
reasoning_content: str | None = None # For thinking/reasoning (DeepSeek R1 format)
|
||||
@@ -60,6 +67,7 @@ class ChatCompletionChunkDelta(CustomBaseModel):
|
||||
|
||||
class ChatCompletionChunkChoice(CustomBaseModel):
|
||||
"""Choice in streaming chunk."""
|
||||
|
||||
index: int
|
||||
delta: ChatCompletionChunkDelta
|
||||
finish_reason: str | None = None
|
||||
@@ -67,6 +75,7 @@ class ChatCompletionChunkChoice(CustomBaseModel):
|
||||
|
||||
class ChatCompletionChunk(CustomBaseModel):
|
||||
"""OpenAI-compatible streaming chunk."""
|
||||
|
||||
id: str
|
||||
object: str = "chat.completion.chunk"
|
||||
created: int
|
||||
|
||||
+13
-17
@@ -4,17 +4,17 @@ Chat completion service.
|
||||
Wrapper around Responses API that converts to Chat Completions format.
|
||||
Embeds reasoning in <think> tags for Open WebUI compatibility.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from typing import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from src.chat import constants
|
||||
from src.chat.schemas import (
|
||||
ChatCompletionChoice,
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionChunkChoice,
|
||||
ChatCompletionChunkDelta,
|
||||
ChatCompletionChoice,
|
||||
ChatCompletionRequest,
|
||||
ChatCompletionResponse,
|
||||
ChatCompletionUsage,
|
||||
@@ -43,10 +43,7 @@ async def create_chat_completion(
|
||||
created_at = int(time.time())
|
||||
|
||||
# Convert Chat request to Responses request
|
||||
input_messages = [
|
||||
{"role": msg.role, "content": msg.content}
|
||||
for msg in request.messages
|
||||
]
|
||||
input_messages = [{"role": msg.role, "content": msg.content} for msg in request.messages]
|
||||
|
||||
response_request = ResponseRequest(
|
||||
model=request.model,
|
||||
@@ -54,7 +51,9 @@ async def create_chat_completion(
|
||||
reasoning={"effort": "medium", "summary": "auto"}, # Enable reasoning
|
||||
temperature=request.temperature or 1.0,
|
||||
max_output_tokens=request.max_tokens,
|
||||
stop=request.stop if isinstance(request.stop, list) else ([request.stop] if request.stop else None),
|
||||
stop=request.stop
|
||||
if isinstance(request.stop, list)
|
||||
else ([request.stop] if request.stop else None),
|
||||
)
|
||||
|
||||
# Call Responses API (will use Steward for Tatlock)
|
||||
@@ -118,16 +117,13 @@ async def create_chat_completion_stream(
|
||||
Yields:
|
||||
Chat completion chunks with reasoning as <think> tags
|
||||
"""
|
||||
from src.responses.streaming import StreamingCoordinator, StreamEventType
|
||||
from src.responses.streaming import StreamEventType, StreamingCoordinator
|
||||
|
||||
completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
|
||||
created_at = int(time.time())
|
||||
|
||||
# Convert Chat request to Responses request
|
||||
input_messages = [
|
||||
{"role": msg.role, "content": msg.content}
|
||||
for msg in request.messages
|
||||
]
|
||||
input_messages = [{"role": msg.role, "content": msg.content} for msg in request.messages]
|
||||
|
||||
response_request = ResponseRequest(
|
||||
model=request.model,
|
||||
@@ -135,7 +131,9 @@ async def create_chat_completion_stream(
|
||||
reasoning={"effort": "medium", "summary": "auto"},
|
||||
temperature=request.temperature or 1.0,
|
||||
max_output_tokens=request.max_tokens,
|
||||
stop=request.stop if isinstance(request.stop, list) else ([request.stop] if request.stop else None),
|
||||
stop=request.stop
|
||||
if isinstance(request.stop, list)
|
||||
else ([request.stop] if request.stop else None),
|
||||
stream=True,
|
||||
)
|
||||
|
||||
@@ -163,7 +161,6 @@ async def create_chat_completion_stream(
|
||||
|
||||
# Stream from Responses API
|
||||
coordinator = StreamingCoordinator()
|
||||
in_reasoning = False
|
||||
|
||||
if use_steward:
|
||||
stream_generator = coordinator.stream_response_with_steward(response_request)
|
||||
@@ -174,7 +171,6 @@ async def create_chat_completion_stream(
|
||||
if event.event == StreamEventType.REASONING_SUMMARY_DELTA:
|
||||
# Stream reasoning via reasoning_content field (DeepSeek R1 format)
|
||||
# Open WebUI renders this as collapsible thinking block
|
||||
in_reasoning = True
|
||||
yield ChatCompletionChunk(
|
||||
id=completion_id,
|
||||
object=constants.CHAT_COMPLETION_CHUNK_OBJECT,
|
||||
@@ -191,7 +187,7 @@ async def create_chat_completion_stream(
|
||||
|
||||
elif event.event == StreamEventType.REASONING_SUMMARY_DONE:
|
||||
# Signal end of reasoning block (no content needed)
|
||||
in_reasoning = False
|
||||
pass # nothing downstream reads this; the event just ends the block
|
||||
|
||||
elif event.event == StreamEventType.OUTPUT_TEXT_DELTA:
|
||||
# Stream message content
|
||||
|
||||
+34
-86
@@ -2,6 +2,7 @@
|
||||
Global application configuration.
|
||||
Following best practice of splitting config across domains.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
@@ -42,6 +43,7 @@ def _get_version_from_pyproject() -> str:
|
||||
|
||||
class Environment(str, Enum):
|
||||
"""Application environment."""
|
||||
|
||||
DEVELOPMENT = "development"
|
||||
PRODUCTION = "production"
|
||||
TESTING = "testing"
|
||||
@@ -54,6 +56,7 @@ class Config(BaseSettings):
|
||||
Loads from environment variables and .env file.
|
||||
Domain-specific configs should be in their respective modules.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
@@ -74,143 +77,90 @@ class Config(BaseSettings):
|
||||
|
||||
# Anthropic Configuration (Claude - cloud fallback)
|
||||
ANTHROPIC_API_KEY: str | None = Field(
|
||||
default=None,
|
||||
description="Anthropic API key for the Claude fallback backend"
|
||||
default=None, description="Anthropic API key for the Claude fallback backend"
|
||||
)
|
||||
ANTHROPIC_MODEL: str = Field(
|
||||
default="claude-sonnet-5",
|
||||
description="Claude model for the fallback backend"
|
||||
default="claude-sonnet-5", description="Claude model for the fallback backend"
|
||||
)
|
||||
PREFER_CLOUD_BACKEND: bool = Field(
|
||||
default=False,
|
||||
description="Prefer Claude over Ollama (default: local-first)"
|
||||
default=False, description="Prefer Claude over Ollama (default: local-first)"
|
||||
)
|
||||
|
||||
# Ollama Configuration (local - primary backend)
|
||||
OLLAMA_HOST: HttpUrl = Field(
|
||||
default="http://localhost:11434",
|
||||
description="Ollama server URL"
|
||||
)
|
||||
OLLAMA_DEFAULT_MODEL: str = Field(
|
||||
default="gemma4:e2b",
|
||||
description="Default Ollama model"
|
||||
)
|
||||
OLLAMA_TIMEOUT: int = Field(
|
||||
default=120,
|
||||
description="Ollama request timeout in seconds"
|
||||
)
|
||||
OLLAMA_HOST: HttpUrl = Field(default="http://localhost:11434", description="Ollama server URL")
|
||||
OLLAMA_DEFAULT_MODEL: str = Field(default="gemma4:e2b", description="Default Ollama model")
|
||||
OLLAMA_TIMEOUT: int = Field(default=120, description="Ollama request timeout in seconds")
|
||||
STEWARD_TIMEOUT: int = Field(
|
||||
default=60,
|
||||
description="Steward analysis timeout in seconds (gemma4 needs ~35s warm)"
|
||||
default=60, description="Steward analysis timeout in seconds (gemma4 needs ~35s warm)"
|
||||
)
|
||||
STREAM_TIMEOUT: int = Field(
|
||||
default=20,
|
||||
description="Timeout for each streaming turn in seconds"
|
||||
default=20, description="Timeout for each streaming turn in seconds"
|
||||
)
|
||||
|
||||
# SearXNG Configuration
|
||||
SEARXNG_HOST: HttpUrl = Field(
|
||||
default="http://searxng:8080",
|
||||
description="SearXNG server URL (container name; internal port 8080)"
|
||||
)
|
||||
SEARXNG_TIMEOUT: int = Field(
|
||||
default=30,
|
||||
description="SearXNG request timeout in seconds"
|
||||
description="SearXNG server URL (container name; internal port 8080)",
|
||||
)
|
||||
SEARXNG_TIMEOUT: int = Field(default=30, description="SearXNG request timeout in seconds")
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_HOST: str = Field(
|
||||
default="localhost",
|
||||
description="Redis server host"
|
||||
)
|
||||
REDIS_PORT: int = Field(
|
||||
default=6379,
|
||||
description="Redis server port"
|
||||
)
|
||||
REDIS_TIMEOUT: int = Field(
|
||||
default=5,
|
||||
description="Redis connection timeout in seconds"
|
||||
)
|
||||
REDIS_HOST: str = Field(default="localhost", description="Redis server host")
|
||||
REDIS_PORT: int = Field(default=6379, description="Redis server port")
|
||||
REDIS_TIMEOUT: int = Field(default=5, description="Redis connection timeout in seconds")
|
||||
|
||||
# Library-Desk Configuration (The Librarian backend)
|
||||
LIBRARIAN_TIMEOUT: int = Field(
|
||||
default=180,
|
||||
description="Total time budget for a librarian delegation in seconds"
|
||||
default=180, description="Total time budget for a librarian delegation in seconds"
|
||||
)
|
||||
LIBRARY_DESK_HOST: HttpUrl = Field(
|
||||
default="http://library-desk:8089",
|
||||
description="Library-Desk API URL (container name; internal port 8089)"
|
||||
description="Library-Desk API URL (container name; internal port 8089)",
|
||||
)
|
||||
LIBRARY_DESK_API_KEY: str = Field(
|
||||
default="",
|
||||
description="API key for Library-Desk authentication"
|
||||
default="", description="API key for Library-Desk authentication"
|
||||
)
|
||||
LIBRARY_DESK_TIMEOUT: int = Field(
|
||||
default=60,
|
||||
description="Library-Desk request timeout in seconds"
|
||||
default=60, description="Library-Desk request timeout in seconds"
|
||||
)
|
||||
|
||||
# Core-API Configuration (The Housekeeper backend)
|
||||
CORE_API_HOST: HttpUrl = Field(
|
||||
default="http://core-api:8083",
|
||||
description="Core-API URL for Home Assistant integration (container name; internal port 8083)"
|
||||
)
|
||||
CORE_API_KEY: str = Field(
|
||||
default="",
|
||||
description="API key for Core-API authentication"
|
||||
)
|
||||
CORE_API_TIMEOUT: int = Field(
|
||||
default=30,
|
||||
description="Core-API request timeout in seconds"
|
||||
description="Core-API URL for Home Assistant integration (container name; internal port 8083)",
|
||||
)
|
||||
CORE_API_KEY: str = Field(default="", description="API key for Core-API authentication")
|
||||
CORE_API_TIMEOUT: int = Field(default=30, description="Core-API request timeout in seconds")
|
||||
|
||||
# Qdrant Configuration (Memory vector storage)
|
||||
QDRANT_HOST: str = Field(
|
||||
default="localhost",
|
||||
description="Qdrant server host"
|
||||
)
|
||||
QDRANT_PORT: int = Field(
|
||||
default=6333,
|
||||
description="Qdrant server port"
|
||||
)
|
||||
QDRANT_HOST: str = Field(default="localhost", description="Qdrant server host")
|
||||
QDRANT_PORT: int = Field(default=6333, description="Qdrant server port")
|
||||
QDRANT_EMBEDDING_DIM: int = Field(
|
||||
default=768,
|
||||
description="Embedding dimension (768 for nomic-embed-text)"
|
||||
default=768, description="Embedding dimension (768 for nomic-embed-text)"
|
||||
)
|
||||
|
||||
# Ollama Embedding Configuration
|
||||
OLLAMA_EMBEDDING_MODEL: str = Field(
|
||||
default="nomic-embed-text",
|
||||
description="Ollama model for embeddings"
|
||||
default="nomic-embed-text", description="Ollama model for embeddings"
|
||||
)
|
||||
|
||||
# Redis Memory Database
|
||||
REDIS_MEMORY_DB: int = Field(
|
||||
default=1,
|
||||
description="Redis database number for memory cache"
|
||||
)
|
||||
REDIS_MEMORY_TTL_HOURS: int = Field(
|
||||
default=24,
|
||||
description="TTL for session context in hours"
|
||||
)
|
||||
REDIS_MEMORY_DB: int = Field(default=1, description="Redis database number for memory cache")
|
||||
REDIS_MEMORY_TTL_HOURS: int = Field(default=24, description="TTL for session context in hours")
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL: str | None = Field(
|
||||
default=None,
|
||||
description="Logging level (auto-set based on environment if not specified)"
|
||||
default=None, description="Logging level (auto-set based on environment if not specified)"
|
||||
)
|
||||
|
||||
# User Configuration
|
||||
DEFAULT_USER: str | None = Field(
|
||||
default=None,
|
||||
description="Default user for single-user setup (auto-set based on environment if not specified)"
|
||||
description="Default user for single-user setup (auto-set based on environment if not specified)",
|
||||
)
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: list[str] = Field(
|
||||
default=["*"],
|
||||
description="Allowed CORS origins"
|
||||
)
|
||||
CORS_ORIGINS: list[str] = Field(default=["*"], description="Allowed CORS origins")
|
||||
CORS_ALLOW_CREDENTIALS: bool = True
|
||||
CORS_ALLOW_METHODS: list[str] = ["*"]
|
||||
CORS_ALLOW_HEADERS: list[str] = ["*"]
|
||||
@@ -235,8 +185,7 @@ class Config(BaseSettings):
|
||||
if (
|
||||
self.ENVIRONMENT != Environment.PRODUCTION
|
||||
and self.DEFAULT_USER is not None
|
||||
and sanitize_user_id(self.DEFAULT_USER)
|
||||
== sanitize_user_id(PRODUCTION_TENANT)
|
||||
and sanitize_user_id(self.DEFAULT_USER) == sanitize_user_id(PRODUCTION_TENANT)
|
||||
):
|
||||
raise ValueError(
|
||||
f"Refusing to start: ENVIRONMENT={self.ENVIRONMENT.value} is "
|
||||
@@ -299,8 +248,7 @@ class Config(BaseSettings):
|
||||
return self.DEFAULT_USER or PRODUCTION_TENANT
|
||||
|
||||
if self.DEFAULT_USER is not None and (
|
||||
self.DEFAULT_USER == TEST_TENANT
|
||||
or self.DEFAULT_USER.startswith(TEST_TENANT_PREFIX)
|
||||
self.DEFAULT_USER == TEST_TENANT or self.DEFAULT_USER.startswith(TEST_TENANT_PREFIX)
|
||||
):
|
||||
return self.DEFAULT_USER
|
||||
return TEST_TENANT
|
||||
|
||||
+18
-8
@@ -16,7 +16,9 @@ Usage:
|
||||
from src.core.context import get_user
|
||||
user = get_user() # Returns current request's user
|
||||
"""
|
||||
|
||||
from contextvars import ContextVar
|
||||
from types import TracebackType
|
||||
|
||||
|
||||
def get_default_user() -> str:
|
||||
@@ -28,6 +30,7 @@ def get_default_user() -> str:
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from src.core.config import config
|
||||
|
||||
return config.effective_default_user
|
||||
|
||||
|
||||
@@ -36,9 +39,7 @@ def get_default_user() -> str:
|
||||
# and resolve the real default in get_user()
|
||||
_USER_NOT_SET = "__user_not_set__"
|
||||
current_user: ContextVar[str] = ContextVar("current_user", default=_USER_NOT_SET)
|
||||
current_conversation: ContextVar[str | None] = ContextVar(
|
||||
"current_conversation", default=None
|
||||
)
|
||||
current_conversation: ContextVar[str | None] = ContextVar("current_conversation", default=None)
|
||||
|
||||
|
||||
def apply_tenant_guard(user: str) -> str:
|
||||
@@ -60,9 +61,8 @@ def apply_tenant_guard(user: str) -> str:
|
||||
from src.core.config import PRODUCTION_TENANT, TEST_TENANT, Environment, config
|
||||
from src.core.multi_tenancy import sanitize_user_id
|
||||
|
||||
if (
|
||||
config.ENVIRONMENT != Environment.PRODUCTION
|
||||
and sanitize_user_id(user) == sanitize_user_id(PRODUCTION_TENANT)
|
||||
if config.ENVIRONMENT != Environment.PRODUCTION and sanitize_user_id(user) == sanitize_user_id(
|
||||
PRODUCTION_TENANT
|
||||
):
|
||||
from src.core.logging_config import get_logger
|
||||
|
||||
@@ -143,7 +143,12 @@ class RequestContext:
|
||||
self._conv_token = current_conversation.set(self.conversation_id)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: TracebackType | None,
|
||||
) -> None:
|
||||
"""Reset context variables on exit."""
|
||||
if self._user_token is not None:
|
||||
current_user.reset(self._user_token)
|
||||
@@ -156,7 +161,12 @@ class RequestContext:
|
||||
self._conv_token = current_conversation.set(self.conversation_id)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: TracebackType | None,
|
||||
) -> None:
|
||||
"""Sync context manager exit."""
|
||||
if self._user_token is not None:
|
||||
current_user.reset(self._user_token)
|
||||
|
||||
@@ -8,7 +8,8 @@ Provides async embedding operations via Ollama API:
|
||||
|
||||
Adapted from library-desk patterns.
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from types import TracebackType
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -73,7 +74,12 @@ class OllamaEmbeddingClient:
|
||||
await self._get_client()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: TracebackType | None,
|
||||
) -> None:
|
||||
"""Async context manager exit."""
|
||||
await self.close()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Global exception definitions.
|
||||
Domain-specific exceptions should be in their respective modules.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -41,7 +42,7 @@ class ModelNotFoundError(AppException):
|
||||
super().__init__(
|
||||
message=f"Model '{model_name}' not found",
|
||||
status_code=404,
|
||||
details={"model": model_name}
|
||||
details={"model": model_name},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ Provides centralized registry of household members (agents) with their
|
||||
capabilities and tools. Supports two-tier abstraction: executive summaries
|
||||
for coordination and full toolsets for execution.
|
||||
"""
|
||||
from typing import Any, Optional
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic_ai import Agent
|
||||
|
||||
from .logging_config import get_logger
|
||||
|
||||
@@ -22,6 +22,7 @@ class HouseholdCapability(BaseModel):
|
||||
This is what the Steward and Butler see for coordination.
|
||||
High-level description without implementation details.
|
||||
"""
|
||||
|
||||
name: str # Unique identifier: "tatlock_core", "librarian", "developer"
|
||||
role: str # Display name: "Butler's Core Tools", "The Librarian"
|
||||
category: str # "core", "research", "technical", "automation"
|
||||
@@ -38,11 +39,12 @@ class HouseholdMember(BaseModel):
|
||||
Contains both the executive summary (for coordination) and
|
||||
implementation details (tools/agent).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
capability: HouseholdCapability
|
||||
tools: list[Any] # PydanticAI tool definitions (any type since Tool is a dataclass)
|
||||
agent: Optional[Any] = None # For expert agents (Phase 4)
|
||||
agent: Any | None = None # For expert agents (Phase 4)
|
||||
|
||||
|
||||
class HouseholdRegistry:
|
||||
@@ -55,7 +57,7 @@ class HouseholdRegistry:
|
||||
3. Agent delegation (Phase 4)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize empty registry."""
|
||||
self._members: dict[str, HouseholdMember] = {}
|
||||
logger.info("household_registry_initialized")
|
||||
@@ -65,7 +67,7 @@ class HouseholdRegistry:
|
||||
name: str,
|
||||
capability: HouseholdCapability,
|
||||
tools: list[Any],
|
||||
agent: Optional[Any] = None,
|
||||
agent: Any | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Register a household member.
|
||||
@@ -95,9 +97,7 @@ class HouseholdRegistry:
|
||||
... )
|
||||
"""
|
||||
if name != capability.name:
|
||||
raise ValueError(
|
||||
f"Name mismatch: '{name}' != '{capability.name}'"
|
||||
)
|
||||
raise ValueError(f"Name mismatch: '{name}' != '{capability.name}'")
|
||||
|
||||
self._members[name] = HouseholdMember(
|
||||
capability=capability,
|
||||
@@ -132,7 +132,7 @@ class HouseholdRegistry:
|
||||
role=member.capability.role,
|
||||
)
|
||||
|
||||
def get_member(self, name: str) -> Optional[HouseholdMember]:
|
||||
def get_member(self, name: str) -> HouseholdMember | None:
|
||||
"""
|
||||
Get full household member specification.
|
||||
|
||||
|
||||
+33
-13
@@ -4,12 +4,14 @@ Structured logging configuration using structlog.
|
||||
Deeply integrates with FastAPI/uvicorn's built-in logging to provide
|
||||
seamless structured logs across the entire application stack.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.config
|
||||
import sys
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from structlog.types import EventDict, Processor
|
||||
@@ -19,7 +21,7 @@ from .config import config
|
||||
|
||||
def add_timestamp(logger: Any, method_name: str, event_dict: EventDict) -> EventDict:
|
||||
"""Add ISO 8601 timestamp to log entries."""
|
||||
event_dict["timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||
event_dict["timestamp"] = datetime.now(UTC).isoformat()
|
||||
return event_dict
|
||||
|
||||
|
||||
@@ -41,11 +43,28 @@ def extract_from_record(logger: Any, method_name: str, event_dict: EventDict) ->
|
||||
# Extract custom fields from record
|
||||
for key, value in record.__dict__.items():
|
||||
if key not in {
|
||||
"name", "msg", "args", "created", "filename", "funcName",
|
||||
"levelname", "levelno", "lineno", "module", "msecs",
|
||||
"message", "pathname", "process", "processName", "relativeCreated",
|
||||
"thread", "threadName", "exc_info", "exc_text", "stack_info",
|
||||
"taskName"
|
||||
"name",
|
||||
"msg",
|
||||
"args",
|
||||
"created",
|
||||
"filename",
|
||||
"funcName",
|
||||
"levelname",
|
||||
"levelno",
|
||||
"lineno",
|
||||
"module",
|
||||
"msecs",
|
||||
"message",
|
||||
"pathname",
|
||||
"process",
|
||||
"processName",
|
||||
"relativeCreated",
|
||||
"thread",
|
||||
"threadName",
|
||||
"exc_info",
|
||||
"exc_text",
|
||||
"stack_info",
|
||||
"taskName",
|
||||
}:
|
||||
event_dict[key] = value
|
||||
|
||||
@@ -164,7 +183,7 @@ def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||
async def log_operation(
|
||||
operation: str,
|
||||
initial_context: dict[str, Any] | None = None,
|
||||
logger_name: str = "tatlock.operations"
|
||||
logger_name: str = "tatlock.operations",
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
"""
|
||||
Context manager for automatic operation timing and logging.
|
||||
@@ -187,21 +206,21 @@ async def log_operation(
|
||||
context = initial_context or {}
|
||||
context["operation"] = operation
|
||||
|
||||
start_time = datetime.now(timezone.utc)
|
||||
start_time = datetime.now(UTC)
|
||||
logger.info("operation_started", **context)
|
||||
|
||||
try:
|
||||
yield context
|
||||
|
||||
# Success case
|
||||
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
|
||||
duration = (datetime.now(UTC) - start_time).total_seconds()
|
||||
context["duration_seconds"] = duration
|
||||
context["success"] = True
|
||||
logger.info("operation_completed", **context)
|
||||
|
||||
except Exception as e:
|
||||
# Error case
|
||||
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
|
||||
duration = (datetime.now(UTC) - start_time).total_seconds()
|
||||
context["duration_seconds"] = duration
|
||||
context["success"] = False
|
||||
context["error"] = str(e)
|
||||
@@ -228,7 +247,8 @@ def get_uvicorn_log_config() -> dict[str, Any]:
|
||||
"()": structlog.stdlib.ProcessorFormatter,
|
||||
"processors": [
|
||||
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
||||
structlog.processors.JSONRenderer() if config.log_format == "json"
|
||||
structlog.processors.JSONRenderer()
|
||||
if config.log_format == "json"
|
||||
else structlog.dev.ConsoleRenderer(colors=True),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ Provides short-term memory storage with TTL:
|
||||
|
||||
Uses Redis DB 1.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
@@ -15,7 +16,7 @@ import redis.asyncio as redis
|
||||
|
||||
from .config import config
|
||||
from .logging_config import get_logger
|
||||
from .multi_tenancy import get_session_key, get_entities_key
|
||||
from .multi_tenancy import get_entities_key, get_session_key
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
+16
-14
@@ -21,14 +21,14 @@ Usage:
|
||||
# Get session context
|
||||
ctx = await memory_service.get_session_context(conversation_id)
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .config import config
|
||||
from .context import get_user, get_conversation_id
|
||||
from .context import get_conversation_id, get_user
|
||||
from .embeddings import get_embedding_client
|
||||
from .logging_config import get_logger
|
||||
from .memory_cache import get_memory_cache
|
||||
@@ -40,22 +40,24 @@ logger = get_logger(__name__)
|
||||
|
||||
class MemoryType(str, Enum):
|
||||
"""Types of memories stored in Qdrant."""
|
||||
USER_PROFILE = "user_profile" # Name, location, timezone
|
||||
PREFERENCE = "preference" # Units, language, theme
|
||||
LEARNED_FACT = "learned_fact" # "My car is a Tesla"
|
||||
|
||||
USER_PROFILE = "user_profile" # Name, location, timezone
|
||||
PREFERENCE = "preference" # Units, language, theme
|
||||
LEARNED_FACT = "learned_fact" # "My car is a Tesla"
|
||||
|
||||
|
||||
class MemoryRecord(BaseModel):
|
||||
"""A memory record stored in Qdrant."""
|
||||
|
||||
id: str
|
||||
type: MemoryType
|
||||
key: str # e.g., "location", "timezone", "car"
|
||||
value: str # The actual content
|
||||
key: str # e.g., "location", "timezone", "car"
|
||||
value: str # The actual content
|
||||
keywords: list[str] = Field(default_factory=list)
|
||||
importance: float = 0.5 # 0.0 - 1.0
|
||||
source: str = "explicit" # "explicit" | "inferred" | "conversation"
|
||||
created_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
updated_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
importance: float = 0.5 # 0.0 - 1.0
|
||||
source: str = "explicit" # "explicit" | "inferred" | "conversation"
|
||||
created_at: str = Field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
updated_at: str = Field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
|
||||
|
||||
class MemoryService:
|
||||
@@ -72,7 +74,7 @@ class MemoryService:
|
||||
- Semantic recall: "What did I mention about X?" → Use Memory Agent
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize memory service with lazy client loading."""
|
||||
self._qdrant = None
|
||||
self._embedding = None
|
||||
@@ -528,7 +530,7 @@ class MemoryService:
|
||||
"keywords": keywords,
|
||||
"importance": importance,
|
||||
"source": source,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"updated_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
result = await self.qdrant.upsert_memory(
|
||||
|
||||
+3
-2
@@ -2,6 +2,7 @@
|
||||
Custom Pydantic base models for consistent serialization.
|
||||
Following best practice of having a global base model.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -23,6 +24,7 @@ class CustomBaseModel(BaseModel):
|
||||
- Timezone-aware datetime handling
|
||||
- Alias population support
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_encoders={datetime: datetime_to_iso_str},
|
||||
populate_by_name=True,
|
||||
@@ -38,6 +40,5 @@ class CustomBaseModel(BaseModel):
|
||||
Useful for logging and debugging.
|
||||
"""
|
||||
return jsonable_encoder(
|
||||
self.model_dump(**kwargs),
|
||||
custom_encoder={datetime: datetime_to_iso_str}
|
||||
self.model_dump(**kwargs), custom_encoder={datetime: datetime_to_iso_str}
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ Provides utilities for user namespace management across:
|
||||
|
||||
Adapted from library-desk patterns.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
@@ -39,13 +40,13 @@ def sanitize_user_id(user_id: str) -> str:
|
||||
sanitized = sanitized.replace(".", "_")
|
||||
|
||||
# Replace any non-alphanumeric characters with underscores
|
||||
sanitized = re.sub(r'[^a-z0-9_]', '_', sanitized)
|
||||
sanitized = re.sub(r"[^a-z0-9_]", "_", sanitized)
|
||||
|
||||
# Remove consecutive underscores
|
||||
sanitized = re.sub(r'_+', '_', sanitized)
|
||||
sanitized = re.sub(r"_+", "_", sanitized)
|
||||
|
||||
# Remove leading/trailing underscores
|
||||
sanitized = sanitized.strip('_')
|
||||
sanitized = sanitized.strip("_")
|
||||
|
||||
return sanitized
|
||||
|
||||
@@ -141,7 +142,7 @@ def validate_user_id(user_id: str) -> bool:
|
||||
return False
|
||||
|
||||
# Must contain at least one alphanumeric character
|
||||
if not re.search(r'[a-zA-Z0-9]', user_id):
|
||||
if not re.search(r"[a-zA-Z0-9]", user_id):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
+14
-12
@@ -3,15 +3,16 @@ Request preprocessing pipeline.
|
||||
|
||||
Analyzes requests via the Steward and creates scoped toolsets for Tatlock.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
from src.agents.steward import analyze_request, format_steward_note
|
||||
from src.agents.steward.schemas import StewardRecommendation
|
||||
from src.core.household_registry import get_household_registry
|
||||
from src.core.logging_config import get_logger
|
||||
from src.core.tracing import trace_span, SpanType
|
||||
from src.core.tracing import SpanType, trace_span
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -45,6 +46,7 @@ class EnrichedRequest:
|
||||
recommendation: Full Steward recommendation
|
||||
steward_reasoning: Plain text reasoning for streaming to user
|
||||
"""
|
||||
|
||||
original_request: str
|
||||
steward_note: str
|
||||
scoped_tools: list[Any] # PydanticAI tool definitions
|
||||
@@ -55,7 +57,7 @@ class EnrichedRequest:
|
||||
async def preprocess_request(
|
||||
user_request: str,
|
||||
conversation_history: list[dict],
|
||||
conversation_id: Optional[str] = None,
|
||||
conversation_id: str | None = None,
|
||||
) -> EnrichedRequest:
|
||||
"""
|
||||
Analyze request via Steward and prepare scoped context for Tatlock.
|
||||
@@ -111,12 +113,14 @@ async def preprocess_request(
|
||||
|
||||
# Update span with results
|
||||
if span:
|
||||
span.metadata.update({
|
||||
"recommended_capabilities": recommendation.recommended_capabilities,
|
||||
"complexity": recommendation.estimated_complexity,
|
||||
"has_memory_context": bool(recommendation.memory_context),
|
||||
"has_conversation_context": recommendation.conversation_context.has_previous_context,
|
||||
})
|
||||
span.metadata.update(
|
||||
{
|
||||
"recommended_capabilities": recommendation.recommended_capabilities,
|
||||
"complexity": recommendation.estimated_complexity,
|
||||
"has_memory_context": bool(recommendation.memory_context),
|
||||
"has_conversation_context": recommendation.conversation_context.has_previous_context,
|
||||
}
|
||||
)
|
||||
span.details["reasoning"] = recommendation.reasoning
|
||||
if recommendation.enriched_query:
|
||||
span.details["enriched_query"] = recommendation.enriched_query
|
||||
@@ -128,9 +132,7 @@ async def preprocess_request(
|
||||
# Uses agent-as-tool pattern: expert agents get delegation wrappers,
|
||||
# core tools are returned directly
|
||||
registry = get_household_registry()
|
||||
scoped_tools = registry.get_delegation_tools(
|
||||
recommendation.recommended_capabilities
|
||||
)
|
||||
scoped_tools = registry.get_delegation_tools(recommendation.recommended_capabilities)
|
||||
|
||||
logger.info(
|
||||
"preprocessing_complete",
|
||||
|
||||
+2
-1
@@ -8,8 +8,9 @@ Provides async operations for storing and retrieving memory embeddings:
|
||||
|
||||
Adapted from library-desk patterns.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4, uuid5, NAMESPACE_DNS
|
||||
from uuid import NAMESPACE_DNS, uuid4, uuid5
|
||||
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.http import models as qdrant_models
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Core router for health and root endpoints.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
+3
-2
@@ -5,6 +5,7 @@ Handles initialization of household registry and other startup tasks.
|
||||
This module should be called during application startup to register
|
||||
all household members.
|
||||
"""
|
||||
|
||||
from src.agents.biographer import register_biographer
|
||||
from src.agents.housekeeper import register_housekeeper
|
||||
from src.agents.librarian import register_librarian
|
||||
@@ -46,7 +47,7 @@ def log_tenant_guard() -> None:
|
||||
)
|
||||
|
||||
|
||||
def register_household_members():
|
||||
def register_household_members() -> None:
|
||||
"""
|
||||
Register all household members with the registry.
|
||||
|
||||
@@ -112,7 +113,7 @@ def register_household_members():
|
||||
)
|
||||
|
||||
|
||||
async def initialize_application():
|
||||
async def initialize_application() -> None:
|
||||
"""
|
||||
Initialize the application.
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ Tool call tracking.
|
||||
Tracks which tools are recommended by the Steward versus which tools
|
||||
are actually used by Tatlock for debugging and analysis.
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from src.core.logging_config import get_logger
|
||||
|
||||
@@ -19,11 +18,7 @@ class ToolCallTracker:
|
||||
to measure recommendation accuracy.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
recommended_capabilities: list[str],
|
||||
conversation_id: Optional[str] = None
|
||||
):
|
||||
def __init__(self, recommended_capabilities: list[str], conversation_id: str | None = None):
|
||||
"""
|
||||
Initialize tool call tracker.
|
||||
|
||||
@@ -51,11 +46,11 @@ class ToolCallTracker:
|
||||
return tool_name.replace("delegate_to_", "")
|
||||
return tool_name
|
||||
|
||||
def log_call(self, message: str):
|
||||
def log_call(self, message: str) -> None:
|
||||
"""Log a tool call message (for UI display)."""
|
||||
logger.debug("tool_call_message", message=message)
|
||||
|
||||
async def track_call(self, tool_name: str, duration: float):
|
||||
async def track_call(self, tool_name: str, duration: float) -> None:
|
||||
"""
|
||||
Record a tool call with timing.
|
||||
|
||||
@@ -87,7 +82,7 @@ class ToolCallTracker:
|
||||
was_recommended=was_recommended,
|
||||
)
|
||||
|
||||
async def finalize(self):
|
||||
async def finalize(self) -> None:
|
||||
"""
|
||||
Finalize tracking and log unused recommended tools.
|
||||
|
||||
@@ -95,9 +90,7 @@ class ToolCallTracker:
|
||||
tools that were recommended but never used.
|
||||
"""
|
||||
# Normalize actual tool names to capabilities for comparison
|
||||
used_capabilities = {
|
||||
self._extract_capability(tool) for tool in self.actual_calls.keys()
|
||||
}
|
||||
used_capabilities = {self._extract_capability(tool) for tool in self.actual_calls.keys()}
|
||||
# Find tools that were recommended but not used
|
||||
unused_tools = self.recommended_capabilities - used_capabilities
|
||||
|
||||
@@ -128,9 +121,7 @@ class ToolCallTracker:
|
||||
"""
|
||||
total_calls = sum(len(durations) for durations in self.actual_calls.values())
|
||||
# Normalize actual tool names to capabilities for comparison
|
||||
used_capabilities = {
|
||||
self._extract_capability(tool) for tool in self.actual_calls.keys()
|
||||
}
|
||||
used_capabilities = {self._extract_capability(tool) for tool in self.actual_calls.keys()}
|
||||
unused = self.recommended_capabilities - used_capabilities
|
||||
|
||||
return {
|
||||
@@ -139,12 +130,8 @@ class ToolCallTracker:
|
||||
"tools_unused": list(unused),
|
||||
"total_calls": total_calls,
|
||||
"accuracy": {
|
||||
"recommended_and_used": len(
|
||||
self.recommended_capabilities & used_capabilities
|
||||
),
|
||||
"recommended_and_used": len(self.recommended_capabilities & used_capabilities),
|
||||
"recommended_but_unused": len(unused),
|
||||
"not_recommended_but_used": len(
|
||||
used_capabilities - self.recommended_capabilities
|
||||
),
|
||||
"not_recommended_but_used": len(used_capabilities - self.recommended_capabilities),
|
||||
},
|
||||
}
|
||||
|
||||
+23
-14
@@ -9,16 +9,16 @@ Enable via DEBUG=true environment variable.
|
||||
Traces are written to logs/traces/{trace_id}.json
|
||||
View with logs/traces/viewer.html
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from contextlib import asynccontextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from src.core.logging_config import get_logger
|
||||
|
||||
@@ -27,6 +27,7 @@ logger = get_logger(__name__)
|
||||
|
||||
class SpanType(str, Enum):
|
||||
"""Types of traced operations."""
|
||||
|
||||
ROUTER = "router"
|
||||
STEWARD = "steward"
|
||||
TATLOCK = "tatlock"
|
||||
@@ -36,6 +37,7 @@ class SpanType(str, Enum):
|
||||
|
||||
class SpanStatus(str, Enum):
|
||||
"""Span completion status."""
|
||||
|
||||
OK = "ok"
|
||||
ERROR = "error"
|
||||
|
||||
@@ -43,6 +45,7 @@ class SpanStatus(str, Enum):
|
||||
@dataclass
|
||||
class Span:
|
||||
"""A single traced operation."""
|
||||
|
||||
span_id: str
|
||||
name: str
|
||||
type: SpanType
|
||||
@@ -88,6 +91,7 @@ class Span:
|
||||
@dataclass
|
||||
class Trace:
|
||||
"""Complete trace of a request."""
|
||||
|
||||
trace_id: str
|
||||
conversation_id: str | None
|
||||
user: str
|
||||
@@ -116,7 +120,9 @@ class Trace:
|
||||
"conversation_id": self.conversation_id,
|
||||
"user": self.user,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"total_duration_ms": round(self.total_duration_ms, 2) if self.total_duration_ms else None,
|
||||
"total_duration_ms": round(self.total_duration_ms, 2)
|
||||
if self.total_duration_ms
|
||||
else None,
|
||||
"status": self.status,
|
||||
"request": self.request,
|
||||
"response": self.response,
|
||||
@@ -132,6 +138,7 @@ _current_span: ContextVar[Span | None] = ContextVar("current_span", default=None
|
||||
def tracing_enabled() -> bool:
|
||||
"""Check if tracing is enabled (requires DEBUG=true)."""
|
||||
from src.core.config import config
|
||||
|
||||
return config.DEBUG
|
||||
|
||||
|
||||
@@ -163,7 +170,7 @@ def start_trace(
|
||||
trace_id=_generate_id("trace_"),
|
||||
conversation_id=conversation_id,
|
||||
user=user,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
timestamp=datetime.now(UTC),
|
||||
request=request,
|
||||
)
|
||||
_current_trace.set(trace)
|
||||
@@ -209,7 +216,7 @@ def start_span(
|
||||
span_id=_generate_id("span_"),
|
||||
name=name,
|
||||
type=span_type,
|
||||
start_time=datetime.now(timezone.utc),
|
||||
start_time=datetime.now(UTC),
|
||||
parent_id=parent.span_id if parent else None,
|
||||
metadata=metadata or {},
|
||||
details=details or {},
|
||||
@@ -254,7 +261,7 @@ def end_span(
|
||||
if not span:
|
||||
return
|
||||
|
||||
span.end_time = datetime.now(timezone.utc)
|
||||
span.end_time = datetime.now(UTC)
|
||||
span.status = status
|
||||
if error:
|
||||
span.error = error
|
||||
@@ -405,7 +412,7 @@ def add_tool_spans_from_messages(messages: list[Any], parent_span: Span | None =
|
||||
if isinstance(part, ToolCallPart):
|
||||
tool_calls[part.tool_call_id] = {
|
||||
"name": part.tool_name,
|
||||
"args": part.args if hasattr(part, 'args') else {},
|
||||
"args": part.args if hasattr(part, "args") else {},
|
||||
}
|
||||
elif isinstance(msg, ModelRequest):
|
||||
for part in msg.parts:
|
||||
@@ -418,7 +425,7 @@ def add_tool_spans_from_messages(messages: list[Any], parent_span: Span | None =
|
||||
name=tool_info["name"],
|
||||
type=SpanType.TOOL,
|
||||
start_time=parent_span.start_time, # Approximate
|
||||
end_time=parent_span.end_time or datetime.now(timezone.utc),
|
||||
end_time=parent_span.end_time or datetime.now(UTC),
|
||||
parent_id=parent_span.span_id,
|
||||
status=SpanStatus.OK,
|
||||
metadata={
|
||||
@@ -427,7 +434,9 @@ def add_tool_spans_from_messages(messages: list[Any], parent_span: Span | None =
|
||||
},
|
||||
details={
|
||||
"args": tool_info.get("args", {}),
|
||||
"result": part.content[:2000] if isinstance(part.content, str) else str(part.content)[:2000],
|
||||
"result": part.content[:2000]
|
||||
if isinstance(part.content, str)
|
||||
else str(part.content)[:2000],
|
||||
},
|
||||
)
|
||||
parent_span.children.append(span.span_id)
|
||||
|
||||
+20
-14
@@ -4,7 +4,10 @@ Trace viewer router.
|
||||
Serves the trace viewer UI and trace files when tracing is enabled.
|
||||
Only available when DEBUG=true.
|
||||
"""
|
||||
|
||||
from datetime import UTC
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
@@ -66,12 +69,12 @@ async def list_traces(
|
||||
return {"traces": [], "total": 0}
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Calculate cutoff time if filtering by time
|
||||
cutoff_time = None
|
||||
if since_minutes:
|
||||
cutoff_time = datetime.now(timezone.utc) - timedelta(minutes=since_minutes)
|
||||
cutoff_time = datetime.now(UTC) - timedelta(minutes=since_minutes)
|
||||
|
||||
# Get all trace files, sorted by modification time (newest first)
|
||||
trace_files = sorted(
|
||||
@@ -80,7 +83,7 @@ async def list_traces(
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
traces = []
|
||||
traces: list[dict[str, Any]] = []
|
||||
for path in trace_files:
|
||||
if len(traces) >= limit:
|
||||
break
|
||||
@@ -93,7 +96,7 @@ async def list_traces(
|
||||
trace_timestamp = data.get("timestamp")
|
||||
if cutoff_time and trace_timestamp:
|
||||
try:
|
||||
ts = datetime.fromisoformat(trace_timestamp.replace('Z', '+00:00'))
|
||||
ts = datetime.fromisoformat(trace_timestamp.replace("Z", "+00:00"))
|
||||
if ts < cutoff_time:
|
||||
continue
|
||||
except (ValueError, TypeError):
|
||||
@@ -109,15 +112,17 @@ async def list_traces(
|
||||
if search and search.lower() not in request_preview.lower():
|
||||
continue
|
||||
|
||||
traces.append({
|
||||
"trace_id": data.get("trace_id"),
|
||||
"timestamp": trace_timestamp,
|
||||
"user": data.get("user"),
|
||||
"status": trace_status,
|
||||
"total_duration_ms": data.get("total_duration_ms"),
|
||||
"span_count": len(data.get("spans", [])),
|
||||
"request_preview": request_preview[:100],
|
||||
})
|
||||
traces.append(
|
||||
{
|
||||
"trace_id": data.get("trace_id"),
|
||||
"timestamp": trace_timestamp,
|
||||
"user": data.get("user"),
|
||||
"status": trace_status,
|
||||
"total_duration_ms": data.get("total_duration_ms"),
|
||||
"span_count": len(data.get("spans", [])),
|
||||
"request_preview": request_preview[:100],
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("trace_list_parse_error", path=str(path), error=str(e))
|
||||
|
||||
@@ -145,9 +150,10 @@ async def get_trace(trace_id: str):
|
||||
|
||||
try:
|
||||
import json
|
||||
|
||||
with open(trace_path) as f:
|
||||
data = json.load(f)
|
||||
return JSONResponse(content=data)
|
||||
except Exception as e:
|
||||
logger.error("trace_read_error", trace_id=trace_id, error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to read trace")
|
||||
raise HTTPException(status_code=500, detail="Failed to read trace") from e
|
||||
|
||||
+2
-1
@@ -9,8 +9,9 @@ Main responsibilities:
|
||||
- Router registration
|
||||
- Lifecycle management
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Models router.
|
||||
OpenAI-compatible /v1/models endpoint.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""
|
||||
OpenAI-compatible models schemas.
|
||||
"""
|
||||
|
||||
from src.core.models import CustomBaseModel
|
||||
|
||||
|
||||
class Model(CustomBaseModel):
|
||||
"""OpenAI-compatible model object."""
|
||||
|
||||
id: str
|
||||
object: str = "model"
|
||||
created: int
|
||||
@@ -14,5 +16,6 @@ class Model(CustomBaseModel):
|
||||
|
||||
class ModelsResponse(CustomBaseModel):
|
||||
"""OpenAI-compatible models list response."""
|
||||
|
||||
object: str = "list"
|
||||
data: list[Model]
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
Ollama HTTP client.
|
||||
Handles all communication with the Ollama service.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, AsyncGenerator
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from httpx import ConnectError, TimeoutException
|
||||
@@ -120,6 +122,7 @@ class OllamaClient:
|
||||
async for line in response.aiter_lines():
|
||||
if line.strip():
|
||||
import json
|
||||
|
||||
yield json.loads(line)
|
||||
|
||||
except ConnectError as e:
|
||||
|
||||
@@ -5,6 +5,7 @@ Ollama's OpenAI-compatible API rejects messages with `content: null`,
|
||||
which PydanticAI sends for assistant messages that only contain tool calls.
|
||||
This provider sanitizes messages to use empty strings instead of null.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Ollama API schemas.
|
||||
Internal models for Ollama API communication.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from src.core.models import CustomBaseModel
|
||||
@@ -9,12 +10,14 @@ from src.core.models import CustomBaseModel
|
||||
|
||||
class OllamaMessage(CustomBaseModel):
|
||||
"""Message format for Ollama API."""
|
||||
|
||||
role: str
|
||||
content: str
|
||||
|
||||
|
||||
class OllamaChatRequest(CustomBaseModel):
|
||||
"""Chat request to Ollama API."""
|
||||
|
||||
model: str
|
||||
messages: list[OllamaMessage]
|
||||
stream: bool = False
|
||||
@@ -23,6 +26,7 @@ class OllamaChatRequest(CustomBaseModel):
|
||||
|
||||
class OllamaChatResponse(CustomBaseModel):
|
||||
"""Chat response from Ollama API."""
|
||||
|
||||
model: str
|
||||
created_at: str
|
||||
message: OllamaMessage
|
||||
@@ -31,6 +35,7 @@ class OllamaChatResponse(CustomBaseModel):
|
||||
|
||||
class OllamaModelInfo(CustomBaseModel):
|
||||
"""Model information from Ollama."""
|
||||
|
||||
name: str
|
||||
modified_at: str
|
||||
size: int
|
||||
@@ -39,4 +44,5 @@ class OllamaModelInfo(CustomBaseModel):
|
||||
|
||||
class OllamaModelsResponse(CustomBaseModel):
|
||||
"""Response from Ollama models list endpoint."""
|
||||
|
||||
models: list[OllamaModelInfo]
|
||||
|
||||
@@ -9,12 +9,12 @@ This module implements the OpenAI Responses API format:
|
||||
"""
|
||||
|
||||
from src.responses.schemas import (
|
||||
FunctionCallOutputItem,
|
||||
MessageOutputItem,
|
||||
OutputItem,
|
||||
ReasoningOutputItem,
|
||||
Response,
|
||||
ResponseRequest,
|
||||
OutputItem,
|
||||
MessageOutputItem,
|
||||
ReasoningOutputItem,
|
||||
FunctionCallOutputItem,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -6,6 +6,7 @@ Handles:
|
||||
- Context trimming to fit model limits
|
||||
- Reserve tokens for output generation
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -59,11 +60,7 @@ class ContextWindow:
|
||||
# Approximate: 4 characters per token
|
||||
return total_chars // 4
|
||||
|
||||
async def trim_to_fit(
|
||||
self,
|
||||
items: list[Any],
|
||||
reserve_tokens: int = 512
|
||||
) -> list[Any]:
|
||||
async def trim_to_fit(self, items: list[Any], reserve_tokens: int = 512) -> list[Any]:
|
||||
"""
|
||||
Trim items to fit within context window.
|
||||
|
||||
@@ -86,7 +83,7 @@ class ContextWindow:
|
||||
return []
|
||||
|
||||
# Start from most recent, work backwards
|
||||
kept_items = []
|
||||
kept_items: list[Any] = []
|
||||
current_tokens = 0
|
||||
|
||||
for item in reversed(items):
|
||||
@@ -102,11 +99,7 @@ class ContextWindow:
|
||||
|
||||
return kept_items
|
||||
|
||||
async def fits_in_context(
|
||||
self,
|
||||
items: list[Any],
|
||||
reserve_tokens: int = 512
|
||||
) -> bool:
|
||||
async def fits_in_context(self, items: list[Any], reserve_tokens: int = 512) -> bool:
|
||||
"""
|
||||
Check if items fit within context window.
|
||||
|
||||
@@ -121,11 +114,7 @@ class ContextWindow:
|
||||
available_tokens = self.max_tokens - reserve_tokens
|
||||
return total_tokens <= available_tokens
|
||||
|
||||
async def get_usage_stats(
|
||||
self,
|
||||
items: list[Any],
|
||||
reserve_tokens: int = 512
|
||||
) -> dict:
|
||||
async def get_usage_stats(self, items: list[Any], reserve_tokens: int = 512) -> dict:
|
||||
"""
|
||||
Get context window usage statistics.
|
||||
|
||||
@@ -153,7 +142,7 @@ class ContextWindow:
|
||||
"reserved_tokens": reserve_tokens,
|
||||
"available_tokens": available_tokens,
|
||||
"usage_percent": round(usage_percent, 2),
|
||||
"fits": total_tokens <= available_tokens
|
||||
"fits": total_tokens <= available_tokens,
|
||||
}
|
||||
|
||||
# ========================================================================
|
||||
|
||||
@@ -6,8 +6,8 @@ Supports hybrid approach:
|
||||
- Optional conversation_id in metadata for server-side grouping
|
||||
- Server can augment with vector memories (future)
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from typing import Dict, List
|
||||
|
||||
from src.responses.schemas import Response, ResponseRequest
|
||||
|
||||
@@ -36,7 +36,7 @@ class ConversationHistory:
|
||||
Args:
|
||||
max_turns: Maximum number of response turns to keep per conversation
|
||||
"""
|
||||
self._conversations: Dict[str, List[Response]] = {}
|
||||
self._conversations: dict[str, list[Response]] = {}
|
||||
self._max_turns = max_turns
|
||||
|
||||
async def get_conversation_id(self, request: ResponseRequest) -> str:
|
||||
@@ -61,11 +61,7 @@ class ConversationHistory:
|
||||
first_msg = str(request.input[0]) if request.input else ""
|
||||
return hashlib.sha256(first_msg.encode()).hexdigest()[:16]
|
||||
|
||||
async def add_response(
|
||||
self,
|
||||
conversation_id: str,
|
||||
response: Response
|
||||
) -> None:
|
||||
async def add_response(self, conversation_id: str, response: Response) -> None:
|
||||
"""
|
||||
Add response to conversation history.
|
||||
|
||||
@@ -81,7 +77,7 @@ class ConversationHistory:
|
||||
# Trim old turns to stay within limit
|
||||
await self._trim_history(conversation_id)
|
||||
|
||||
async def get_history(self, conversation_id: str) -> List[Response]:
|
||||
async def get_history(self, conversation_id: str) -> list[Response]:
|
||||
"""
|
||||
Retrieve conversation history.
|
||||
|
||||
@@ -125,20 +121,17 @@ class ConversationHistory:
|
||||
conversation_id: Conversation identifier
|
||||
"""
|
||||
if len(self._conversations[conversation_id]) > self._max_turns:
|
||||
self._conversations[conversation_id] = (
|
||||
self._conversations[conversation_id][-self._max_turns:]
|
||||
)
|
||||
self._conversations[conversation_id] = self._conversations[conversation_id][
|
||||
-self._max_turns :
|
||||
]
|
||||
|
||||
# ========================================================================
|
||||
# Future: Vector Memory Integration
|
||||
# ========================================================================
|
||||
|
||||
async def get_relevant_memories(
|
||||
self,
|
||||
conversation_id: str,
|
||||
query: str,
|
||||
limit: int = 5
|
||||
) -> List[dict]:
|
||||
self, conversation_id: str, query: str, limit: int = 5
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Retrieve relevant memories from vector store.
|
||||
|
||||
|
||||
+9
-12
@@ -7,10 +7,10 @@ OpenAI-compatible /v1/responses endpoint with streaming support.
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from src.responses import service
|
||||
from src.responses.schemas import ResponseRequest, Response
|
||||
from src.core.exceptions import ModelNotFoundError, AppException
|
||||
from src.core.exceptions import AppException, ModelNotFoundError
|
||||
from src.core.logging_config import get_logger
|
||||
from src.responses import service
|
||||
from src.responses.schemas import Response, ResponseRequest
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -58,14 +58,11 @@ async def create_response(
|
||||
if use_steward:
|
||||
logger.info("Streaming with Steward preprocessing for Tatlock request")
|
||||
from src.responses.streaming import StreamingCoordinator
|
||||
|
||||
coordinator = StreamingCoordinator()
|
||||
return EventSourceResponse(
|
||||
coordinator.stream_response_with_steward(request)
|
||||
)
|
||||
return EventSourceResponse(coordinator.stream_response_with_steward(request))
|
||||
else:
|
||||
return EventSourceResponse(
|
||||
service.create_response_stream(request)
|
||||
)
|
||||
return EventSourceResponse(service.create_response_stream(request))
|
||||
|
||||
# Non-streaming response
|
||||
if use_steward:
|
||||
@@ -76,12 +73,12 @@ async def create_response(
|
||||
|
||||
except ModelNotFoundError as e:
|
||||
logger.error(f"Model not found: {e}")
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
|
||||
except AppException as e:
|
||||
logger.error(f"Application error: {e}")
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message) from e
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
raise HTTPException(status_code=500, detail="Internal server error") from e
|
||||
|
||||
+38
-46
@@ -8,18 +8,20 @@ OpenAI Responses API format with support for:
|
||||
- Streaming and non-streaming modes
|
||||
"""
|
||||
|
||||
from typing import Literal, Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from src.core.models import CustomBaseModel
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Output Item Schemas (appear in response.output array)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class OutputTextContent(CustomBaseModel):
|
||||
"""Text content in message output."""
|
||||
|
||||
type: Literal["output_text"] = "output_text"
|
||||
text: str
|
||||
annotations: list[dict] = Field(default_factory=list)
|
||||
@@ -31,6 +33,7 @@ class MessageOutputItem(CustomBaseModel):
|
||||
|
||||
Represents the assistant's final response message.
|
||||
"""
|
||||
|
||||
type: Literal["message"] = "message"
|
||||
id: str
|
||||
role: Literal["assistant"] = "assistant"
|
||||
@@ -45,6 +48,7 @@ class ReasoningOutputItem(CustomBaseModel):
|
||||
Represents the model's thinking/reasoning process.
|
||||
Displayed separately from the final answer.
|
||||
"""
|
||||
|
||||
type: Literal["reasoning"] = "reasoning"
|
||||
id: str
|
||||
summary: list[str] # List of reasoning steps
|
||||
@@ -57,6 +61,7 @@ class FunctionCallOutputItem(CustomBaseModel):
|
||||
|
||||
Represents a tool/function that the model wants to execute.
|
||||
"""
|
||||
|
||||
type: Literal["function_call"] = "function_call"
|
||||
id: str
|
||||
name: str
|
||||
@@ -66,15 +71,17 @@ class FunctionCallOutputItem(CustomBaseModel):
|
||||
|
||||
# Union type for all output items
|
||||
# Type: ignore because Pydantic handles union types specially
|
||||
OutputItem = MessageOutputItem | ReasoningOutputItem | FunctionCallOutputItem # type: ignore
|
||||
OutputItem = MessageOutputItem | ReasoningOutputItem | FunctionCallOutputItem
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Usage Tracking
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ResponseUsage(CustomBaseModel):
|
||||
"""Token usage statistics for the response."""
|
||||
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
reasoning_tokens: int = 0
|
||||
@@ -85,8 +92,10 @@ class ResponseUsage(CustomBaseModel):
|
||||
# Request Schema
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class Tool(CustomBaseModel):
|
||||
"""Tool/function definition."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
parameters: dict[str, Any]
|
||||
@@ -94,6 +103,7 @@ class Tool(CustomBaseModel):
|
||||
|
||||
class ReasoningConfig(CustomBaseModel):
|
||||
"""Reasoning configuration."""
|
||||
|
||||
effort: Literal["none", "minimal", "low", "medium", "high", "xhigh"] = "medium"
|
||||
summary: Literal["auto", "off"] = "auto"
|
||||
|
||||
@@ -104,46 +114,25 @@ class ResponseRequest(CustomBaseModel):
|
||||
|
||||
OpenAI Responses API format with optional extensions.
|
||||
"""
|
||||
|
||||
model: str = Field(description="Model ID to use")
|
||||
input: list[dict] = Field(
|
||||
description="Input messages or previous responses"
|
||||
)
|
||||
input: list[dict] = Field(description="Input messages or previous responses")
|
||||
reasoning: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Reasoning configuration: {effort: 'medium', summary: 'auto'}"
|
||||
)
|
||||
tools: list[dict] | None = Field(
|
||||
default=None,
|
||||
description="Available tools/functions"
|
||||
default=None, description="Reasoning configuration: {effort: 'medium', summary: 'auto'}"
|
||||
)
|
||||
tools: list[dict] | None = Field(default=None, description="Available tools/functions")
|
||||
metadata: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Custom metadata (e.g., conversation_id for server-side tracking)"
|
||||
)
|
||||
stream: bool = Field(
|
||||
default=False,
|
||||
description="Enable streaming mode"
|
||||
)
|
||||
max_output_tokens: int | None = Field(
|
||||
default=None,
|
||||
description="Maximum tokens to generate"
|
||||
)
|
||||
temperature: float = Field(
|
||||
default=1.0,
|
||||
ge=0.0,
|
||||
le=2.0,
|
||||
description="Sampling temperature"
|
||||
)
|
||||
stop: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Stop sequences"
|
||||
default=None, description="Custom metadata (e.g., conversation_id for server-side tracking)"
|
||||
)
|
||||
stream: bool = Field(default=False, description="Enable streaming mode")
|
||||
max_output_tokens: int | None = Field(default=None, description="Maximum tokens to generate")
|
||||
temperature: float = Field(default=1.0, ge=0.0, le=2.0, description="Sampling temperature")
|
||||
stop: list[str] | None = Field(default=None, description="Stop sequences")
|
||||
user: str | None = Field(
|
||||
default=None,
|
||||
description="Unique identifier for end-user (OpenAI standard)"
|
||||
default=None, description="Unique identifier for end-user (OpenAI standard)"
|
||||
)
|
||||
|
||||
@field_validator('reasoning')
|
||||
@field_validator("reasoning")
|
||||
@classmethod
|
||||
def validate_reasoning(cls, v: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""
|
||||
@@ -154,21 +143,21 @@ class ResponseRequest(CustomBaseModel):
|
||||
- summary must be 'auto' or 'off'
|
||||
"""
|
||||
if v is not None:
|
||||
if 'effort' in v:
|
||||
allowed_efforts = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh']
|
||||
if v['effort'] not in allowed_efforts:
|
||||
if "effort" in v:
|
||||
allowed_efforts = ["none", "minimal", "low", "medium", "high", "xhigh"]
|
||||
if v["effort"] not in allowed_efforts:
|
||||
raise ValueError(
|
||||
f"reasoning.effort must be one of {allowed_efforts}, got '{v['effort']}'"
|
||||
)
|
||||
if 'summary' in v:
|
||||
allowed_summaries = ['auto', 'off']
|
||||
if v['summary'] not in allowed_summaries:
|
||||
if "summary" in v:
|
||||
allowed_summaries = ["auto", "off"]
|
||||
if v["summary"] not in allowed_summaries:
|
||||
raise ValueError(
|
||||
f"reasoning.summary must be one of {allowed_summaries}, got '{v['summary']}'"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator('max_output_tokens')
|
||||
@field_validator("max_output_tokens")
|
||||
@classmethod
|
||||
def validate_max_output_tokens(cls, v: int | None) -> int | None:
|
||||
"""
|
||||
@@ -180,7 +169,7 @@ class ResponseRequest(CustomBaseModel):
|
||||
raise ValueError(f"max_output_tokens must be positive, got {v}")
|
||||
return v
|
||||
|
||||
@field_validator('stop')
|
||||
@field_validator("stop")
|
||||
@classmethod
|
||||
def validate_stop_sequences(cls, v: list[str] | None) -> list[str] | None:
|
||||
"""
|
||||
@@ -203,20 +192,20 @@ class ResponseRequest(CustomBaseModel):
|
||||
# Response Schema
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class Response(CustomBaseModel):
|
||||
"""
|
||||
Complete response object.
|
||||
|
||||
Contains output array with reasoning, function calls, and messages.
|
||||
"""
|
||||
|
||||
id: str = Field(description="Unique response ID")
|
||||
object: Literal["response"] = "response"
|
||||
created_at: int = Field(description="Unix timestamp")
|
||||
model: str = Field(description="Model used")
|
||||
status: Literal["completed", "in_progress", "failed", "cancelled"]
|
||||
output: list[OutputItem] = Field(
|
||||
description="Output items (reasoning, function_call, message)"
|
||||
)
|
||||
output: list[OutputItem] = Field(description="Output items (reasoning, function_call, message)")
|
||||
usage: ResponseUsage = Field(description="Token usage statistics")
|
||||
|
||||
|
||||
@@ -224,8 +213,10 @@ class Response(CustomBaseModel):
|
||||
# Error Schema
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ErrorDetail(CustomBaseModel):
|
||||
"""Error detail object."""
|
||||
|
||||
type: str
|
||||
message: str
|
||||
code: int | None = None
|
||||
@@ -233,4 +224,5 @@ class ErrorDetail(CustomBaseModel):
|
||||
|
||||
class ErrorResponse(CustomBaseModel):
|
||||
"""Error response format."""
|
||||
|
||||
error: ErrorDetail
|
||||
|
||||
+67
-64
@@ -52,9 +52,9 @@ def _extract_response_preview(response: Response) -> str:
|
||||
"""Extract response preview text for tracing."""
|
||||
if response.output:
|
||||
for item in response.output:
|
||||
if hasattr(item, 'content'):
|
||||
if hasattr(item, "content"):
|
||||
for content in item.content:
|
||||
if hasattr(content, 'text'):
|
||||
if hasattr(content, "text"):
|
||||
return content.text[:200]
|
||||
return ""
|
||||
|
||||
@@ -79,10 +79,12 @@ async def _execute_single_delegation(
|
||||
result summary is a curated user-safe sentence.
|
||||
"""
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
if agent_name == "biographer":
|
||||
from src.agents.delegation import delegate_to_biographer
|
||||
|
||||
result = await delegate_to_biographer(task=task, context=context)
|
||||
duration = time.time() - start_time
|
||||
await tracker.track_call("delegate_to_biographer", duration)
|
||||
@@ -90,6 +92,7 @@ async def _execute_single_delegation(
|
||||
|
||||
elif agent_name == "librarian":
|
||||
from src.agents.delegation import delegate_to_librarian
|
||||
|
||||
result = await delegate_to_librarian(task=task, context=context)
|
||||
duration = time.time() - start_time
|
||||
await tracker.track_call("delegate_to_librarian", duration)
|
||||
@@ -97,6 +100,7 @@ async def _execute_single_delegation(
|
||||
|
||||
elif agent_name == "housekeeper":
|
||||
from src.agents.delegation import delegate_to_housekeeper
|
||||
|
||||
result = await delegate_to_housekeeper(task=task, context=context)
|
||||
duration = time.time() - start_time
|
||||
await tracker.track_call("delegate_to_housekeeper", duration)
|
||||
@@ -107,9 +111,7 @@ async def _execute_single_delegation(
|
||||
|
||||
|
||||
async def _handle_text_delegation(
|
||||
response: str,
|
||||
tracker: "ToolCallTracker",
|
||||
conversation_id: str
|
||||
response: str, tracker: "ToolCallTracker", conversation_id: str
|
||||
) -> str:
|
||||
"""
|
||||
Handle text-based delegation fallback.
|
||||
@@ -173,8 +175,7 @@ async def _handle_text_delegation(
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
tasks = [
|
||||
_execute_single_delegation(agent.lower(), task, tracker)
|
||||
for agent, task in matches
|
||||
_execute_single_delegation(agent.lower(), task, tracker) for agent, task in matches
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
@@ -188,10 +189,7 @@ async def _handle_text_delegation(
|
||||
got=len(results),
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
return (
|
||||
"I apologize, sir. I was unable to complete the "
|
||||
"requested delegations."
|
||||
)
|
||||
return "I apologize, sir. I was unable to complete the " "requested delegations."
|
||||
|
||||
# Combine results (failures carry curated user-safe sentences)
|
||||
summaries = []
|
||||
@@ -205,8 +203,7 @@ async def _handle_text_delegation(
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
summaries.append(
|
||||
f"**{agent_name}**: "
|
||||
f"{get_think_message(agent_name, task, 'error')}"
|
||||
f"**{agent_name}**: " f"{get_think_message(agent_name, task, 'error')}"
|
||||
)
|
||||
else:
|
||||
_, output, _ = item
|
||||
@@ -226,9 +223,7 @@ async def _handle_text_delegation(
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
try:
|
||||
_, output, _ = await _execute_single_delegation(
|
||||
agent_name, task, tracker
|
||||
)
|
||||
_, output, _ = await _execute_single_delegation(agent_name, task, tracker)
|
||||
summaries.append(output)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
@@ -421,7 +416,7 @@ def _calculate_usage(input_messages: list[dict], output_items: list) -> Response
|
||||
elif isinstance(item, FunctionCallOutputItem):
|
||||
func_text = item.arguments
|
||||
output_tokens += len(func_text) // 4
|
||||
elif hasattr(item, 'type'):
|
||||
elif hasattr(item, "type"):
|
||||
# Agent OutputItem objects (backward compatibility)
|
||||
if item.type == "reasoning":
|
||||
reasoning_text = " ".join(item.data.get("summary", []))
|
||||
@@ -439,7 +434,7 @@ def _calculate_usage(input_messages: list[dict], output_items: list) -> Response
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
reasoning_tokens=reasoning_tokens,
|
||||
total_tokens=total_tokens
|
||||
total_tokens=total_tokens,
|
||||
)
|
||||
|
||||
|
||||
@@ -527,7 +522,7 @@ async def create_response(request: ResponseRequest) -> Response:
|
||||
model=request.model,
|
||||
status="completed",
|
||||
output=converted_items,
|
||||
usage=usage
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
# Track conversation history (for analytics and future vector memory)
|
||||
@@ -640,12 +635,15 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
|
||||
# If Steward recommends ONLY delegation agents (biographer/librarian/housekeeper),
|
||||
# we still use two-phase but delegate directly in Phase 1
|
||||
delegation_agents = {"biographer", "librarian", "housekeeper"}
|
||||
delegation_only = all(
|
||||
cap in delegation_agents
|
||||
for cap in enriched.recommendation.recommended_capabilities
|
||||
) and enriched.recommendation.recommended_capabilities
|
||||
delegation_only = (
|
||||
all(
|
||||
cap in delegation_agents for cap in enriched.recommendation.recommended_capabilities
|
||||
)
|
||||
and enriched.recommendation.recommended_capabilities
|
||||
)
|
||||
|
||||
from src.agents.tatlock import TatlockAgent
|
||||
|
||||
tatlock = TatlockAgent()
|
||||
|
||||
# Use enriched query (with location/timezone context) if available
|
||||
@@ -677,7 +675,9 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
|
||||
)
|
||||
# Add text delegation results to expert_results
|
||||
if text_delegation_results != orchestration_results["raw_output"]:
|
||||
orchestration_results["expert_results"]["text_delegation"] = text_delegation_results
|
||||
orchestration_results["expert_results"]["text_delegation"] = (
|
||||
text_delegation_results
|
||||
)
|
||||
|
||||
# Phase 2: Synthesize butler-toned response from all results
|
||||
tatlock_response = await tatlock.synthesize_from_results(
|
||||
@@ -694,23 +694,25 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
|
||||
|
||||
# Add Steward reasoning as reasoning output
|
||||
if enriched.steward_reasoning:
|
||||
output_items.append(ReasoningOutputItem(
|
||||
id=f"rs_{generate_id()}",
|
||||
summary=[enriched.steward_reasoning],
|
||||
status="completed"
|
||||
))
|
||||
output_items.append(
|
||||
ReasoningOutputItem(
|
||||
id=f"rs_{generate_id()}",
|
||||
summary=[enriched.steward_reasoning],
|
||||
status="completed",
|
||||
)
|
||||
)
|
||||
|
||||
# Add Tatlock's message
|
||||
output_items.append(MessageOutputItem(
|
||||
id=f"msg_{generate_id()}",
|
||||
role="assistant",
|
||||
content=[OutputTextContent(
|
||||
type="output_text",
|
||||
text=tatlock_response,
|
||||
annotations=[]
|
||||
)],
|
||||
status="completed"
|
||||
))
|
||||
output_items.append(
|
||||
MessageOutputItem(
|
||||
id=f"msg_{generate_id()}",
|
||||
role="assistant",
|
||||
content=[
|
||||
OutputTextContent(type="output_text", text=tatlock_response, annotations=[])
|
||||
],
|
||||
status="completed",
|
||||
)
|
||||
)
|
||||
|
||||
# Calculate usage (approximate)
|
||||
usage = _calculate_usage(request.input, output_items)
|
||||
@@ -721,7 +723,7 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
|
||||
model=request.model,
|
||||
status="completed",
|
||||
output=output_items,
|
||||
usage=usage
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
# Track conversation history
|
||||
@@ -752,9 +754,7 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
|
||||
raise
|
||||
|
||||
|
||||
async def create_response_stream(
|
||||
request: ResponseRequest
|
||||
) -> AsyncGenerator[dict, None]:
|
||||
async def create_response_stream(request: ResponseRequest) -> AsyncGenerator[dict, None]:
|
||||
"""
|
||||
Create streaming response.
|
||||
|
||||
@@ -774,10 +774,7 @@ async def create_response_stream(
|
||||
coordinator = StreamingCoordinator()
|
||||
|
||||
async for event in coordinator.stream_response(request):
|
||||
yield {
|
||||
"event": event.event,
|
||||
"data": event.model_dump_json()
|
||||
}
|
||||
yield {"event": event.event, "data": event.model_dump_json()}
|
||||
|
||||
|
||||
async def get_conversation_history(conversation_id: str) -> list[Response]:
|
||||
@@ -802,7 +799,7 @@ async def get_conversation_stats() -> dict:
|
||||
"""
|
||||
return {
|
||||
"total_conversations": await _conversation_history.get_conversation_count(),
|
||||
"max_turns_per_conversation": _conversation_history._max_turns
|
||||
"max_turns_per_conversation": _conversation_history._max_turns,
|
||||
}
|
||||
|
||||
|
||||
@@ -843,23 +840,29 @@ def _convert_output_items(items: list) -> list:
|
||||
|
||||
for item in items:
|
||||
if item.type == "message":
|
||||
converted.append(MessageOutputItem(
|
||||
id=item.id,
|
||||
content=[OutputTextContent(**c) for c in item.data["content"]],
|
||||
status=item.data.get("status", "completed")
|
||||
))
|
||||
converted.append(
|
||||
MessageOutputItem(
|
||||
id=item.id,
|
||||
content=[OutputTextContent(**c) for c in item.data["content"]],
|
||||
status=item.data.get("status", "completed"),
|
||||
)
|
||||
)
|
||||
elif item.type == "reasoning":
|
||||
converted.append(ReasoningOutputItem(
|
||||
id=item.id,
|
||||
summary=item.data["summary"],
|
||||
status=item.data.get("status", "completed")
|
||||
))
|
||||
converted.append(
|
||||
ReasoningOutputItem(
|
||||
id=item.id,
|
||||
summary=item.data["summary"],
|
||||
status=item.data.get("status", "completed"),
|
||||
)
|
||||
)
|
||||
elif item.type == "function_call":
|
||||
converted.append(FunctionCallOutputItem(
|
||||
id=item.id,
|
||||
name=item.data["name"],
|
||||
arguments=item.data["arguments"],
|
||||
status=item.data.get("status", "completed")
|
||||
))
|
||||
converted.append(
|
||||
FunctionCallOutputItem(
|
||||
id=item.id,
|
||||
name=item.data["name"],
|
||||
arguments=item.data["arguments"],
|
||||
status=item.data.get("status", "completed"),
|
||||
)
|
||||
)
|
||||
|
||||
return converted
|
||||
|
||||
+85
-85
@@ -30,8 +30,10 @@ logger = get_logger(__name__)
|
||||
# Stream Event Types
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class StreamEventType(str, Enum):
|
||||
"""Streaming event types for Responses API."""
|
||||
|
||||
REASONING_SUMMARY_DELTA = "response.reasoning_summary_text.delta"
|
||||
REASONING_SUMMARY_DONE = "response.reasoning_summary_text.done"
|
||||
OUTPUT_TEXT_DELTA = "response.output_text.delta"
|
||||
@@ -46,30 +48,38 @@ class StreamEventType(str, Enum):
|
||||
# Stream Event Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ReasoningSummaryDelta(CustomBaseModel):
|
||||
"""Reasoning summary text delta event."""
|
||||
event: Literal[StreamEventType.REASONING_SUMMARY_DELTA] = StreamEventType.REASONING_SUMMARY_DELTA
|
||||
|
||||
event: Literal[StreamEventType.REASONING_SUMMARY_DELTA] = (
|
||||
StreamEventType.REASONING_SUMMARY_DELTA
|
||||
)
|
||||
delta: str
|
||||
|
||||
|
||||
class ReasoningSummaryDone(CustomBaseModel):
|
||||
"""Reasoning summary completion event."""
|
||||
|
||||
event: Literal[StreamEventType.REASONING_SUMMARY_DONE] = StreamEventType.REASONING_SUMMARY_DONE
|
||||
|
||||
|
||||
class OutputTextDelta(CustomBaseModel):
|
||||
"""Output text delta event."""
|
||||
|
||||
event: Literal[StreamEventType.OUTPUT_TEXT_DELTA] = StreamEventType.OUTPUT_TEXT_DELTA
|
||||
delta: str
|
||||
|
||||
|
||||
class OutputTextDone(CustomBaseModel):
|
||||
"""Output text completion event."""
|
||||
|
||||
event: Literal[StreamEventType.OUTPUT_TEXT_DONE] = StreamEventType.OUTPUT_TEXT_DONE
|
||||
|
||||
|
||||
class FunctionCallDelta(CustomBaseModel):
|
||||
"""Function call arguments delta event."""
|
||||
|
||||
event: Literal[StreamEventType.FUNCTION_CALL_DELTA] = StreamEventType.FUNCTION_CALL_DELTA
|
||||
delta: str
|
||||
name: str | None = None # Only in first chunk
|
||||
@@ -77,31 +87,34 @@ class FunctionCallDelta(CustomBaseModel):
|
||||
|
||||
class FunctionCallDone(CustomBaseModel):
|
||||
"""Function call completion event."""
|
||||
|
||||
event: Literal[StreamEventType.FUNCTION_CALL_DONE] = StreamEventType.FUNCTION_CALL_DONE
|
||||
|
||||
|
||||
class ResponseDone(CustomBaseModel):
|
||||
"""Response completion event with full response."""
|
||||
|
||||
event: Literal[StreamEventType.RESPONSE_DONE] = StreamEventType.RESPONSE_DONE
|
||||
response: Response
|
||||
|
||||
|
||||
class ErrorEvent(CustomBaseModel):
|
||||
"""Error event."""
|
||||
|
||||
event: Literal[StreamEventType.ERROR] = StreamEventType.ERROR
|
||||
error: dict
|
||||
|
||||
|
||||
# Union type for all stream events
|
||||
StreamEvent = (
|
||||
ReasoningSummaryDelta |
|
||||
ReasoningSummaryDone |
|
||||
OutputTextDelta |
|
||||
OutputTextDone |
|
||||
FunctionCallDelta |
|
||||
FunctionCallDone |
|
||||
ResponseDone |
|
||||
ErrorEvent
|
||||
ReasoningSummaryDelta
|
||||
| ReasoningSummaryDone
|
||||
| OutputTextDelta
|
||||
| OutputTextDone
|
||||
| FunctionCallDelta
|
||||
| FunctionCallDone
|
||||
| ResponseDone
|
||||
| ErrorEvent
|
||||
)
|
||||
|
||||
|
||||
@@ -109,6 +122,7 @@ StreamEvent = (
|
||||
# Streaming Coordinator
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class StreamingCoordinator:
|
||||
"""
|
||||
Coordinates streaming from agents to SSE format.
|
||||
@@ -123,7 +137,7 @@ class StreamingCoordinator:
|
||||
|
||||
async def stream_response_with_steward(
|
||||
self,
|
||||
request: "ResponseRequest" # type: ignore # Forward reference
|
||||
request: "ResponseRequest", # Forward reference
|
||||
) -> AsyncGenerator[StreamEvent, None]:
|
||||
"""
|
||||
Stream response with Steward preprocessing and two-phase Tatlock execution.
|
||||
@@ -181,10 +195,13 @@ class StreamingCoordinator:
|
||||
|
||||
# Check if direct delegation is recommended
|
||||
delegation_agents = {"biographer", "librarian", "housekeeper"}
|
||||
delegation_only = all(
|
||||
cap in delegation_agents
|
||||
for cap in enriched.recommendation.recommended_capabilities
|
||||
) and enriched.recommendation.recommended_capabilities
|
||||
delegation_only = (
|
||||
all(
|
||||
cap in delegation_agents
|
||||
for cap in enriched.recommendation.recommended_capabilities
|
||||
)
|
||||
and enriched.recommendation.recommended_capabilities
|
||||
)
|
||||
|
||||
tatlock = TatlockAgent()
|
||||
|
||||
@@ -222,7 +239,7 @@ class StreamingCoordinator:
|
||||
# Stream the synthesized response
|
||||
chunk_size = 50
|
||||
for i in range(0, len(tatlock_response), chunk_size):
|
||||
yield OutputTextDelta(delta=tatlock_response[i:i + chunk_size])
|
||||
yield OutputTextDelta(delta=tatlock_response[i : i + chunk_size])
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
yield OutputTextDone()
|
||||
@@ -231,12 +248,10 @@ class StreamingCoordinator:
|
||||
message_item = MessageOutputItem(
|
||||
id=f"msg_{generate_id()}",
|
||||
role="assistant",
|
||||
content=[OutputTextContent(
|
||||
type="output_text",
|
||||
text=tatlock_response,
|
||||
annotations=[]
|
||||
)],
|
||||
status="completed"
|
||||
content=[
|
||||
OutputTextContent(type="output_text", text=tatlock_response, annotations=[])
|
||||
],
|
||||
status="completed",
|
||||
)
|
||||
output_items.append(message_item)
|
||||
|
||||
@@ -252,7 +267,7 @@ class StreamingCoordinator:
|
||||
model=request.model,
|
||||
status="completed",
|
||||
output=output_items,
|
||||
usage=usage
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
# Track conversation history
|
||||
@@ -267,8 +282,8 @@ class StreamingCoordinator:
|
||||
async def _stream_direct_delegation(
|
||||
self,
|
||||
user_message: str,
|
||||
recommendation: "StewardRecommendation", # type: ignore
|
||||
tracker: "ToolCallTracker", # type: ignore
|
||||
recommendation: "StewardRecommendation",
|
||||
tracker: "ToolCallTracker",
|
||||
conversation_id: str,
|
||||
conversation_history: list | None = None,
|
||||
results: dict | None = None,
|
||||
@@ -319,17 +334,11 @@ class StreamingCoordinator:
|
||||
try:
|
||||
# Execute delegation
|
||||
if agent == "librarian":
|
||||
result = await delegate_to_librarian(
|
||||
task=user_message, context=context
|
||||
)
|
||||
result = await delegate_to_librarian(task=user_message, context=context)
|
||||
elif agent == "biographer":
|
||||
result = await delegate_to_biographer(
|
||||
task=user_message, context=context
|
||||
)
|
||||
result = await delegate_to_biographer(task=user_message, context=context)
|
||||
elif agent == "housekeeper":
|
||||
result = await delegate_to_housekeeper(
|
||||
task=user_message, context=context
|
||||
)
|
||||
result = await delegate_to_housekeeper(task=user_message, context=context)
|
||||
else:
|
||||
result = None
|
||||
|
||||
@@ -366,17 +375,19 @@ class StreamingCoordinator:
|
||||
yield ReasoningSummaryDone()
|
||||
|
||||
if results is not None:
|
||||
results.update({
|
||||
"tools_called": tools_called,
|
||||
"expert_results": expert_results,
|
||||
"tool_outputs": {},
|
||||
"raw_output": "",
|
||||
"think_messages": think_messages,
|
||||
})
|
||||
results.update(
|
||||
{
|
||||
"tools_called": tools_called,
|
||||
"expert_results": expert_results,
|
||||
"tool_outputs": {},
|
||||
"raw_output": "",
|
||||
"think_messages": think_messages,
|
||||
}
|
||||
)
|
||||
|
||||
async def stream_response(
|
||||
self,
|
||||
request: "ResponseRequest" # type: ignore # Forward reference
|
||||
request: "ResponseRequest", # Forward reference
|
||||
) -> AsyncGenerator[StreamEvent, None]:
|
||||
"""
|
||||
Coordinate streaming from agent to SSE events.
|
||||
@@ -435,18 +446,13 @@ class StreamingCoordinator:
|
||||
elif item.type == "function_call":
|
||||
# Stream function call arguments
|
||||
# First chunk includes name
|
||||
yield FunctionCallDelta(
|
||||
name=item.data["name"],
|
||||
delta=""
|
||||
)
|
||||
yield FunctionCallDelta(name=item.data["name"], delta="")
|
||||
|
||||
# Stream arguments in chunks
|
||||
args = item.data["arguments"]
|
||||
chunk_size = 20
|
||||
for i in range(0, len(args), chunk_size):
|
||||
yield FunctionCallDelta(
|
||||
delta=args[i:i+chunk_size]
|
||||
)
|
||||
yield FunctionCallDelta(delta=args[i : i + chunk_size])
|
||||
await asyncio.sleep(0.03)
|
||||
|
||||
yield FunctionCallDone()
|
||||
@@ -458,7 +464,7 @@ class StreamingCoordinator:
|
||||
# Only stream the NEW text (delta) since last update
|
||||
if current_text.startswith(last_message_text):
|
||||
# Extract only the new portion
|
||||
delta_text = current_text[len(last_message_text):]
|
||||
delta_text = current_text[len(last_message_text) :]
|
||||
|
||||
if delta_text:
|
||||
# Stream the delta text in chunks while preserving formatting
|
||||
@@ -466,17 +472,16 @@ class StreamingCoordinator:
|
||||
chunk_size = 50 # characters per chunk
|
||||
|
||||
for i in range(0, len(delta_text), chunk_size):
|
||||
chunk = delta_text[i:i+chunk_size]
|
||||
chunk = delta_text[i : i + chunk_size]
|
||||
|
||||
# Check stop sequences on full accumulated text
|
||||
stop_found, text_before_stop = self._check_stop_sequence(
|
||||
current_text,
|
||||
request.stop
|
||||
current_text, request.stop
|
||||
)
|
||||
|
||||
if stop_found:
|
||||
# Only emit remaining delta before stop
|
||||
remaining = text_before_stop[len(last_message_text):]
|
||||
remaining = text_before_stop[len(last_message_text) :]
|
||||
if remaining:
|
||||
yield OutputTextDelta(delta=remaining)
|
||||
yield OutputTextDone()
|
||||
@@ -508,11 +513,12 @@ class StreamingCoordinator:
|
||||
model=request.model,
|
||||
status="completed",
|
||||
output=self._convert_output_items(output_items),
|
||||
usage=usage
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
# Track conversation history (import here to avoid circular dependency)
|
||||
from src.responses.service import _conversation_history
|
||||
|
||||
conversation_id = await _conversation_history.get_conversation_id(request)
|
||||
await _conversation_history.add_response(conversation_id, final_response)
|
||||
|
||||
@@ -534,24 +540,30 @@ class StreamingCoordinator:
|
||||
converted = []
|
||||
for item in items:
|
||||
if item.type == "message":
|
||||
converted.append(MessageOutputItem(
|
||||
id=item.id,
|
||||
content=[OutputTextContent(**c) for c in item.data["content"]],
|
||||
status=item.data.get("status", "completed")
|
||||
))
|
||||
converted.append(
|
||||
MessageOutputItem(
|
||||
id=item.id,
|
||||
content=[OutputTextContent(**c) for c in item.data["content"]],
|
||||
status=item.data.get("status", "completed"),
|
||||
)
|
||||
)
|
||||
elif item.type == "reasoning":
|
||||
converted.append(ReasoningOutputItem(
|
||||
id=item.id,
|
||||
summary=item.data["summary"],
|
||||
status=item.data.get("status", "completed")
|
||||
))
|
||||
converted.append(
|
||||
ReasoningOutputItem(
|
||||
id=item.id,
|
||||
summary=item.data["summary"],
|
||||
status=item.data.get("status", "completed"),
|
||||
)
|
||||
)
|
||||
elif item.type == "function_call":
|
||||
converted.append(FunctionCallOutputItem(
|
||||
id=item.id,
|
||||
name=item.data["name"],
|
||||
arguments=item.data["arguments"],
|
||||
status=item.data.get("status", "completed")
|
||||
))
|
||||
converted.append(
|
||||
FunctionCallOutputItem(
|
||||
id=item.id,
|
||||
name=item.data["name"],
|
||||
arguments=item.data["arguments"],
|
||||
status=item.data.get("status", "completed"),
|
||||
)
|
||||
)
|
||||
|
||||
return converted
|
||||
|
||||
@@ -576,18 +588,10 @@ class StreamingCoordinator:
|
||||
error_type = "internal_error"
|
||||
code = 500
|
||||
|
||||
return ErrorEvent(
|
||||
error={
|
||||
"type": error_type,
|
||||
"message": str(error),
|
||||
"code": code
|
||||
}
|
||||
)
|
||||
return ErrorEvent(error={"type": error_type, "message": str(error), "code": code})
|
||||
|
||||
def _check_stop_sequence(
|
||||
self,
|
||||
accumulated_text: str,
|
||||
stop_sequences: list[str] | None
|
||||
self, accumulated_text: str, stop_sequences: list[str] | None
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Check if any stop sequence is encountered.
|
||||
@@ -624,11 +628,7 @@ class StreamingCoordinator:
|
||||
"""
|
||||
return len(text) // 4
|
||||
|
||||
def _check_max_tokens(
|
||||
self,
|
||||
current_tokens: int,
|
||||
max_tokens: int | None
|
||||
) -> bool:
|
||||
def _check_max_tokens(self, current_tokens: int, max_tokens: int | None) -> bool:
|
||||
"""
|
||||
Check if max tokens limit reached.
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
Tests for Biographer capability registration.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.agents.biographer.capability import (
|
||||
BIOGRAPHER_CAPABILITY,
|
||||
get_biographer_capability,
|
||||
@@ -73,9 +74,7 @@ class TestBiographerRegistration:
|
||||
"src.agents.biographer.capability.get_household_registry",
|
||||
return_value=mock_registry,
|
||||
):
|
||||
with patch(
|
||||
"src.agents.biographer.capability.get_biographer_agent"
|
||||
) as mock_get_agent:
|
||||
with patch("src.agents.biographer.capability.get_biographer_agent") as mock_get_agent:
|
||||
mock_agent = MagicMock()
|
||||
mock_get_agent.return_value = mock_agent
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
Tests for Housekeeper capability registration.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.agents.housekeeper.capability import (
|
||||
HOUSEKEEPER_CAPABILITY,
|
||||
get_housekeeper_capability,
|
||||
@@ -74,9 +75,7 @@ class TestHousekeeperRegistration:
|
||||
"src.agents.housekeeper.capability.get_household_registry",
|
||||
return_value=mock_registry,
|
||||
):
|
||||
with patch(
|
||||
"src.agents.housekeeper.capability.get_housekeeper_agent"
|
||||
) as mock_get_agent:
|
||||
with patch("src.agents.housekeeper.capability.get_housekeeper_agent") as mock_get_agent:
|
||||
mock_agent = MagicMock()
|
||||
mock_get_agent.return_value = mock_agent
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
Tests for the Core-API HTTP client.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src.agents.housekeeper.client import (
|
||||
Area,
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
Tests for Librarian capability registration.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.agents.librarian.capability import (
|
||||
LIBRARIAN_CAPABILITY,
|
||||
get_librarian_capability,
|
||||
@@ -67,9 +68,7 @@ class TestLibrarianRegistration:
|
||||
"src.agents.librarian.capability.get_household_registry",
|
||||
return_value=mock_registry,
|
||||
):
|
||||
with patch(
|
||||
"src.agents.librarian.capability.get_librarian_agent"
|
||||
) as mock_get_agent:
|
||||
with patch("src.agents.librarian.capability.get_librarian_agent") as mock_get_agent:
|
||||
mock_agent = MagicMock()
|
||||
mock_get_agent.return_value = mock_agent
|
||||
|
||||
|
||||
@@ -137,9 +137,7 @@ class TestHybridSearch:
|
||||
assert "docker" in result.keywords
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hybrid_search_empty_results(
|
||||
self, client_with_mock, mock_httpx_client
|
||||
):
|
||||
async def test_hybrid_search_empty_results(self, client_with_mock, mock_httpx_client):
|
||||
"""Test hybrid search with no results."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
@@ -445,9 +443,7 @@ class TestUpdateWikiPage:
|
||||
assert page.tags == ["projects", "devops"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_wiki_page_multiple_fields(
|
||||
self, client_with_mock, mock_httpx_client
|
||||
):
|
||||
async def test_update_wiki_page_multiple_fields(self, client_with_mock, mock_httpx_client):
|
||||
"""Test updating multiple fields at once."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
@@ -622,15 +618,11 @@ class TestExplicitUserContract:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("method_name,kwargs", TENANT_SCOPED_METHODS)
|
||||
async def test_user_from_context_is_sent_on_the_wire(
|
||||
self, method_name, kwargs
|
||||
):
|
||||
async def test_user_from_context_is_sent_on_the_wire(self, method_name, kwargs):
|
||||
"""With no explicit user, the context user is resolved and sent."""
|
||||
client, mock_httpx = self._wire_client()
|
||||
|
||||
with patch(
|
||||
"src.agents.librarian.client.get_user", return_value="llm_tester"
|
||||
):
|
||||
with patch("src.agents.librarian.client.get_user", return_value="llm_tester"):
|
||||
await getattr(client, method_name)(**kwargs)
|
||||
|
||||
assert self._sent_user(mock_httpx) == "llm_tester"
|
||||
@@ -647,9 +639,7 @@ class TestExplicitUserContract:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("method_name,kwargs", TENANT_SCOPED_METHODS)
|
||||
async def test_empty_context_user_fails_before_any_request(
|
||||
self, method_name, kwargs
|
||||
):
|
||||
async def test_empty_context_user_fails_before_any_request(self, method_name, kwargs):
|
||||
"""An empty resolved user raises before any bytes hit the wire."""
|
||||
client, mock_httpx = self._wire_client()
|
||||
|
||||
@@ -698,9 +688,7 @@ class TestExplicitUserContract:
|
||||
from src.core import config as config_module
|
||||
from src.core.config import Environment
|
||||
|
||||
monkeypatch.setattr(
|
||||
config_module.config, "ENVIRONMENT", Environment.DEVELOPMENT
|
||||
)
|
||||
monkeypatch.setattr(config_module.config, "ENVIRONMENT", Environment.DEVELOPMENT)
|
||||
client, mock_httpx = self._wire_client()
|
||||
|
||||
await getattr(client, method_name)(user=explicit_user, **kwargs)
|
||||
@@ -708,16 +696,12 @@ class TestExplicitUserContract:
|
||||
assert self._sent_user(mock_httpx) == "llm_tester"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_production_tenant_passes_through_in_prod(
|
||||
self, monkeypatch
|
||||
):
|
||||
async def test_explicit_production_tenant_passes_through_in_prod(self, monkeypatch):
|
||||
"""In production the production tenant is sent unchanged."""
|
||||
from src.core import config as config_module
|
||||
from src.core.config import Environment
|
||||
|
||||
monkeypatch.setattr(
|
||||
config_module.config, "ENVIRONMENT", Environment.PRODUCTION
|
||||
)
|
||||
monkeypatch.setattr(config_module.config, "ENVIRONMENT", Environment.PRODUCTION)
|
||||
client, mock_httpx = self._wire_client()
|
||||
|
||||
await client.hybrid_search("q", user="jpmschweitzer")
|
||||
|
||||
@@ -91,9 +91,7 @@ class TestBoundedRetries:
|
||||
mock_httpx.post.side_effect = httpx.ConnectError("Connection refused")
|
||||
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
await client_with_mock.create_wiki_page(
|
||||
title="T", path="/t", content="c", user="u"
|
||||
)
|
||||
await client_with_mock.create_wiki_page(title="T", path="/t", content="c", user="u")
|
||||
|
||||
assert mock_httpx.post.call_count == 1
|
||||
|
||||
@@ -103,9 +101,7 @@ class TestBoundedRetries:
|
||||
mock_httpx.post.side_effect = httpx.ConnectError("Connection refused")
|
||||
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
await client_with_mock.smart_create_wiki_page(
|
||||
topic="T", tags=["x"], user="u"
|
||||
)
|
||||
await client_with_mock.smart_create_wiki_page(topic="T", tags=["x"], user="u")
|
||||
|
||||
assert mock_httpx.post.call_count == 1
|
||||
|
||||
@@ -184,9 +180,7 @@ class TestModelRetryEscalation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_tool_raises_model_retry_on_transport_error(self):
|
||||
mock_client = AsyncMock()
|
||||
mock_client.hybrid_search.side_effect = httpx.ConnectError(
|
||||
"Connection refused"
|
||||
)
|
||||
mock_client.hybrid_search.side_effect = httpx.ConnectError("Connection refused")
|
||||
|
||||
with self._patched_client(mock_client):
|
||||
with pytest.raises(ModelRetry):
|
||||
@@ -219,14 +213,10 @@ class TestModelRetryEscalation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_tool_never_raises_model_retry(self):
|
||||
mock_client = AsyncMock()
|
||||
mock_client.create_wiki_page.side_effect = httpx.ConnectError(
|
||||
"Connection refused"
|
||||
)
|
||||
mock_client.create_wiki_page.side_effect = httpx.ConnectError("Connection refused")
|
||||
|
||||
with self._patched_client(mock_client):
|
||||
result = await create_wiki_page(
|
||||
title="T", path="/t", content="c", tags=["x"]
|
||||
)
|
||||
result = await create_wiki_page(title="T", path="/t", content="c", tags=["x"])
|
||||
|
||||
assert "unable" in result
|
||||
assert "Connection refused" not in result
|
||||
|
||||
@@ -83,9 +83,7 @@ class TestHybridRAGContract:
|
||||
assert source in SOURCE_ICONS, f"no icon for source '{source}'"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_maps_to_formatted_context(
|
||||
self, client_with_recorded_response
|
||||
):
|
||||
async def test_context_maps_to_formatted_context(self, client_with_recorded_response):
|
||||
"""Top-level 'context' field maps to formatted_context."""
|
||||
response = await client_with_recorded_response.hybrid_search(
|
||||
"home server infrastructure", user="testuser"
|
||||
@@ -94,9 +92,7 @@ class TestHybridRAGContract:
|
||||
assert response.formatted_context != ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keywords_and_synonyms_from_dict(
|
||||
self, client_with_recorded_response
|
||||
):
|
||||
async def test_keywords_and_synonyms_from_dict(self, client_with_recorded_response):
|
||||
"""keywords is a dict: core_keywords + nested synonyms map."""
|
||||
response = await client_with_recorded_response.hybrid_search(
|
||||
"home server infrastructure", user="testuser"
|
||||
@@ -124,9 +120,7 @@ class TestHybridRAGContract:
|
||||
assert len(response.related_dossiers) == len(set(response.related_dossiers))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_payload_never_sends_zero_limits(
|
||||
self, client_with_recorded_response
|
||||
):
|
||||
async def test_payload_never_sends_zero_limits(self, client_with_recorded_response):
|
||||
"""The live service 422s on limits < 1; disabled legs use enable_* flags."""
|
||||
await client_with_recorded_response.hybrid_search(
|
||||
"home server infrastructure",
|
||||
@@ -151,24 +145,18 @@ class TestHybridRAGContract:
|
||||
assert config["enable_volatile"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_always_sent_as_query_param(
|
||||
self, client_with_recorded_response
|
||||
):
|
||||
async def test_user_always_sent_as_query_param(self, client_with_recorded_response):
|
||||
"""The tenant is always sent explicitly - library-desk is removing
|
||||
its server-side default, so a missing user would 422."""
|
||||
await client_with_recorded_response.hybrid_search(
|
||||
"home server infrastructure", user="testuser"
|
||||
)
|
||||
|
||||
params = client_with_recorded_response._client.post.call_args.kwargs[
|
||||
"params"
|
||||
]
|
||||
params = client_with_recorded_response._client.post.call_args.kwargs["params"]
|
||||
assert params["user"] == "testuser"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_counts_and_timing_parsed(
|
||||
self, client_with_recorded_response
|
||||
):
|
||||
async def test_source_counts_and_timing_parsed(self, client_with_recorded_response):
|
||||
"""source_counts and timing map into the response model."""
|
||||
response = await client_with_recorded_response.hybrid_search(
|
||||
"home server infrastructure", user="testuser"
|
||||
@@ -178,9 +166,7 @@ class TestHybridRAGContract:
|
||||
assert response.timing.get("total_ms", 0) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_status_absent_is_tolerated(
|
||||
self, client_with_recorded_response
|
||||
):
|
||||
async def test_source_status_absent_is_tolerated(self, client_with_recorded_response):
|
||||
"""Recorded response predates source_status/degraded - defaults apply."""
|
||||
response = await client_with_recorded_response.hybrid_search(
|
||||
"home server infrastructure", user="testuser"
|
||||
@@ -218,7 +204,9 @@ class TestHybridRAGContract:
|
||||
assert response.source_status["volatile"] == "disabled"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_renders_no_unknown_results(self, client_with_recorded_response, monkeypatch):
|
||||
async def test_tool_renders_no_unknown_results(
|
||||
self, client_with_recorded_response, monkeypatch
|
||||
):
|
||||
"""The hybrid_search tool renders real sources and non-zero scores."""
|
||||
|
||||
class _Factory:
|
||||
@@ -231,9 +219,7 @@ class TestHybridRAGContract:
|
||||
async def __aexit__(self, *args):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.agents.librarian.tools.LibraryDeskClient", _Factory()
|
||||
)
|
||||
monkeypatch.setattr("src.agents.librarian.tools.LibraryDeskClient", _Factory())
|
||||
|
||||
output = await hybrid_search("home server infrastructure")
|
||||
|
||||
@@ -278,9 +264,7 @@ class TestCoverageNote:
|
||||
|
||||
def test_wiki_leg_absence_is_not_degradation(self):
|
||||
"""vector/graph missing from top-N counts is healthy ranking, not outage."""
|
||||
response = self._response(
|
||||
source_counts={"web": 2, "documents": 1, "volatile": 1}
|
||||
)
|
||||
response = self._response(source_counts={"web": 2, "documents": 1, "volatile": 1})
|
||||
note = _coverage_note(
|
||||
response, include_web=True, include_documents=True, include_volatile=True
|
||||
)
|
||||
|
||||
@@ -35,9 +35,7 @@ def _nullable_anyof_paths(schema: object, path: str = "") -> list[str]:
|
||||
class TestLibrarianToolSchemas:
|
||||
"""All registered librarian tools emit Ollama-safe parameter schemas."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_func", LIBRARIAN_TOOLS, ids=lambda f: f.__name__
|
||||
)
|
||||
@pytest.mark.parametrize("tool_func", LIBRARIAN_TOOLS, ids=lambda f: f.__name__)
|
||||
def test_no_nullable_anyof_in_schema(self, tool_func):
|
||||
schema = Tool(tool_func).function_schema.json_schema
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user