21 Commits
Author SHA1 Message Date
jpmschweitzer 9d34b94bfb fix(permissions): narrow rm -rf deny globs to their exact forms
The trailing wildcard on the three rm -rf deny entries spanned path
separators, so Bash(rm -rf /*) matched every absolute path on the
machine rather than the filesystem root, and the ~ and $HOME entries
had the same shape. Narrowed to the exact literal forms.

These rules match literal command text, so they still stop a typo on
rm -rf /, rm -rf ~ or rm -rf $HOME exactly, but they no longer stop a
recursive delete aimed at any other path. That reduced cover is
deliberate, not an oversight.
2026-08-25 20:31:29 +02:00
jpmschweitzer 648b848747 fix(setup): prove the venv works, and fix the dev install it was faking
`make setup` exited 0 whether or not the environment worked (D-24) — and
it turns out it didn't: `pip install -e ".[dev]"` targeted a `[dev]`
extra that pyproject.toml has never declared, so pip only warned and
silently installed zero dev dependencies. Discovered by the new check
on its first run against a clean venv.

Switch the install to `-r requirements-dev.txt -e .`, the real dev
dependency list, and end setup with `pytest --collect-only` — it
exercises the whole import graph (src.main, every domain, every
dev/test dependency pytest itself needs), so it fails the target on a
missing dependency instead of reporting success for an unusable env.

setup still covers webber-api only; webber-cli and webber-sandbox have
their own pyproject.toml/venv and are flagged, not silently included
(T-47).
2026-08-17 12:08:14 +02:00
jpmschweitzerandClaude 7172927a79 release api/v1.1.0
Build and Push API / release (push) Successful in 2s
Build and Push API / build (push) Successful in 2m17s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 16:30:09 +02:00
jpmschweitzerandClaude 3e495daa73 fix(webber-api): clear mypy, and the dead code it was covering for
55 errors to zero. Nearly all of them traced back to two causes rather than 55.

THE DECORATOR. @logged wraps ~24 functions across this package and was declared
`def decorator(func: Callable):` with no ParamSpec and no return annotation, so
it erased the signature of everything it touched. ToolResult.execute() is
annotated `-> ToolResult`; through the decorator it came back Any, and mypy
reported 33 no-any-return errors spread across the tools and agents. Each looked
like a local annotation slip. All of them were one decorator. Typed with
ParamSpec/TypeVar; the async branch casts at the await rather than loosening R,
because loosening R would put the Any straight back into every caller.

THE MISSING TYPE PARAMETER. BaseAgent was not generic, so _create_agent returned
a bare Agent — Agent[Any, Any] — and pydantic_ai then typed every run() result
as Any. BaseAgent is now Generic[CtxT] bound to AgentContext, _agent is declared
on the base instead of reached through hasattr, and the three tool-registration
functions take their agent's real context type. tools_streaming.py already did
this; the other three had not been updated.

Eight `execute` overrides carry a targeted ignore rather than a package-wide
disable_error_code. Every tool narrows the base's **kwargs to its own named
parameters, which is a real LSP violation — but nothing anywhere is typed as
BaseTool, and every call site constructs the concrete tool. The abstract method
earns its place by making a tool without execute impossible to instantiate. The
reasoning lives in BaseTool.execute's docstring; the per-site suppressions mean
an override that IS unsound still gets caught.

BaseAgent.run_stream widened to AsyncIterator[str | StreamEvent], which is what
callers already receive: task streams structured events, explore and plan stream
strings, and the router branches on isinstance with a comment calling the string
path legacy. The annotation now says what the code does.

AND THE PART THAT MATTERS MORE THAN THE TYPES.

Chasing the last error found that the Ollama sanitiser has been broken. It
fetched the parent's chat getter with `AsyncOpenAI.chat.fget`, and openai made
`chat` a functools.cached_property, whose getter is `.func`. Touching `.chat`
raised AttributeError — meaning the content: null workaround that CLAUDE.md
documents as live would have failed on the first completion any agent attempted.
Confirmed in the running container (openai 2.46.0) as well as locally (2.15.0).

Two things hid it. The line carried a bare `# type: ignore`, which suppressed
precisely the complaint that would have caught it. And /agents/run and
/agents/stream have served zero requests in 30 days, so nothing exercised the
path. A mitigation can rot completely while every check stays green, if no check
actually runs it.

The lookup now reads whichever getter the descriptor exposes and raises a
legible TypeError if openai adopts a third shape. tests/test_ollama_provider.py
walks the chain an agent request walks, short of the network call —
mutation-checked: all four fail against the old lookup.

215 passed, 23 skipped, plus the four new. mypy clean over 90 files.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 16:27:51 +02:00
jpmschweitzerandClaude eb3467d06a fix(webber-api): clear ruff, and two things it was pointing at
97 findings to zero. Most were mechanical — 52 unsorted import blocks, 10
unsorted __all__, assorted pyupgrade and simplify hints. Two were not, and both
were visible only because the lint made me look.

`webber version` did not exist. src/cli/commands/version.py defines
show_version(), main.py imported it, and the registration line was never
written — the CLI exposed chat and explore only. The import carried
`# noqa: F401`, which is what kept the omission quiet: someone marked the
symptom as intentional instead of asking why it was unused. show_version is not
redundant with the --version flag; it prints the resolved Ollama URL, model and
debug state, which is the form worth having when something is misconfigured.
Registered, and the suppression dropped because the import is now genuinely used.

test_spawn_explore_agent asserted nothing. It built a mock RunContext, patched
get_agent, and stopped at the comment "For now, verify the explore agent would
be called correctly". It had been counted as a passing test. An AST sweep of all
238 test functions found it was the only one, which is worth knowing — the
problem was contained, not systemic. It is now skipped with a reason, so it
reports as unfinished rather than as passing. Reducing it rather than deleting
its imports was the point: tidying the imports would have made a hollow test
look clean.

Two findings were false positives, and both are recorded rather than silently
worked around:

B023 flagged run_agent closing over full_prompt and ctx. Traced: agent_task is
awaited at line 326 before `continue` reaches the next iteration, so neither
name can be rebound while the closure is pending, and the exception path
cancels and awaits too. Not a bug. Bound as defaults anyway, because that stays
true if the await ever moves. I had called it a live bug before tracing it,
which is the mistake Rule 5 exists for.

RUF012 flagged `rules: list[ApprovalRule] = []` on ApprovalRuleSet. Its
suggested fix — annotate ClassVar — would remove the field from the model.
ApprovalRuleSet is a pydantic model and pydantic deep-copies defaults per
instance; verified by constructing two and confirming their lists are distinct
objects. Suppressed with that evidence in the comment. Ruff cannot see the
pydantic base because BaseSchema is a local subclass of BaseModel.

Also moved a stray `from src.shared.logging import ...` that had drifted below
a function definition, and merged a nested if in the ollama provider.

215 passed, 23 skipped, unchanged except for the new skip. `webber version`
exercised end to end.

mypy is NOT addressed here and the gate still fails on it — 55 errors in 14
files, 35 of them no-any-return from pydantic_ai's untyped returns. That was
hidden behind ruff, because the gate stops at the first failing stage.

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 18:57:22 +02:00
jpmschweitzerandClaude 225dc74385 ci(make): reserve exit 69 for "could not run" (D-26)
Environment guards now exit 69 rather than 1, so a caller can tell a suite
that could not start from one that ran and failed. The first toj test sweep
reported "3 repositories failed" and none of the three had executed a test —
two could not find go, one had no venv. That points the reader at the tests
when the fault is in the environment.

Only the environment guards change. A gitleaks finding, a failed test run and
a vulncheck hit still exit 1, because those did run and did fail.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 15:56:20 +02:00
jpmschweitzerandClaude 80d5cc1c3e build: add the Makefile command surface (D-27)
Every repo gets one at the root: help, plus test and lint where those exist.
The point is that a target name means the same thing in every repo, so an
agent or a person can act without reading the repo first.

Paths resolve here rather than in callers (D-10). python3 on this host is 3.8
and cannot parse these sources, and a bare pytest or ruff resolves only in a
login shell — so both are named explicitly through the venv, and a missing
venv fails with the command to fix it rather than a bare no-such-file.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 12:46:55 +02:00
jpmschweitzerandClaude b4d5c4d9b2 docs: correct the vault policy, and qualify workspace decision ids
This repo's Work tracking section still described the reversed policy --
that tickets and decisions live in the workspace vault and this repo's
trees stay empty. That was overturned the same day: repo vaults are
standalone and a repo's work travels with a clone, because the changelog
is committed.

Every other repo was corrected at the time; this one was missed because
the search for the offending phrase used a fixed string and the phrase
happened to wrap across a line break here. Worth noting as a search
failure rather than a writing one -- five files were checked, four
matched, and the fifth was reported clean.

Decision ids are also qualified now. They are per-vault sequences, so a
bare D-15 here will mean this repo's D-15 the moment this repo records
one; pql already holds D-1 through D-31 against the workspace's D-1
through D-21, all of them unrelated.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 04:17:29 +02:00
jpmschweitzerandClaude 8eec3b68d6 docs: replace both AGENTS.md files with one CLAUDE.md
One agent doc per repo, and it is CLAUDE.md. This repo carried two --
one at the root and one under webber-api/ -- which is the drift problem
in its purest form: two documents, one subject, and no way to know which
the last reader trusted. Written fresh rather than reformatted.

README.md linked to webber-api/AGENTS.md, so that pointer moves with the
file rather than dangling.

The architecture section states the method used to establish what is
live -- import the app inside the container and read sys.modules -- and
then the case where that method fails here. src/domains/tools is absent
from a cold snapshot and is entirely live: each agent's _register_tools
imports its tool package from inside the method body, on every
/agents/run. Absence from a snapshot taken before any request is served
is a timing artifact, not evidence of death, and deleting on that basis
would have removed the tool layer.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 03:14:50 +02:00
jpmschweitzerandClaude 401c7f4e8c chore: adopt the workspace agent-config baseline
Commits a .claude/settings.json rather than leaving permissions to
per-developer local state, and initialises a pql vault for this repo's
tickets and internal decisions.

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 03:14:24 +02:00
jpmschweitzerandClaude 9612c7af05 docs(architecture): correct agent model to gemma4:e2b
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 14:59:12 +02:00
jpmschweitzerandClaude 9d58df85f3 fix(config): default agent model to gemma4:e2b
mistral-nemo-large holds ~9.2 GB of the 11 GB card it shares with Speaches,
which starves Whisper and breaks voice transcription. gemma4:e2b holds
1.9 GB and is faster. The deployed stack already overrides this via
OLLAMA_AGENT_MODEL; this aligns the default so a deployment without that
override does not reintroduce the contention.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 14:58:57 +02:00
jpmschweitzerandClaude 3de96fe070 docs: correct Ollama model references to gemma4:e2b
The deployed agent model is gemma4:e2b; these references still named
mistral-nemo, so the setup and troubleshooting steps checked for a model
that is no longer expected to be present.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 14:51:58 +02:00
jpmschweitzerandClaude Fable 5 e2acb7de39 docs(architecture): registry is git.schweitz.net not git.schweitz.internal
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 17:11:08 +02:00
jpmschweitzerandClaude Fable 5 5998571890 chore: release api v1.0.1
Build and Push API / release (push) Successful in 2s
Build and Push API / build (push) Successful in 1m39s
Patch release for the network migration: docker-hostname config
defaults for Tatlock/SearXNG and CI image push via the
git.schweitz.net registry route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:32:02 +02:00
jpmschweitzerandClaude Fable 5 6ac1d15591 fix(config): use docker hostnames for tatlock and searxng defaults
The homelab is retiring *.schweitz.internal and will rebind host ports
to 127.0.0.1, so container-to-container traffic must use container
names on the docker-dataplane network. Switch defaults from host
IP:port to http://tatlock:8000 and http://searxng:8080 (SearXNG's
internal port is 8080; 8087 is only the host-published port).

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:10:35 +02:00
78 changed files with 1605 additions and 660 deletions
+67
View File
@@ -0,0 +1,67 @@
{
"env": {
"PQL_VAULT": "/mnt/media/Projects/webber"
},
"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(.venv/bin/python -m pytest:*)",
"Bash(.venv/bin/pytest:*)",
"Bash(pytest:*)",
"Bash(ruff *)",
"Bash(mypy *)",
"Bash(docker logs webber:*)",
"Bash(curl -s http://localhost:8086/*)",
"Bash(curl -s http://localhost:8095/*)"
],
"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(rm -rf $HOME)",
"Bash(rm -rf /)",
"Bash(rm -rf ~)",
"Bash(su *)",
"Bash(sudo *)",
"Bash(toj)",
"Bash(toj:*)"
]
}
}
+1
View File
@@ -0,0 +1 @@
.pql/changelog/*.sql merge=union
+2 -2
View File
@@ -6,7 +6,7 @@ on:
- 'api/v*' - 'api/v*'
env: env:
IMAGE_NAME: git.schweitz.internal/jpmschweitzer/webber-api IMAGE_NAME: git.schweitz.net/jpmschweitzer/webber-api
jobs: jobs:
release: release:
@@ -44,7 +44,7 @@ jobs:
- name: Login to Gitea Registry - name: Login to Gitea Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: git.schweitz.internal registry: git.schweitz.net
username: ${{ secrets.REGISTRY_USER }} username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }} password: ${{ secrets.REGISTRY_PASSWORD }}
+13
View File
@@ -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
View File
@@ -75,3 +75,16 @@ webber-sandbox/.current_template
# Ruff cache # Ruff cache
.ruff_cache/ .ruff_cache/
# 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
+11
View File
@@ -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
+139
View File
@@ -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);
+139
View File
@@ -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);
+139
View File
@@ -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);
-289
View File
@@ -1,289 +0,0 @@
# Webber Monorepo - Agent Instructions
> **Start every session by reading this file.**
> This file contains everything you need to work with this codebase efficiently.
## Quick Reference
| Action | Command |
|--------|---------|
| Start API server | `cd webber-api && ./wakeup.sh` |
| View API logs | `tail -f webber-api/logs/server.log` |
| Run API tests | `cd webber-api && .venv/bin/python -m pytest tests/ -v` |
| Check CLI status | `cd webber-cli && .venv/bin/webber-cli status` |
| Load sandbox | `./sandbox.sh load calculator-cli` |
| Explore sandbox | `cd webber-cli && .venv/bin/webber-cli explore "query" -d ../webber-sandbox` |
---
## Repository Structure
```
webber/
├── webber-api/ # FastAPI backend server
│ ├── src/ # API source code
│ ├── tests/ # API tests (pytest)
│ ├── docs/ # Architecture docs, COVERAGE.md
│ ├── logs/ # Runtime logs (server.log)
│ ├── .venv/ # API virtual environment
│ ├── wakeup.sh # Dev server startup script
│ └── AGENTS.md # API-specific development guide
├── webber-cli/ # CLI client
│ ├── webber_cli/ # Python package (underscore!)
│ ├── .venv/ # CLI virtual environment
│ └── README.md # CLI usage guide
├── webber-sandbox/ # Active test project (contents swappable)
│ ├── src/ # Current project source
│ ├── tests/ # Current project tests
│ ├── .venv/ # Sandbox virtual environment
│ └── TASKS.md # Tasks for Webber to complete
├── sandbox-templates/ # Template storage
│ ├── calculator-cli/ # Simple CLI with intentional bugs
│ └── empty/ # Blank starter project
├── sandbox.sh # Sandbox management script
└── AGENTS.md # THIS FILE
```
---
## Development Workflow
### 1. Start the API Server
```bash
cd webber-api
./wakeup.sh
```
- **Port:** 8095 (dev), 8086 (production Docker)
- **Logs:** `webber-api/logs/server.log`
- **Health check:** `curl http://localhost:8095/health`
- **API docs:** http://localhost:8095/docs
To stop: `Ctrl+C` or `pkill -f "uvicorn src.main:app"`
### 2. Run Tests
```bash
# API tests (39 tests)
cd webber-api
.venv/bin/python -m pytest tests/ -v
# With coverage
.venv/bin/python -m pytest tests/ --cov=src
# Single test file
.venv/bin/python -m pytest tests/test_tools.py -v
```
### 3. Use the CLI
```bash
cd webber-cli
# Check API connection
.venv/bin/webber-cli status
# Interactive chat (default mode - full capabilities)
.venv/bin/webber-cli chat -d ../webber-sandbox
# Read-only mode (safe exploration and planning)
.venv/bin/webber-cli chat --mode plan -d ../webber-sandbox
# Auto-accept mode (no approval prompts - use with caution)
.venv/bin/webber-cli chat --mode auto_accept -d ../webber-sandbox
# List previous sessions
.venv/bin/webber-cli sessions
# Resume a previous session
.venv/bin/webber-cli chat --resume <session-id>
```
**CLI Features:**
- **Tab completion** for commands and file paths
- **Command history** persisted to `~/.webber_history`
- **Session persistence** - conversations saved and resumable
- **Config file** - persistent settings via `~/.webber/config.toml`
- **Runtime mode switching** via `mode plan|default|auto_accept`
- **Directory navigation** via `cd <path>`
**Configuration:**
```bash
# Show current config
.venv/bin/webber-cli config
# Initialize config file with defaults
.venv/bin/webber-cli config --init
```
**Note:** The API server must be running for CLI commands to work.
---
## Sandbox Management
The sandbox is a swappable test project for functional testing.
### Available Templates
| Template | Description |
|----------|-------------|
| `calculator-cli` | Python CLI with intentional bugs (div-by-zero, missing tests) |
| `empty` | Blank starter project |
### Commands
```bash
# List available templates
./sandbox.sh list
# Load a template (clears sandbox, preserves .venv)
./sandbox.sh load calculator-cli
# Reset to last loaded template
./sandbox.sh reset
# Save current sandbox as new template
./sandbox.sh save my-template
# Check current status
./sandbox.sh status
```
### After Loading a Template
```bash
cd webber-sandbox
source .venv/bin/activate # Create .venv first if missing
pip install -r requirements.txt
# Read the tasks
cat TASKS.md
# Run the project's tests
pytest tests/ -v
```
---
## Testing Webber's Capabilities
### Scenario: Find bugs in calculator-cli
```bash
# 1. Load the template
./sandbox.sh load calculator-cli
# 2. Have Webber explore it (plan mode = read-only)
cd webber-cli
.venv/bin/webber-cli chat --mode plan -d ../webber-sandbox
# Then ask: "find all bugs in the code"
# 3. Check TASKS.md for expected bugs
cat ../webber-sandbox/TASKS.md
```
### Known bugs in calculator-cli:
- Division by zero not handled (`operations.py:divide`)
- Invalid operation causes KeyError (`main.py:get_operation`)
- Power function broken for fractional exponents
- Missing tests for divide and power functions
---
## Key Files for Debugging
| File | Purpose |
|------|---------|
| `webber-api/logs/server.log` | API server logs |
| `webber-api/src/domains/agents/explore/prompts.py` | Explore agent system prompts |
| `webber-api/src/domains/agents/explore/agent.py` | Explore agent implementation |
| `webber-api/src/ollama/provider.py` | Ollama integration (sanitizes content:null) |
| `webber-api/docs/COVERAGE.md` | Feature coverage and known issues |
---
## Versioning & Releases
Uses prefixed tags:
- `api/vX.Y.Z` → Triggers API Docker build
- `cli/vX.Y.Z` → Triggers CLI build (future)
### MANDATORY Release Procedure
**NEVER push a tag before updating version files.** Follow this exact order:
```bash
# For API releases:
# 1. Update version in webber-api/pyproject.toml
# 2. Update webber-api/CHANGELOG.md with release notes
# 3. Commit the version bump
git add -A && git commit -m "chore: release api vX.Y.Z"
# 4. Create the tag (AFTER the commit)
git tag api/vX.Y.Z
# 5. Push everything together
git push origin main --tags
# For CLI releases:
# 1. Update version in webber-cli/pyproject.toml
# 2. Update webber-cli/CHANGELOG.md with release notes
# 3. Commit the version bump
git add -A && git commit -m "chore: release cli vX.Y.Z"
# 4. Create the tag (AFTER the commit)
git tag cli/vX.Y.Z
# 5. Push everything together
git push origin main --tags
```
**Why this matters:** Pushing a tag before the version commit requires deleting and recreating the tag, which can trigger CI/CD pipelines prematurely and cause deployment issues.
---
## Troubleshooting
### API server won't start
```bash
# Check if port is in use
lsof -i :8095
# Kill stuck process
pkill -f "uvicorn src.main:app"
```
### CLI can't connect
```bash
# Check API is running
curl http://localhost:8095/health
# Check CLI config
echo $WEBBER_API_URL # Should be http://localhost:8095
```
### Ollama errors
```bash
# Check Ollama is running
curl http://192.168.86.149:11434/api/tags
# Check model is available
curl http://192.168.86.149:11434/api/tags | grep mistral-nemo
```
### Tests failing
```bash
# Run with verbose output
cd webber-api
.venv/bin/python -m pytest tests/ -v --tb=short
```
---
## Known Limitations
1. **Model hallucination** - Mistral Nemo sometimes makes up file contents instead of using tool results
See `webber-api/docs/COVERAGE.md` for full feature coverage status.
+189
View File
@@ -0,0 +1,189 @@
# CLAUDE.md — webber
Local-LLM multi-agent development assistant — "similar to Claude Code but running locally"
(`webber-api/docs/architecture.md`), backed by Ollama via PydanticAI. Logical monorepo, single
`.git`, three subprojects: `webber-api/` (FastAPI server, deployed), `webber-cli/` (Typer CLI
client), `webber-sandbox/` (swappable test project used by `sandbox.sh`, not shipped).
## Ports
| | Port | How |
|---|---|---|
| Local dev | **8095** | `cd webber-api && ./wakeup.sh`, uvicorn `--reload`, logs to `webber-api/logs/server.log` |
| Production | **8086** | container `webber`, confirmed running (`docker ps`) on `docker-dataplane` |
`wakeup.sh` refuses to start if 8095 is already bound — it does not silently pick another
port. Testing `localhost:8086` on the dev box hits the *container*, not your reload server.
## Live contract
`http://localhost:8086/openapi.json` — 10 paths, `version: 1.0.1` (verified 2026-08-09,
matches `webber-api/pyproject.toml` and the live `/health` response). Human docs at
`http://localhost:8086/docs`. Query the live spec rather than inferring routes from source —
`src/domains/router.py` currently has two routers commented out (see Architecture), so a
source read alone will overcount if you don't check whether an include is live.
```
/, /health, /agents/, /agents/run, /agents/stream, /agents/{agent_type},
/conversations/, /conversations/{conversation_id},
/conversations/{conversation_id}/messages, /conversations/{conversation_id}/save
```
## Architecture
Domain-first layout under `webber-api/src/domains/<name>/`. `src/main.py` includes exactly one
router, `src.domains.router.root_router`, which composes the domain routers. A full directory
map lives in `webber-api/docs/architecture.md` — read that before adding a domain rather than
duplicating it here.
**How liveness below was established:** `docker exec webber python3 -c "import src.main; import
sys; print(sorted(m for m in sys.modules if m.startswith('src.')))"` — i.e. importing the real
app inside the running container and reading `sys.modules`, not grepping `main.py`. Re-run that
command to re-check; a grep of imports will miss function-body imports, and this repo has one
that matters.
- **Wired at startup, serving routes:** `src.domains.health`, `src.domains.agents` (router +
`explore`/`plan`/`task` agent packages), `src.domains.conversations`, `src.shared.*`,
`src.ollama`, `src.db` (imported both by `conversations/router.py` at module scope and by
`main.py`'s lifespan shutdown handler).
- **Present in source, explicitly disabled:** `src/domains/router.py` has
`# from src.domains.auth.router import router as auth_router` and the equivalent for
`tools_router` — both commented out with the include calls also commented out. `src/domains/auth/`
is just an empty `__init__.py`. This one *is* dead — the disabling is visible in the same file,
not a matter of tracing an indirect import.
- **The trap: `src/domains/tools/` is not in `sys.modules` right after `import src.main`, but it
is not dead.** `src/domains/agents/{explore,plan,task}/agent.py` each have a method
(e.g. `PlanAgent._register_tools`) that does `from src.domains.agents.plan.tools import
register_plan_tools` **inside the function body**, called every time that agent is
constructed — i.e. on every `/agents/run` or `/agents/stream` request for that agent type.
That nested module then imports the real tool classes from `src.domains.tools.file`,
`.search`, `.shell` at module scope. A static snapshot taken before any request is served
will not show `src.domains.tools` loaded; that is a timing artifact, not evidence it is
unused. Don't delete `src/domains/tools/` on the strength of a `sys.modules` check alone —
confirm by hitting `/agents/run` and re-checking, or by tracing the call graph from each
agent's `_register_tools`.
- **`src/cli/`** is the implementation behind `webber-cli`'s `pyproject.toml` script entry —
it is a separate Typer app, not imported by the API (`src.main`) at all. Its liveness is
"is the CLI installed and invoked", not "is it wired into the API process".
Group new work by domain, not file type — `webber-api/docs/fastapi-best-practices.md` is the
house reference (mirrors the convention used across the other in-house FastAPI services here).
## Database
SQLite by default (`database_url = "sqlite+aiosqlite:///./webber.db"` in
`src/shared/config.py`), not Postgres — confirmed by reading `src/shared/config.py` and
`src/db/database.py` (the latter's docstring says the pattern is ported from core-api, but
the backend differs). Models under `webber-api/src/domains/<name>/models.py` import `Base`
from `src/db/models.py`. No Alembic here (unlike core-api) — did not find a migrations
directory; unverified whether schema changes have any managed migration path at all. Check
before assuming one exists.
## Working here
**Test locally first.** `cd webber-api && ./wakeup.sh` auto-reloads on code changes (not on
`requirements.txt` changes — restart after adding a dependency). Deploy only once a feature
is complete and tested.
```bash
cd webber-api
.venv/bin/python -m pytest tests/ # all tests
.venv/bin/python -m pytest tests/ -v --cov # verbose + coverage
.venv/bin/python -m pytest tests/test_tools.py -v # single file
```
`webber-api/pyproject.toml` declares `[tool.ruff]` and `[tool.mypy]` — unlike core-api, this
repo does have ruff/mypy config; whether either runs in CI is a separate question (see CI below
— it does not).
Copy `webber-api/.env.example` to `webber-api/.env`. Notable defaults: `OLLAMA_URL` points at
`192.168.86.149:11434` (the host's Ollama, not a container), `OLLAMA_AGENT_MODEL=gemma4:e2b`,
optional Tatlock integration via `TATLOCK_API_URL`/`INTERNAL_API_KEY`, optional SearXNG via
`SEARXNG_URL` for the `web_search` tool.
### Sandbox
`webber-sandbox/` is a disposable project used to exercise the agents end-to-end, managed by
`./sandbox.sh {list,load,reset,save,status}` from the repo root. `sandbox-templates/` holds the
reusable templates (`calculator-cli` has intentionally-seeded bugs for testing Explore/Task).
This directory is fixture material, not shipped code — do not treat bugs in it as real bugs.
### CLI
`webber-cli/` is a Typer client (`webber-cli status|chat|explore|sessions|config`) with tab
completion, session persistence (`~/.webber_history`, `~/.webber/config.toml`), and three chat
modes (`plan` read-only, `default`, `auto_accept`). It talks to the API over HTTP — it does not
share a process with `webber-api`. Run it from its own venv: `cd webber-cli && .venv/bin/webber-cli status`.
## CI
`.gitea/workflows/build-api.yml` triggers only on `api/vX.Y.Z` tags: creates a Gitea release,
builds/pushes `git.schweitz.net/jpmschweitzer/webber-api`, then pings Watchtower.
`build-cli.yml` triggers on `cli/vX.Y.Z` tags but is a placeholder — it only echoes a TODO, it
does not build or publish anything. **No test or lint gate runs in CI for either package**
pytest and ruff only run locally or on request. Verify tests pass before tagging.
## Work tracking
Work lives in **pql**, not a markdown TODO or `docs/COVERAGE.md`. **This repo's vault is
standalone** — its tickets and its 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, which is this repo.
```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-level decisions that constrain this service live in the **workspace** vault and need the
flag:
```bash
/home/jpmschweitzer/.local/bin/pql --vault /mnt/media/Projects decisions list --domain webber
```
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.
Do not add a TODO section to a markdown file.
## 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` previously mandated `feature/...` or
`fix/...` branches for every change and forbade committing to `main` directly — that rule was
retired workspace-wide on 2026-08-08 and does not apply here anymore.)
- **Conventional Commits**: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`.
- **Atomic commits** — one logical change each.
- **Stage explicitly. Never `git add -A`** — denied by policy; it sweeps in whatever else is
dirty, including secrets.
- Each package versions independently via prefixed tags (`api/vX.Y.Z`, `cli/vX.Y.Z`) and its
own `CHANGELOG.md` (`webber-api/CHANGELOG.md`, `webber-cli/CHANGELOG.md`); the root
`CHANGELOG.md` is just an index pointing at both.
## Releasing (API)
Ask whether a deploy is wanted first — it is not automatic.
1. Bump the version in `webber-api/pyproject.toml`.
2. Move `[Unreleased]` entries into a dated version section in `webber-api/CHANGELOG.md`.
3. Stage the changed files by name, commit, tag `api/vX.Y.Z`, `git push origin main --tags`.
4. Gitea CI (`build-api.yml`) builds and pushes the image on the tag; Watchtower deploys it.
5. Verify: `curl http://192.168.86.149:8086/health`.
CLI releases (`cli/vX.Y.Z`) currently only log a TODO in CI — there is no build/publish step
to trigger yet.
## Known issues (carried over, unverified beyond what's stated)
- **Model hallucination**: the Explore agent's model can hallucinate file contents instead of
using actual tool results, per `webber-api/AGENTS.md` — a mitigation (stronger model or
response validation) was suggested there but not confirmed implemented.
- **Ollama `content: null` workaround**: `src/ollama/provider.py` (confirmed present, loaded at
startup per the `sys.modules` check above) sanitizes `content: null` to `content: ""` for
assistant messages with tool calls, working around an Ollama API limitation.
+84
View File
@@ -0,0 +1,84 @@
# webber — the repo's command surface (D-27).
#
# Multi-component, so this lives at the root and reaches down rather than
# sitting inside webber-api/. The code, tests and tooling config are all in
# webber-api/; sandbox-templates/ and sandbox.sh are the other half of the repo
# and have no build of their own. Keeping one Makefile means `make test` means
# the same thing wherever you are standing (D-27).
#
# Paths resolve here (D-10): `python3` is 3.8 on this host, and a bare `pytest`
# or `ruff` resolves only in a login shell.
API := $(CURDIR)/webber-api
VENV := $(API)/.venv
PYTHON ?= python3.12
.DEFAULT_GOAL := help
.PHONY: help
help: ## Show this help
@grep -hE '^[a-z][a-z0-9_-]*:.*?## ' $(MAKEFILE_LIST) \
| awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
.PHONY: setup
# Covers webber-api only. webber-cli and webber-sandbox each have their own
# pyproject.toml and venv but are not wired in here — that reads as an
# omission rather than a decision: no ticket or decision record excludes
# them, and their .venvs on disk predate this target and were built by hand.
# Flagged here rather than silently extended — T-47's scope is verification
# of what setup already covers, not widening what it covers.
setup: ## Create/converge the webber-api venv and prove it's usable (T-47)
cd $(API) && $(PYTHON) -m venv .venv && .venv/bin/pip install -r requirements-dev.txt -e .
@# The prior line read `pip install -e ".[dev]"`, but pyproject.toml
@# declares no [dev] extra and never has (checked full history) — pip
@# only warns ("does not provide the extra 'dev'") and installs the
@# bare package, so `setup` silently produced a venv with no pytest,
@# ruff or mypy. requirements-dev.txt (which -r's requirements.txt) is
@# the real dev dependency list; this is what it was presumably meant
@# to install. Found by the check below, which failed on the very
@# first run against a clean venv (T-47).
@# Exit 0 from pip install is not evidence the env is usable (D-24) — a
@# step whose job is to not fail has a passing state indistinguishable
@# from its broken state. collect-only exercises the real import graph
@# (src.main, every domain, every dev/test dependency pytest itself
@# needs), not just one module import, so it catches a missing dev
@# dependency the same as a broken package import — and fails the
@# target when it does.
cd $(API) && .venv/bin/python -m pytest tests/ --collect-only -q
.PHONY: test
test: ## Run the webber-api test suite
@test -x $(VENV)/bin/python || { echo "FAIL — no venv; run: make setup"; exit 69; }
cd $(API) && .venv/bin/python -m pytest tests/
.PHONY: lint
lint: ## ruff check over webber-api
@test -x $(VENV)/bin/ruff || { echo "FAIL — ruff not installed; run: make setup"; exit 69; }
cd $(API) && .venv/bin/ruff check .
.PHONY: typecheck
typecheck: ## mypy over webber-api
@test -x $(VENV)/bin/mypy || { echo "FAIL — mypy not installed; run: make setup"; exit 69; }
cd $(API) && .venv/bin/mypy .
# 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 typecheck test ## Everything the pre-push hook runs
+2 -2
View File
@@ -107,13 +107,13 @@ This project uses prefixed tags for independent release cycles:
## Requirements ## Requirements
- Python 3.12+ - Python 3.12+
- Ollama running with `mistral-nemo:latest` model - Ollama running with `gemma4:e2b` model
- Docker (for production deployment) - Docker (for production deployment)
- SearXNG (optional, for web search) - SearXNG (optional, for web search)
## Documentation ## Documentation
- `webber-api/AGENTS.md` - API development guidelines - `CLAUDE.md` - Agent development guidelines (repo-wide)
- `webber-api/docs/COVERAGE.md` - Feature coverage and roadmap - `webber-api/docs/COVERAGE.md` - Feature coverage and roadmap
- `webber-api/docs/architecture.md` - System architecture - `webber-api/docs/architecture.md` - System architecture
- `webber-cli/README.md` - CLI usage guide - `webber-cli/README.md` - CLI usage guide
Executable
+50
View File
@@ -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"
+54
View File
@@ -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)_
+5 -2
View File
@@ -14,13 +14,16 @@ CORS_ORIGINS=["http://localhost:3000","http://localhost:8080"]
# LLM - Ollama (tower-of-joy) # LLM - Ollama (tower-of-joy)
OLLAMA_URL=http://192.168.86.149:11434 OLLAMA_URL=http://192.168.86.149:11434
OLLAMA_AGENT_MODEL=mistral-nemo-large:latest OLLAMA_AGENT_MODEL=gemma4:e2b
OLLAMA_EMBED_MODEL=nomic-embed-text:latest OLLAMA_EMBED_MODEL=nomic-embed-text:latest
# Auth - Tatlock integration (optional) # Auth - Tatlock integration (optional)
# TATLOCK_API_URL=http://192.168.86.149:8000 # TATLOCK_API_URL=http://tatlock:8000
# INTERNAL_API_KEY=your-internal-key # INTERNAL_API_KEY=your-internal-key
# Web search - SearXNG (container name on docker-dataplane; internal port 8080)
# SEARXNG_URL=http://searxng:8080
# Tool execution # Tool execution
TOOL_TIMEOUT_SECONDS=120 TOOL_TIMEOUT_SECONDS=120
SANDBOX_ENABLED=true SANDBOX_ENABLED=true
-128
View File
@@ -1,128 +0,0 @@
# 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?
### 🛡️ Git Discipline
* **ALWAYS add the relevant tests for the added code** Make sure to keep the test coverage up as we go, and run tests before commiting.
* **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 version tag (starts with "v")
- Watchtower pulls and deploys to production
- Verify deployment: `curl http://192.168.86.149:8086/health`
---
### 🧪 Local Development Setup
* **Always test locally first** before committing and deploying. The build-deploy loop is slow.
* **Only deploy** when a phase or feature is complete and tested locally
* **Environment**: Copy `.env.example` to `.env` and configure for your local setup
#### ⚠️ CRITICAL: Starting the Local Server
**ALWAYS use `./wakeup.sh` to start the local server. NEVER use raw uvicorn commands.**
```bash
./wakeup.sh
```
The wakeup script provides:
- **Port conflict detection** - Warns if port 8086 is already in use
- **Virtual environment activation** - Ensures correct Python environment
- **Centralized logging** - All logs written to `logs/server.log` for easy tailing
- **Auto-reload** - Code changes picked up automatically (except requirements.txt changes)
- **Consistent configuration** - Same startup every time
To monitor logs in another terminal:
```bash
tail -f logs/server.log
```
To stop the server: Press `Ctrl+C`
To kill a stuck server:
```bash
pkill -f "uvicorn src.main:app"
# or
kill $(lsof -t -i:8086)
```
#### Testing
**Test REST endpoints** against `http://localhost:8086`:
```bash
curl http://localhost:8086/health
curl http://localhost:8086/
curl http://localhost:8086/docs # Swagger UI
```
**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/ -v # Verbose output
.venv/bin/python -m pytest tests/ --cov # With coverage
```
---
## 1.5 Known Issues & Future Improvements
### Explore Agent
- **Model Hallucination**: Mistral Nemo sometimes hallucinates file contents instead of using actual tool results. Consider using a more capable model (codestral, qwen2.5-coder) or adding response validation.
- **Ollama Provider**: We use a custom `WebberOllamaProvider` (ported from tatlock) that sanitizes `content: null` to `content: ""` for assistant messages with tool calls. This works around an Ollama API limitation.
- **Gitignore Support**: ✅ Fixed - The filesystem tools now honor `.gitignore` patterns and default ignores (`.venv/`, `__pycache__/`, `node_modules/`, etc.).
---
## 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
to be determined
+22
View File
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [1.1.0] - 2026-08-11
### Added
- `webber version` command. It was implemented and imported but never registered, so the
subcommand did not exist; the fuller output includes the resolved Ollama URL and model.
### Fixed
- The Ollama `content: null` sanitiser raised `AttributeError` on first use. It looked up the
parent client's chat getter via `.fget`, and openai now exposes `chat` as a
`cached_property`. Any agent request would have failed before reaching the model.
### Changed
- Default `OLLAMA_AGENT_MODEL` is now `gemma4:e2b` instead of `mistral-nemo-large:latest`, so a deployment without an explicit override no longer exhausts shared GPU memory
- `BaseAgent` is generic over its context type and `run_stream` is typed as
`AsyncIterator[str | StreamEvent]`, matching what callers already receive.
## [1.0.1] - 2026-07-19
### Changed
- Default `TATLOCK_API_URL` and `SEARXNG_URL` now use docker container names (`http://tatlock:8000`, `http://searxng:8080`) instead of host IP:port, for container-to-container traffic on the docker-dataplane network
- CI workflow now pushes Docker images via the `git.schweitz.net` registry route
## [1.0.0] - 2026-01-15 ## [1.0.0] - 2026-01-15
### Added ### Added
+6 -4
View File
@@ -13,7 +13,7 @@ Webber is a FastAPI-based agent orchestration service that provides:
**Port:** 8086 **Port:** 8086
**Runtime:** Python 3.12, FastAPI, Uvicorn **Runtime:** Python 3.12, FastAPI, Uvicorn
**Agent Framework:** PydanticAI **Agent Framework:** PydanticAI
**Default LLM:** Ollama with mistral-nemo-large:latest **Default LLM:** Ollama with gemma4:e2b
--- ---
@@ -205,9 +205,11 @@ All settings via environment variables or `.env`:
| HOST | 0.0.0.0 | Server host | | HOST | 0.0.0.0 | Server host |
| PORT | 8086 | Server port | | PORT | 8086 | Server port |
| OLLAMA_URL | http://192.168.86.149:11434 | Ollama API URL | | OLLAMA_URL | http://192.168.86.149:11434 | Ollama API URL |
| OLLAMA_AGENT_MODEL | mistral-nemo-large:latest | Agent reasoning model | | OLLAMA_AGENT_MODEL | gemma4:e2b | Agent reasoning model |
| OLLAMA_EMBED_MODEL | nomic-embed-text:latest | Embedding model | | OLLAMA_EMBED_MODEL | nomic-embed-text:latest | Embedding model |
| TATLOCK_API_URL | http://192.168.86.149:8000 | Tatlock auth service | | TATLOCK_API_URL | http://tatlock:8000 | Tatlock auth service |
| SEARXNG_URL | http://searxng:8080 | SearXNG web search instance |
| SEARXNG_TIMEOUT | 10 | SearXNG request timeout (seconds) |
| TOOL_TIMEOUT_SECONDS | 120 | Tool execution timeout | | TOOL_TIMEOUT_SECONDS | 120 | Tool execution timeout |
| SANDBOX_ENABLED | true | Enable sandboxed execution | | SANDBOX_ENABLED | true | Enable sandboxed execution |
| ALLOWED_PATHS | [] | Paths accessible to tools | | ALLOWED_PATHS | [] | Paths accessible to tools |
@@ -357,7 +359,7 @@ Gitea Actions workflow:
Deployed in Portainer `agents` stack alongside Tatlock: Deployed in Portainer `agents` stack alongside Tatlock:
- Network: `docker-dataplane` - Network: `docker-dataplane`
- Registry: `git.schweitz.internal/jpmschweitzer/webber` - Registry: `git.schweitz.net/jpmschweitzer/webber`
- Auto-update: Watchtower with label `com.centurylinklabs.watchtower.enable=true` - Auto-update: Watchtower with label `com.centurylinklabs.watchtower.enable=true`
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "webber-api" name = "webber-api"
version = "1.0.0" version = "1.1.0"
description = "Webber API - Multi-Agent AI Development Server" description = "Webber API - Multi-Agent AI Development Server"
authors = [ authors = [
{name = "jpmschweitzer"} {name = "jpmschweitzer"}
+3
View File
@@ -15,6 +15,9 @@ pip-audit~=2.9.0
# Type checking # Type checking
mypy~=1.19.1 mypy~=1.19.1
# Stubs for aiofiles, which ships none. Without them mypy reports
# import-untyped on every module that reads or writes a file.
types-aiofiles~=25.1
# Linting and formatting # Linting and formatting
ruff~=0.9.4 ruff~=0.9.4
+1 -1
View File
@@ -6,9 +6,9 @@ from pathlib import Path
import typer import typer
from src.cli.session.loop import AgenticLoop
from src.cli.theme import get_theme from src.cli.theme import get_theme
from src.cli.ui.console import get_console from src.cli.ui.console import get_console
from src.cli.session.loop import AgenticLoop
from src.shared.logging import setup_logging from src.shared.logging import setup_logging
console = get_console() console = get_console()
+7 -1
View File
@@ -53,11 +53,17 @@ def main(
# Import and register commands # Import and register commands
from src.cli.commands import chat, explore, version # noqa: E402, F401 from src.cli.commands import chat, explore, version # noqa: E402
# Register subcommands # Register subcommands
app.command(name="chat")(chat.chat_command) app.command(name="chat")(chat.chat_command)
app.command(name="explore")(explore.explore_command) app.command(name="explore")(explore.explore_command)
# version was imported and never registered, so `webber version` did not exist.
# The --version flag above is the terse form; show_version prints the panel with
# the resolved Ollama URL, model and debug state, which is the one worth having
# when something is misconfigured. The F401 suppression on the import was what
# kept the omission quiet.
app.command(name="version")(version.show_version)
if __name__ == "__main__": if __name__ == "__main__":
+1 -1
View File
@@ -4,4 +4,4 @@ Session management for CLI.
from src.cli.session.context import SessionState from src.cli.session.context import SessionState
from src.cli.session.loop import AgenticLoop from src.cli.session.loop import AgenticLoop
__all__ = ["SessionState", "AgenticLoop"] __all__ = ["AgenticLoop", "SessionState"]
+1 -2
View File
@@ -1,14 +1,13 @@
""" """
Agentic conversation loop for interactive CLI. Agentic conversation loop for interactive CLI.
""" """
from typing import Any
from rich.console import Console from rich.console import Console
from src.cli.session.context import SessionState from src.cli.session.context import SessionState
from src.cli.ui.display import format_response from src.cli.ui.display import format_response
from src.domains.agents.base import BaseAgent from src.domains.agents.base import BaseAgent
from src.shared.logging import logged, trace_span, get_logger from src.shared.logging import get_logger, logged, trace_span
logger = get_logger(__name__) logger = get_logger(__name__)
+2 -2
View File
@@ -4,7 +4,7 @@ CLI theme configuration.
Centralized color and style definitions for the Webber CLI. Centralized color and style definitions for the Webber CLI.
All color choices should be defined here for easy customization. All color choices should be defined here for easy customization.
""" """
from dataclasses import dataclass from dataclasses import dataclass, field
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -36,7 +36,7 @@ class ThemeColors:
class ThemeConfig: class ThemeConfig:
"""Complete theme configuration.""" """Complete theme configuration."""
colors: ThemeColors = ThemeColors() colors: ThemeColors = field(default_factory=ThemeColors)
# Spinner style for loading indicators # Spinner style for loading indicators
spinner: str = "dots" spinner: str = "dots"
+2 -2
View File
@@ -2,6 +2,6 @@
CLI UI components. CLI UI components.
""" """
from src.cli.ui.console import get_console from src.cli.ui.console import get_console
from src.cli.ui.display import format_response, format_code from src.cli.ui.display import format_code, format_response
__all__ = ["get_console", "format_response", "format_code"] __all__ = ["format_code", "format_response", "get_console"]
-1
View File
@@ -8,7 +8,6 @@ from rich.syntax import Syntax
from rich.text import Text from rich.text import Text
from src.cli.theme import get_theme from src.cli.theme import get_theme
from src.cli.ui.console import get_console
def format_response(text: str) -> Markdown | Text: def format_response(text: str) -> Markdown | Text:
+1 -1
View File
@@ -7,8 +7,8 @@ from src.db.database import Database, get_database, get_session
from src.db.models import Base from src.db.models import Base
__all__ = [ __all__ = [
"Base",
"Database", "Database",
"get_database", "get_database",
"get_session", "get_session",
"Base",
] ]
+12 -12
View File
@@ -4,34 +4,34 @@ Agent implementations.
All agents inherit from BaseAgent and are registered in the global registry. All agents inherit from BaseAgent and are registered in the global registry.
""" """
from src.domains.agents.base import ( from src.domains.agents.base import (
BaseAgent,
AgentContext, AgentContext,
AgentProtocol, AgentProtocol,
register_agent, BaseAgent,
get_agent, get_agent,
list_agents,
get_registry, get_registry,
list_agents,
register_agent,
) )
from src.domains.agents.explore import ( from src.domains.agents.explore import (
ExploreAgentImpl, ExploreAgentImpl,
ExploreContext, ExploreContext,
explore_agent,
explore, explore,
explore_agent,
) )
__all__ = [ __all__ = [
# Base classes
"BaseAgent",
"AgentContext", "AgentContext",
"AgentProtocol", "AgentProtocol",
# Registry functions # Base classes
"register_agent", "BaseAgent",
"get_agent",
"list_agents",
"get_registry",
# Explore agent # Explore agent
"ExploreAgentImpl", "ExploreAgentImpl",
"ExploreContext", "ExploreContext",
"explore_agent",
"explore", "explore",
"explore_agent",
"get_agent",
"get_registry",
"list_agents",
# Registry functions
"register_agent",
] ]
+26 -9
View File
@@ -6,10 +6,11 @@ All agents are built on PydanticAI and registered in a central registry.
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Protocol, runtime_checkable from typing import Any, Generic, Protocol, TypeVar, runtime_checkable
from pydantic_ai import Agent from pydantic_ai import Agent
from src.domains.agents.schemas import StreamEvent
from src.shared.logging import get_logger from src.shared.logging import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -27,6 +28,15 @@ class AgentContext:
timeout_seconds: int = 120 timeout_seconds: int = 120
# Every agent narrows the context its tools receive — ExploreContext,
# PlanContext, TaskContext. Without this parameter BaseAgent could only say
# `Agent`, which is `Agent[Any, Any]`, and pydantic_ai then types every
# `.run()` result as Any. That is where 35 of this package's mypy errors came
# from: functions declared `-> str` returning Any, each looking like a local
# annotation slip rather than one missing type parameter in the base class.
CtxT = TypeVar("CtxT", bound=AgentContext)
@runtime_checkable @runtime_checkable
class AgentProtocol(Protocol): class AgentProtocol(Protocol):
"""Protocol that all agents must implement.""" """Protocol that all agents must implement."""
@@ -60,7 +70,7 @@ class AgentProtocol(Protocol):
... ...
class BaseAgent(ABC): class BaseAgent(ABC, Generic[CtxT]):
""" """
Abstract base class for agent implementations. Abstract base class for agent implementations.
@@ -71,9 +81,10 @@ class BaseAgent(ABC):
name = "explore" name = "explore"
description = "Fast codebase exploration" description = "Fast codebase exploration"
def _create_agent(self) -> Agent: class ExploreAgent(BaseAgent[ExploreContext]):
# Create and configure PydanticAI agent def _create_agent(self) -> Agent[ExploreContext, str]:
... # Create and configure PydanticAI agent
...
async def run(self, prompt: str, **kwargs) -> str: async def run(self, prompt: str, **kwargs) -> str:
# Execute agent # Execute agent
@@ -92,15 +103,21 @@ class BaseAgent(ABC):
"""Human-readable description.""" """Human-readable description."""
pass pass
# Declared on the base rather than only in each subclass's __init__. The
# base reached it through hasattr, so mypy could not determine its type at
# all; the guard existed because nothing guaranteed the attribute existed.
# Declaring it here makes the None check sufficient.
_agent: "Agent[CtxT, str] | None" = None
@property @property
def agent(self) -> Agent: def agent(self) -> "Agent[CtxT, str]":
"""Lazy-loaded PydanticAI agent.""" """Lazy-loaded PydanticAI agent."""
if not hasattr(self, '_agent') or self._agent is None: if self._agent is None:
self._agent = self._create_agent() self._agent = self._create_agent()
return self._agent return self._agent
@abstractmethod @abstractmethod
def _create_agent(self) -> Agent: def _create_agent(self) -> "Agent[CtxT, str]":
""" """
Create and configure the PydanticAI agent. Create and configure the PydanticAI agent.
@@ -115,7 +132,7 @@ class BaseAgent(ABC):
async def run_stream( async def run_stream(
self, prompt: str, **kwargs: Any self, prompt: str, **kwargs: Any
) -> AsyncIterator[str]: ) -> AsyncIterator[str | StreamEvent]:
""" """
Execute the agent with streaming output. Execute the agent with streaming output.
@@ -4,13 +4,13 @@ Explore Agent - Fast codebase exploration.
from src.domains.agents.explore.agent import ( from src.domains.agents.explore.agent import (
ExploreAgentImpl, ExploreAgentImpl,
ExploreContext, ExploreContext,
explore_agent,
explore, explore,
explore_agent,
) )
__all__ = [ __all__ = [
"ExploreAgentImpl", "ExploreAgentImpl",
"ExploreContext", "ExploreContext",
"explore_agent",
"explore", "explore",
"explore_agent",
] ]
@@ -12,11 +12,11 @@ from typing import Any
from pydantic_ai import Agent from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIModel from pydantic_ai.models.openai import OpenAIModel
from src.domains.agents.base import BaseAgent, AgentContext, register_agent from src.domains.agents.base import AgentContext, BaseAgent, register_agent
from src.domains.agents.explore.prompts import EXPLORE_SYSTEM_PROMPT from src.domains.agents.explore.prompts import EXPLORE_SYSTEM_PROMPT
from src.ollama.provider import get_ollama_provider from src.ollama.provider import get_ollama_provider
from src.shared.config import get_settings from src.shared.config import get_settings
from src.shared.logging import logged, get_logger, trace_span from src.shared.logging import get_logger, logged, trace_span
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -31,7 +31,7 @@ class ExploreContext(AgentContext):
pass pass
class ExploreAgentImpl(BaseAgent): class ExploreAgentImpl(BaseAgent[ExploreContext]):
""" """
Fast codebase exploration agent. Fast codebase exploration agent.
@@ -44,7 +44,7 @@ class ExploreAgentImpl(BaseAgent):
def __init__(self): def __init__(self):
"""Initialize the explore agent.""" """Initialize the explore agent."""
self._agent: Agent[ExploreContext, str] | None = None self._agent = None
self._settings = get_settings() self._settings = get_settings()
def _create_agent(self) -> Agent[ExploreContext, str]: def _create_agent(self) -> Agent[ExploreContext, str]:
+12 -12
View File
@@ -5,10 +5,10 @@ Registers our tool implementations with the PydanticAI agent.
""" """
from pydantic_ai import Agent, RunContext from pydantic_ai import Agent, RunContext
from src.domains.agents.base import AgentContext from src.domains.agents.explore.agent import ExploreContext
from src.domains.tools.file.read import ReadFileTool
from src.domains.tools.file.glob import GlobFilesTool
from src.domains.tools.file.edit import EditFileTool from src.domains.tools.file.edit import EditFileTool
from src.domains.tools.file.glob import GlobFilesTool
from src.domains.tools.file.read import ReadFileTool
from src.domains.tools.file.write import WriteFileTool from src.domains.tools.file.write import WriteFileTool
from src.domains.tools.search.grep import GrepContentTool from src.domains.tools.search.grep import GrepContentTool
from src.domains.tools.search.web import WebSearchTool from src.domains.tools.search.web import WebSearchTool
@@ -16,7 +16,7 @@ from src.domains.tools.shell.bash import BashReadOnlyTool
from src.domains.tools.shell.bash_full import BashTool from src.domains.tools.shell.bash_full import BashTool
def register_explore_tools(agent: Agent[AgentContext, str]) -> None: def register_explore_tools(agent: Agent[ExploreContext, str]) -> None:
""" """
Register all exploration tools with the agent. Register all exploration tools with the agent.
@@ -25,7 +25,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool @agent.tool
async def read_file( async def read_file(
ctx: RunContext[AgentContext], ctx: RunContext[ExploreContext],
file_path: str, file_path: str,
offset: int = 0, offset: int = 0,
limit: int = 2000 limit: int = 2000
@@ -52,7 +52,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool @agent.tool
async def glob_files( async def glob_files(
ctx: RunContext[AgentContext], ctx: RunContext[ExploreContext],
pattern: str, pattern: str,
path: str | None = None, path: str | None = None,
limit: int = 100 limit: int = 100
@@ -85,7 +85,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool @agent.tool
async def grep_content( async def grep_content(
ctx: RunContext[AgentContext], ctx: RunContext[ExploreContext],
pattern: str, pattern: str,
path: str | None = None, path: str | None = None,
file_glob: str | None = None, file_glob: str | None = None,
@@ -125,7 +125,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool @agent.tool
async def bash_readonly( async def bash_readonly(
ctx: RunContext[AgentContext], ctx: RunContext[ExploreContext],
command: str, command: str,
cwd: str | None = None, cwd: str | None = None,
timeout: int = 30 timeout: int = 30
@@ -170,7 +170,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool @agent.tool
async def edit_file( async def edit_file(
ctx: RunContext[AgentContext], ctx: RunContext[ExploreContext],
file_path: str, file_path: str,
old_string: str, old_string: str,
new_string: str, new_string: str,
@@ -204,7 +204,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool @agent.tool
async def write_file( async def write_file(
ctx: RunContext[AgentContext], ctx: RunContext[ExploreContext],
file_path: str, file_path: str,
content: str content: str
) -> str: ) -> str:
@@ -231,7 +231,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool @agent.tool
async def bash( async def bash(
ctx: RunContext[AgentContext], ctx: RunContext[ExploreContext],
command: str, command: str,
cwd: str | None = None, cwd: str | None = None,
timeout: int = 60 timeout: int = 60
@@ -277,7 +277,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool @agent.tool
async def web_search( async def web_search(
ctx: RunContext[AgentContext], ctx: RunContext[ExploreContext],
query: str, query: str,
num_results: int = 5, num_results: int = 5,
categories: str | None = None categories: str | None = None
@@ -16,15 +16,15 @@ Usage:
from src.domains.agents.plan.agent import ( from src.domains.agents.plan.agent import (
PlanAgentImpl, PlanAgentImpl,
PlanContext, PlanContext,
plan_agent,
plan, plan,
plan_agent,
plan_stream, plan_stream,
) )
__all__ = [ __all__ = [
"PlanAgentImpl", "PlanAgentImpl",
"PlanContext", "PlanContext",
"plan_agent",
"plan", "plan",
"plan_agent",
"plan_stream", "plan_stream",
] ]
+4 -4
View File
@@ -12,11 +12,11 @@ from typing import Any
from pydantic_ai import Agent from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIModel from pydantic_ai.models.openai import OpenAIModel
from src.domains.agents.base import BaseAgent, AgentContext, register_agent from src.domains.agents.base import AgentContext, BaseAgent, register_agent
from src.domains.agents.plan.prompts import PLAN_SYSTEM_PROMPT from src.domains.agents.plan.prompts import PLAN_SYSTEM_PROMPT
from src.ollama.provider import get_ollama_provider from src.ollama.provider import get_ollama_provider
from src.shared.config import get_settings from src.shared.config import get_settings
from src.shared.logging import logged, get_logger, trace_span from src.shared.logging import get_logger, logged, trace_span
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -32,7 +32,7 @@ class PlanContext(AgentContext):
pass pass
class PlanAgentImpl(BaseAgent): class PlanAgentImpl(BaseAgent[PlanContext]):
""" """
Software architect agent for implementation planning. Software architect agent for implementation planning.
@@ -47,7 +47,7 @@ class PlanAgentImpl(BaseAgent):
def __init__(self): def __init__(self):
"""Initialize the plan agent.""" """Initialize the plan agent."""
self._agent: Agent[PlanContext, str] | None = None self._agent = None
self._settings = get_settings() self._settings = get_settings()
def _create_agent(self) -> Agent[PlanContext, str]: def _create_agent(self) -> Agent[PlanContext, str]:
+7 -7
View File
@@ -6,14 +6,14 @@ It cannot modify files - only explore and analyze.
""" """
from pydantic_ai import Agent, RunContext from pydantic_ai import Agent, RunContext
from src.domains.agents.base import AgentContext from src.domains.agents.plan.agent import PlanContext
from src.domains.tools.file.read import ReadFileTool
from src.domains.tools.file.glob import GlobFilesTool from src.domains.tools.file.glob import GlobFilesTool
from src.domains.tools.file.read import ReadFileTool
from src.domains.tools.search.grep import GrepContentTool from src.domains.tools.search.grep import GrepContentTool
from src.domains.tools.shell.bash import BashReadOnlyTool from src.domains.tools.shell.bash import BashReadOnlyTool
def register_plan_tools(agent: Agent[AgentContext, str]) -> None: def register_plan_tools(agent: Agent[PlanContext, str]) -> None:
""" """
Register read-only exploration tools with the Plan agent. Register read-only exploration tools with the Plan agent.
@@ -28,7 +28,7 @@ def register_plan_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool @agent.tool
async def read_file( async def read_file(
ctx: RunContext[AgentContext], ctx: RunContext[PlanContext],
file_path: str, file_path: str,
offset: int = 0, offset: int = 0,
limit: int = 2000 limit: int = 2000
@@ -55,7 +55,7 @@ def register_plan_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool @agent.tool
async def glob_files( async def glob_files(
ctx: RunContext[AgentContext], ctx: RunContext[PlanContext],
pattern: str, pattern: str,
path: str | None = None, path: str | None = None,
limit: int = 100 limit: int = 100
@@ -88,7 +88,7 @@ def register_plan_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool @agent.tool
async def grep_content( async def grep_content(
ctx: RunContext[AgentContext], ctx: RunContext[PlanContext],
pattern: str, pattern: str,
path: str | None = None, path: str | None = None,
file_glob: str | None = None, file_glob: str | None = None,
@@ -128,7 +128,7 @@ def register_plan_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool @agent.tool
async def bash_readonly( async def bash_readonly(
ctx: RunContext[AgentContext], ctx: RunContext[PlanContext],
command: str, command: str,
cwd: str | None = None, cwd: str | None = None,
timeout: int = 30 timeout: int = 30
+7 -7
View File
@@ -10,23 +10,24 @@ Streaming uses structured events instead of raw text to avoid
garbled output during tool execution. garbled output during tool execution.
""" """
import json import json
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from src.domains.agents.base import get_agent, list_agents
# Import agents to ensure they're registered # Import agents to ensure they're registered
import src.domains.agents.explore # noqa: F401 import src.domains.agents.explore
import src.domains.agents.plan # noqa: F401 import src.domains.agents.plan
import src.domains.agents.task # noqa: F401 import src.domains.agents.task # noqa: F401
from src.domains.agents.base import get_agent, list_agents
from src.domains.agents.schemas import ( from src.domains.agents.schemas import (
AgentRunRequest,
AgentRunResponse,
AgentInfo, AgentInfo,
AgentListResponse, AgentListResponse,
AgentRunRequest,
AgentRunResponse,
PermissionMode, PermissionMode,
StreamEvent, StreamEvent,
) )
from src.shared.logging import get_logger, logged
def _get_mode(mode_value: str | PermissionMode) -> PermissionMode: def _get_mode(mode_value: str | PermissionMode) -> PermissionMode:
@@ -34,7 +35,6 @@ def _get_mode(mode_value: str | PermissionMode) -> PermissionMode:
if isinstance(mode_value, PermissionMode): if isinstance(mode_value, PermissionMode):
return mode_value return mode_value
return PermissionMode(mode_value) return PermissionMode(mode_value)
from src.shared.logging import logged, get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
+7 -1
View File
@@ -70,7 +70,13 @@ class ApprovalRuleSet(BaseSchema):
First matching rule determines the action. First matching rule determines the action.
If no rules match, falls back to default action. If no rules match, falls back to default action.
""" """
rules: list[ApprovalRule] = [] # Suppression justified: this is a pydantic model, not a plain class. Pydantic
# deep-copies field defaults per instance — verified: two ApprovalRuleSet()
# instances have `rules` lists that are not the same object, and appending
# to one leaves the other empty. RUF012's suggested fix, annotating this
# ClassVar, would remove the field from the model altogether. Ruff cannot
# see the pydantic base because BaseSchema is a local subclass of BaseModel.
rules: list[ApprovalRule] = [] # noqa: RUF012
default_action: ApprovalAction = ApprovalAction.ask # Default when no rules match default_action: ApprovalAction = ApprovalAction.ask # Default when no rules match
@@ -19,15 +19,15 @@ Usage:
from src.domains.agents.task.agent import ( from src.domains.agents.task.agent import (
TaskAgentImpl, TaskAgentImpl,
TaskContext, TaskContext,
task_agent,
task, task,
task_agent,
task_stream, task_stream,
) )
__all__ = [ __all__ = [
"TaskAgentImpl", "TaskAgentImpl",
"TaskContext", "TaskContext",
"task_agent",
"task", "task",
"task_agent",
"task_stream", "task_stream",
] ]
+18 -11
View File
@@ -8,6 +8,7 @@ Full orchestrator agent that can:
- Stream structured events instead of raw text - Stream structured events instead of raw text
""" """
import asyncio import asyncio
import contextlib
import os import os
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -16,12 +17,12 @@ from typing import Any
from pydantic_ai import Agent from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIModel from pydantic_ai.models.openai import OpenAIModel
from src.domains.agents.base import BaseAgent, AgentContext, register_agent from src.domains.agents.base import AgentContext, BaseAgent, register_agent
from src.domains.agents.schemas import PermissionMode, StreamEvent, StreamEventType from src.domains.agents.schemas import PermissionMode, StreamEvent, StreamEventType
from src.domains.agents.task.prompts import TASK_SYSTEM_PROMPT, TASK_PLAN_MODE_PROMPT from src.domains.agents.task.prompts import TASK_PLAN_MODE_PROMPT, TASK_SYSTEM_PROMPT
from src.ollama.provider import get_ollama_provider from src.ollama.provider import get_ollama_provider
from src.shared.config import get_settings from src.shared.config import get_settings
from src.shared.logging import logged, get_logger, trace_span from src.shared.logging import get_logger, logged, trace_span
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -61,7 +62,7 @@ def _summarize_result(result: str, max_len: int = 80) -> str:
return result return result
class TaskAgentImpl(BaseAgent): class TaskAgentImpl(BaseAgent[TaskContext]):
""" """
Full orchestrator agent for autonomous task execution. Full orchestrator agent for autonomous task execution.
@@ -127,8 +128,8 @@ class TaskAgentImpl(BaseAgent):
def _register_tools(self, agent: Agent[TaskContext, str], mode: PermissionMode) -> None: def _register_tools(self, agent: Agent[TaskContext, str], mode: PermissionMode) -> None:
"""Register tools with the agent based on permission mode.""" """Register tools with the agent based on permission mode."""
from src.domains.agents.task.tools_streaming import ( from src.domains.agents.task.tools_streaming import (
register_task_tools_streaming,
register_readonly_tools_streaming, register_readonly_tools_streaming,
register_task_tools_streaming,
) )
if mode == PermissionMode.plan: if mode == PermissionMode.plan:
@@ -293,8 +294,16 @@ User request: {prompt}"""
message=f"Retrying (attempt {retries + 1})..." message=f"Retrying (attempt {retries + 1})..."
) )
# Run agent in background task so we can yield events # Run agent in background task so we can yield events.
async def run_agent() -> str: #
# full_prompt and ctx are bound as defaults rather than closed
# over. Today the closure is safe either way — the task is
# awaited below before `continue` reaches the next iteration, so
# neither name can be rebound while it is pending. Binding them
# keeps that true if the await ever moves, which is the failure
# B023 is warning about and the kind that surfaces as one agent
# silently running another's prompt.
async def run_agent(full_prompt: str = full_prompt, ctx: TaskContext = ctx) -> str:
try: try:
result = await agent.run(full_prompt, deps=ctx) result = await agent.run(full_prompt, deps=ctx)
return result.output return result.output
@@ -314,7 +323,7 @@ User request: {prompt}"""
timeout=0.1 timeout=0.1
) )
yield event yield event
except asyncio.TimeoutError: except TimeoutError:
# No events, check if agent is done # No events, check if agent is done
continue continue
@@ -346,10 +355,8 @@ User request: {prompt}"""
# Cancel agent if still running # Cancel agent if still running
if not agent_task.done(): if not agent_task.done():
agent_task.cancel() agent_task.cancel()
try: with contextlib.suppress(asyncio.CancelledError):
await agent_task await agent_task
except asyncio.CancelledError:
pass
return return
# Yield response in chunks for streaming feel # Yield response in chunks for streaming feel
+19 -19
View File
@@ -7,10 +7,10 @@ The Task agent has access to tools based on permission mode:
""" """
from pydantic_ai import Agent, RunContext from pydantic_ai import Agent, RunContext
from src.domains.agents.base import AgentContext from src.domains.agents.task.agent import TaskContext
from src.domains.tools.file.read import ReadFileTool
from src.domains.tools.file.glob import GlobFilesTool
from src.domains.tools.file.edit import EditFileTool from src.domains.tools.file.edit import EditFileTool
from src.domains.tools.file.glob import GlobFilesTool
from src.domains.tools.file.read import ReadFileTool
from src.domains.tools.file.write import WriteFileTool from src.domains.tools.file.write import WriteFileTool
from src.domains.tools.search.grep import GrepContentTool from src.domains.tools.search.grep import GrepContentTool
from src.domains.tools.search.web import WebSearchTool from src.domains.tools.search.web import WebSearchTool
@@ -18,11 +18,11 @@ from src.domains.tools.shell.bash import BashReadOnlyTool
from src.domains.tools.shell.bash_full import BashTool from src.domains.tools.shell.bash_full import BashTool
def _register_read_file(agent: Agent[AgentContext, str]) -> None: def _register_read_file(agent: Agent[TaskContext, str]) -> None:
"""Register read_file tool.""" """Register read_file tool."""
@agent.tool @agent.tool
async def read_file( async def read_file(
ctx: RunContext[AgentContext], ctx: RunContext[TaskContext],
file_path: str, file_path: str,
offset: int = 0, offset: int = 0,
limit: int = 2000 limit: int = 2000
@@ -48,11 +48,11 @@ def _register_read_file(agent: Agent[AgentContext, str]) -> None:
return result.to_string() return result.to_string()
def _register_glob_files(agent: Agent[AgentContext, str]) -> None: def _register_glob_files(agent: Agent[TaskContext, str]) -> None:
"""Register glob_files tool.""" """Register glob_files tool."""
@agent.tool @agent.tool
async def glob_files( async def glob_files(
ctx: RunContext[AgentContext], ctx: RunContext[TaskContext],
pattern: str, pattern: str,
path: str | None = None, path: str | None = None,
limit: int = 100 limit: int = 100
@@ -82,11 +82,11 @@ def _register_glob_files(agent: Agent[AgentContext, str]) -> None:
return result.to_string() return result.to_string()
def _register_grep_content(agent: Agent[AgentContext, str]) -> None: def _register_grep_content(agent: Agent[TaskContext, str]) -> None:
"""Register grep_content tool.""" """Register grep_content tool."""
@agent.tool @agent.tool
async def grep_content( async def grep_content(
ctx: RunContext[AgentContext], ctx: RunContext[TaskContext],
pattern: str, pattern: str,
path: str | None = None, path: str | None = None,
file_glob: str | None = None, file_glob: str | None = None,
@@ -118,11 +118,11 @@ def _register_grep_content(agent: Agent[AgentContext, str]) -> None:
return result.to_string() return result.to_string()
def _register_bash_readonly(agent: Agent[AgentContext, str]) -> None: def _register_bash_readonly(agent: Agent[TaskContext, str]) -> None:
"""Register bash_readonly tool.""" """Register bash_readonly tool."""
@agent.tool @agent.tool
async def bash_readonly( async def bash_readonly(
ctx: RunContext[AgentContext], ctx: RunContext[TaskContext],
command: str, command: str,
cwd: str | None = None, cwd: str | None = None,
timeout: int = 30 timeout: int = 30
@@ -156,11 +156,11 @@ def _register_bash_readonly(agent: Agent[AgentContext, str]) -> None:
return result.to_string() return result.to_string()
def _register_spawn_agent(agent: Agent[AgentContext, str], readonly_only: bool = False) -> None: def _register_spawn_agent(agent: Agent[TaskContext, str], readonly_only: bool = False) -> None:
"""Register spawn_agent tool.""" """Register spawn_agent tool."""
@agent.tool @agent.tool
async def spawn_agent( async def spawn_agent(
ctx: RunContext[AgentContext], ctx: RunContext[TaskContext],
agent_type: str, agent_type: str,
prompt: str, prompt: str,
working_dir: str | None = None working_dir: str | None = None
@@ -212,7 +212,7 @@ def _register_spawn_agent(agent: Agent[AgentContext, str], readonly_only: bool =
return f"Sub-agent error: {e}" return f"Sub-agent error: {e}"
def register_readonly_tools(agent: Agent[AgentContext, str]) -> None: def register_readonly_tools(agent: Agent[TaskContext, str]) -> None:
""" """
Register read-only tools with the agent. Register read-only tools with the agent.
@@ -227,7 +227,7 @@ def register_readonly_tools(agent: Agent[AgentContext, str]) -> None:
_register_spawn_agent(agent, readonly_only=True) _register_spawn_agent(agent, readonly_only=True)
def register_task_tools(agent: Agent[AgentContext, str]) -> None: def register_task_tools(agent: Agent[TaskContext, str]) -> None:
""" """
Register all tools with the Task agent. Register all tools with the Task agent.
@@ -247,7 +247,7 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool @agent.tool
async def edit_file( async def edit_file(
ctx: RunContext[AgentContext], ctx: RunContext[TaskContext],
file_path: str, file_path: str,
old_string: str, old_string: str,
new_string: str, new_string: str,
@@ -281,7 +281,7 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool @agent.tool
async def write_file( async def write_file(
ctx: RunContext[AgentContext], ctx: RunContext[TaskContext],
file_path: str, file_path: str,
content: str content: str
) -> str: ) -> str:
@@ -308,7 +308,7 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool @agent.tool
async def bash( async def bash(
ctx: RunContext[AgentContext], ctx: RunContext[TaskContext],
command: str, command: str,
cwd: str | None = None, cwd: str | None = None,
timeout: int = 60 timeout: int = 60
@@ -350,7 +350,7 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool @agent.tool
async def web_search( async def web_search(
ctx: RunContext[AgentContext], ctx: RunContext[TaskContext],
query: str, query: str,
num_results: int = 5, num_results: int = 5,
categories: str | None = None categories: str | None = None
@@ -9,9 +9,9 @@ from pydantic_ai import Agent, RunContext
from src.domains.agents.base import AgentContext from src.domains.agents.base import AgentContext
from src.domains.agents.schemas import StreamEvent, StreamEventType from src.domains.agents.schemas import StreamEvent, StreamEventType
from src.domains.agents.task.agent import TaskContext from src.domains.agents.task.agent import TaskContext
from src.domains.tools.file.read import ReadFileTool
from src.domains.tools.file.glob import GlobFilesTool
from src.domains.tools.file.edit import EditFileTool from src.domains.tools.file.edit import EditFileTool
from src.domains.tools.file.glob import GlobFilesTool
from src.domains.tools.file.read import ReadFileTool
from src.domains.tools.file.write import WriteFileTool from src.domains.tools.file.write import WriteFileTool
from src.domains.tools.search.grep import GrepContentTool from src.domains.tools.search.grep import GrepContentTool
from src.domains.tools.search.web import WebSearchTool from src.domains.tools.search.web import WebSearchTool
@@ -11,6 +11,6 @@ from src.domains.conversations.service import ConversationService
__all__ = [ __all__ = [
"Conversation", "Conversation",
"Message",
"ConversationService", "ConversationService",
"Message",
] ]
@@ -20,7 +20,7 @@ from src.domains.conversations.schemas import (
) )
from src.domains.conversations.service import ConversationService from src.domains.conversations.service import ConversationService
from src.shared.auth import require_auth from src.shared.auth import require_auth
from src.shared.logging import logged, get_logger from src.shared.logging import get_logger, logged
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -170,7 +170,7 @@ async def add_message(
logger.exception(f"Agent response failed: {e}") logger.exception(f"Agent response failed: {e}")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail=f"Agent error: {str(e)}" detail=f"Agent error: {e!s}"
) )
# Add assistant message # Add assistant message
@@ -6,7 +6,6 @@ from uuid import UUID
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
# === Request Schemas === # === Request Schemas ===
class CreateConversationRequest(BaseModel): class CreateConversationRequest(BaseModel):
@@ -5,7 +5,7 @@ Handles CRUD operations, context building, and summarization triggers.
""" """
from uuid import UUID from uuid import UUID
from sqlalchemy import select, func from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
+1 -1
View File
@@ -6,9 +6,9 @@ main.py only includes this root_router.
""" """
from fastapi import APIRouter from fastapi import APIRouter
from src.domains.health.router import router as health_router
from src.domains.agents.router import router as agents_router from src.domains.agents.router import router as agents_router
from src.domains.conversations.router import router as conversations_router from src.domains.conversations.router import router as conversations_router
from src.domains.health.router import router as health_router
# from src.domains.auth.router import router as auth_router # from src.domains.auth.router import router as auth_router
# from src.domains.tools.router import router as tools_router # from src.domains.tools.router import router as tools_router
+8 -8
View File
@@ -4,19 +4,19 @@ Tool implementations for agent use.
All tools inherit from BaseTool and return ToolResult. All tools inherit from BaseTool and return ToolResult.
""" """
from src.domains.tools.base import BaseTool, ToolResult from src.domains.tools.base import BaseTool, ToolResult
from src.domains.tools.file import ReadFileTool, GlobFilesTool, EditFileTool, WriteFileTool from src.domains.tools.file import EditFileTool, GlobFilesTool, ReadFileTool, WriteFileTool
from src.domains.tools.search import GrepContentTool, WebSearchTool from src.domains.tools.search import GrepContentTool, WebSearchTool
from src.domains.tools.shell import BashReadOnlyTool, BashTool from src.domains.tools.shell import BashReadOnlyTool, BashTool
__all__ = [ __all__ = [
"BaseTool", "BaseTool",
"ToolResult",
"ReadFileTool",
"GlobFilesTool",
"EditFileTool",
"WriteFileTool",
"GrepContentTool",
"WebSearchTool",
"BashReadOnlyTool", "BashReadOnlyTool",
"BashTool", "BashTool",
"EditFileTool",
"GlobFilesTool",
"GrepContentTool",
"ReadFileTool",
"ToolResult",
"WebSearchTool",
"WriteFileTool",
] ]
+16 -4
View File
@@ -32,10 +32,7 @@ class ToolResult:
if not self.success: if not self.success:
return f"ERROR: {self.error}" return f"ERROR: {self.error}"
if isinstance(self.data, str): content = self.data if isinstance(self.data, str) else str(self.data)
content = self.data
else:
content = str(self.data)
if len(content) > max_length: if len(content) > max_length:
self.truncated = True self.truncated = True
@@ -90,6 +87,21 @@ class BaseTool(ABC):
""" """
Execute the tool with given arguments. Execute the tool with given arguments.
Note on the `# type: ignore[override]` each implementation carries.
Every tool narrows this to its own named parameters — read_file takes
file_path/offset/limit, bash takes command/timeout — which mypy reports
as an LSP violation, and strictly it is: a caller holding a BaseTool
could call .execute(anything=1) and no implementation would accept it.
Nothing does. Checked: no reference anywhere in this package is typed as
BaseTool, and every call site constructs the concrete tool and passes its
specific arguments. What this abstract method buys is the runtime
guarantee that a tool without an execute cannot be instantiated, and that
is worth keeping.
The suppressions are per-site rather than a disable_error_code for the
whole package, so a future override that IS unsound still gets caught.
Returns: Returns:
ToolResult with success status and data or error ToolResult with success status and data or error
""" """
@@ -1,9 +1,9 @@
""" """
File operation tools. File operation tools.
""" """
from src.domains.tools.file.read import ReadFileTool
from src.domains.tools.file.glob import GlobFilesTool
from src.domains.tools.file.edit import EditFileTool from src.domains.tools.file.edit import EditFileTool
from src.domains.tools.file.glob import GlobFilesTool
from src.domains.tools.file.read import ReadFileTool
from src.domains.tools.file.write import WriteFileTool from src.domains.tools.file.write import WriteFileTool
__all__ = ["ReadFileTool", "GlobFilesTool", "EditFileTool", "WriteFileTool"] __all__ = ["EditFileTool", "GlobFilesTool", "ReadFileTool", "WriteFileTool"]
+7 -6
View File
@@ -2,11 +2,12 @@
File editing tool with find-and-replace functionality. File editing tool with find-and-replace functionality.
""" """
import difflib import difflib
import aiofiles
from pathlib import Path from pathlib import Path
import aiofiles
from src.domains.tools.base import BaseTool, ToolResult from src.domains.tools.base import BaseTool, ToolResult
from src.shared.logging import logged, get_logger from src.shared.logging import get_logger, logged
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -95,7 +96,7 @@ Examples:
return "".join(diff) return "".join(diff)
@logged() @logged()
async def execute( async def execute( # type: ignore[override] # see BaseTool.execute
self, self,
file_path: str, file_path: str,
old_string: str, old_string: str,
@@ -147,15 +148,15 @@ Examples:
try: try:
# Read file content # Read file content
async with aiofiles.open(path, 'r', encoding='utf-8', errors='replace') as f: async with aiofiles.open(path, encoding='utf-8', errors='replace') as f:
content = await f.read() content = await f.read()
# Check if old_string exists # Check if old_string exists
count = content.count(old_string) count = content.count(old_string)
if count == 0: if count == 0:
return self._error( return self._error(
f"old_string not found in file. " "old_string not found in file. "
f"Make sure to match exact whitespace and indentation." "Make sure to match exact whitespace and indentation."
) )
# Check uniqueness if replace_all is False # Check uniqueness if replace_all is False
+2 -2
View File
@@ -6,7 +6,7 @@ from pathlib import Path
from src.domains.tools.base import BaseTool, ToolResult from src.domains.tools.base import BaseTool, ToolResult
from src.domains.tools.gitignore import filter_gitignored from src.domains.tools.gitignore import filter_gitignored
from src.shared.logging import logged, get_logger from src.shared.logging import get_logger, logged
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -63,7 +63,7 @@ IMPORTANT:
self.honor_gitignore = honor_gitignore self.honor_gitignore = honor_gitignore
@logged() @logged()
async def execute( async def execute( # type: ignore[override] # see BaseTool.execute
self, self,
pattern: str, pattern: str,
path: str | None = None, path: str | None = None,
+5 -4
View File
@@ -1,11 +1,12 @@
""" """
File reading tool with line number formatting and sandboxing. File reading tool with line number formatting and sandboxing.
""" """
import aiofiles
from pathlib import Path from pathlib import Path
import aiofiles
from src.domains.tools.base import BaseTool, ToolResult from src.domains.tools.base import BaseTool, ToolResult
from src.shared.logging import logged, get_logger from src.shared.logging import get_logger, logged
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -55,7 +56,7 @@ IMPORTANT:
self.max_line_length = max_line_length self.max_line_length = max_line_length
@logged() @logged()
async def execute( async def execute( # type: ignore[override] # see BaseTool.execute
self, self,
file_path: str, file_path: str,
offset: int = 0, offset: int = 0,
@@ -87,7 +88,7 @@ IMPORTANT:
return self._error(f"Not a file: {file_path}") return self._error(f"Not a file: {file_path}")
try: try:
async with aiofiles.open(path, 'r', encoding='utf-8', errors='replace') as f: async with aiofiles.open(path, encoding='utf-8', errors='replace') as f:
content = await f.read() content = await f.read()
lines = content.splitlines() lines = content.splitlines()
+4 -3
View File
@@ -1,11 +1,12 @@
""" """
File writing tool for creating and overwriting files. File writing tool for creating and overwriting files.
""" """
import aiofiles
from pathlib import Path from pathlib import Path
import aiofiles
from src.domains.tools.base import BaseTool, ToolResult from src.domains.tools.base import BaseTool, ToolResult
from src.shared.logging import logged, get_logger from src.shared.logging import get_logger, logged
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -57,7 +58,7 @@ Examples:
self.max_content_size = max_content_size self.max_content_size = max_content_size
@logged() @logged()
async def execute( async def execute( # type: ignore[override] # see BaseTool.execute
self, self,
file_path: str, file_path: str,
content: str content: str
+1 -4
View File
@@ -90,10 +90,7 @@ class GitignoreFilter:
# Make path relative to root for matching # Make path relative to root for matching
try: try:
if path.is_absolute(): rel_path = path.resolve().relative_to(self.root_dir) if path.is_absolute() else path
rel_path = path.resolve().relative_to(self.root_dir)
else:
rel_path = path
except ValueError: except ValueError:
# Path is not under root_dir, don't filter # Path is not under root_dir, don't filter
return False return False
+2 -2
View File
@@ -7,7 +7,7 @@ from typing import Literal
from src.domains.tools.base import BaseTool, ToolResult from src.domains.tools.base import BaseTool, ToolResult
from src.domains.tools.gitignore import filter_gitignored from src.domains.tools.gitignore import filter_gitignored
from src.shared.logging import logged, get_logger from src.shared.logging import get_logger, logged
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -70,7 +70,7 @@ IMPORTANT:
self.honor_gitignore = honor_gitignore self.honor_gitignore = honor_gitignore
@logged() @logged()
async def execute( async def execute( # type: ignore[override] # see BaseTool.execute
self, self,
pattern: str, pattern: str,
path: str | None = None, path: str | None = None,
+7 -3
View File
@@ -3,12 +3,13 @@ Web search tool using SearXNG.
""" """
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Any
import httpx import httpx
from src.domains.tools.base import BaseTool, ToolResult from src.domains.tools.base import BaseTool, ToolResult
from src.shared.config import get_settings from src.shared.config import get_settings
from src.shared.logging import logged, get_logger from src.shared.logging import get_logger, logged
from src.shared.retry import retry_async from src.shared.retry import retry_async
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -91,10 +92,13 @@ IMPORTANT:
params=params, params=params,
) )
response.raise_for_status() response.raise_for_status()
return response.json() # httpx types .json() as Any. Naming the shape here keeps the Any from
# travelling into every caller of this method.
payload: dict[Any, Any] = response.json()
return payload
@logged() @logged()
async def execute( async def execute( # type: ignore[override] # see BaseTool.execute
self, self,
query: str, query: str,
num_results: int = 5, num_results: int = 5,
+3 -3
View File
@@ -8,7 +8,7 @@ import shlex
from pathlib import Path from pathlib import Path
from src.domains.tools.base import BaseTool, ToolResult from src.domains.tools.base import BaseTool, ToolResult
from src.shared.logging import logged, get_logger from src.shared.logging import get_logger, logged
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -115,7 +115,7 @@ Examples:
self.max_output_size = max_output_size self.max_output_size = max_output_size
@logged() @logged()
async def execute( async def execute( # type: ignore[override] # see BaseTool.execute
self, self,
command: str, command: str,
cwd: str | None = None, cwd: str | None = None,
@@ -190,7 +190,7 @@ Examples:
exit_code=proc.returncode exit_code=proc.returncode
) )
except asyncio.TimeoutError: except TimeoutError:
return self._error(f"Command timed out after {timeout} seconds") return self._error(f"Command timed out after {timeout} seconds")
except Exception as e: except Exception as e:
logger.exception(f"Error executing command: {command}") logger.exception(f"Error executing command: {command}")
@@ -8,7 +8,7 @@ import shlex
from pathlib import Path from pathlib import Path
from src.domains.tools.base import BaseTool, ToolResult from src.domains.tools.base import BaseTool, ToolResult
from src.shared.logging import logged, get_logger from src.shared.logging import get_logger, logged
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -153,7 +153,7 @@ Examples:
self.max_output_size = max_output_size self.max_output_size = max_output_size
@logged() @logged()
async def execute( async def execute( # type: ignore[override] # see BaseTool.execute
self, self,
command: str, command: str,
cwd: str | None = None, cwd: str | None = None,
@@ -228,7 +228,7 @@ Examples:
exit_code=proc.returncode exit_code=proc.returncode
) )
except asyncio.TimeoutError: except TimeoutError:
return self._error(f"Command timed out after {timeout} seconds") return self._error(f"Command timed out after {timeout} seconds")
except Exception as e: except Exception as e:
logger.exception(f"Error executing command: {command}") logger.exception(f"Error executing command: {command}")
@@ -340,7 +340,7 @@ Examples:
# Handle git with flags before subcommand (e.g., git -C path status) # Handle git with flags before subcommand (e.g., git -C path status)
if git_subcommand.startswith("-"): if git_subcommand.startswith("-"):
# Find the actual subcommand # Find the actual subcommand
for i, token in enumerate(tokens[2:], 2): for _i, token in enumerate(tokens[2:], 2):
if not token.startswith("-"): if not token.startswith("-"):
git_subcommand = token git_subcommand = token
break break
+44 -5
View File
@@ -54,17 +54,54 @@ class _SanitizedAsyncOpenAI(AsyncOpenAI):
super().__init__(api_key="ollama", **kwargs) super().__init__(api_key="ollama", **kwargs)
@property @property
def chat(self) -> "_SanitizedChat": def chat(self) -> "_SanitizedChat": # type: ignore[override]
"""Return sanitized chat interface.""" """Return sanitized chat interface.
Deliberately incompatible with AsyncOpenAI.chat, which is a Chat
resource. Replacing it is the entire mechanism of this class: Ollama
rejects assistant messages carrying content: null alongside tool_calls,
so every completion has to pass through the sanitiser. Typing it as the
parent's Chat would describe an object this class does not return.
The suppression is on this member alone; the rest of the client keeps
its inherited types.
"""
return _SanitizedChat(self) return _SanitizedChat(self)
def _parent_chat(client: AsyncOpenAI) -> Any:
"""Get AsyncOpenAI's own `chat`, bypassing the subclass override.
This read the descriptor's `.fget` until 2026-08-11, which is the property
API. openai made `chat` a functools.cached_property, whose getter is `.func`,
so the call raised AttributeError the moment anything touched `.chat` that
is, on the first completion any agent tried to make. Verified broken in the
running container on openai 2.46.0 as well as locally on 2.15.0.
It went unnoticed because the endpoints that reach it had served no requests
in 30 days, and because the line carried a bare `# type: ignore` that
suppressed exactly the complaint that would have flagged it.
Reading whichever getter the descriptor actually exposes keeps this working
across that change and the reverse of it, and raises something legible if
openai adopts a third shape.
"""
descriptor = AsyncOpenAI.__dict__["chat"]
getter = getattr(descriptor, "func", None) or getattr(descriptor, "fget", None)
if getter is None: # pragma: no cover - defensive
raise TypeError(
f"AsyncOpenAI.chat is a {type(descriptor).__name__} with neither "
"'func' nor 'fget'; the sanitising wrapper needs updating"
)
return getter(client)
class _SanitizedChat: class _SanitizedChat:
"""Chat interface wrapper with sanitized completions.""" """Chat interface wrapper with sanitized completions."""
def __init__(self, client: _SanitizedAsyncOpenAI): def __init__(self, client: _SanitizedAsyncOpenAI):
self._client = client self._client = client
self._original_chat = AsyncOpenAI.chat.fget(client) # type: ignore self._original_chat = _parent_chat(client)
@property @property
def completions(self) -> "_SanitizedCompletions": def completions(self) -> "_SanitizedCompletions":
@@ -110,8 +147,10 @@ def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
msg_copy = dict(msg) msg_copy = dict(msg)
# Fix null content in assistant messages with tool calls # Fix null content in assistant messages with tool calls
if msg_copy.get("role") == "assistant": if (
if msg_copy.get("content") is None and msg_copy.get("tool_calls"): msg_copy.get("role") == "assistant"
and msg_copy.get("content") is None and msg_copy.get("tool_calls")
):
msg_copy["content"] = "" msg_copy["content"] = ""
logger.debug( logger.debug(
f"Sanitized null content, tool_calls={len(msg_copy['tool_calls'])}" f"Sanitized null content, tool_calls={len(msg_copy['tool_calls'])}"
+3 -3
View File
@@ -64,15 +64,15 @@ class Settings(BaseSettings):
# LLM - Ollama (always hot in VRAM on tower-of-joy) # LLM - Ollama (always hot in VRAM on tower-of-joy)
ollama_url: str = "http://192.168.86.149:11434" ollama_url: str = "http://192.168.86.149:11434"
ollama_agent_model: str = "mistral-nemo-large:latest" ollama_agent_model: str = "gemma4:e2b"
ollama_embed_model: str = "nomic-embed-text:latest" ollama_embed_model: str = "nomic-embed-text:latest"
# Auth - Tatlock integration # Auth - Tatlock integration
tatlock_api_url: str | None = "http://192.168.86.149:8000" tatlock_api_url: str | None = "http://tatlock:8000"
internal_api_key: str | None = None internal_api_key: str | None = None
# Web search - SearXNG (use SEARXNG_URL env var to override) # Web search - SearXNG (use SEARXNG_URL env var to override)
searxng_url: str = "http://192.168.86.149:8087" searxng_url: str = "http://searxng:8080"
searxng_timeout: int = 10 searxng_timeout: int = 10
# Tool execution # Tool execution
+20 -6
View File
@@ -16,6 +16,7 @@ from collections.abc import Callable
from contextvars import ContextVar, Token from contextvars import ContextVar, Token
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, ParamSpec, TypeVar, cast
from uuid import uuid4 from uuid import uuid4
# === Trace Context === # === Trace Context ===
@@ -80,12 +81,21 @@ def get_logger(name: str) -> logging.Logger:
# === Decorator === # === Decorator ===
# @logged wraps ~24 functions across this package. Untyped, its decorator
# erased every one of their signatures, so mypy saw `Any` coming back from
# annotated functions like `ToolResult.execute() -> ToolResult`. That surfaced
# as 33 no-any-return errors scattered across the tools and agents — each
# reading like a local annotation slip, all of them this one decorator.
P = ParamSpec("P")
R = TypeVar("R")
def logged( def logged(
logger: logging.Logger | None = None, logger: logging.Logger | None = None,
slow_threshold_ms: float = 100.0, slow_threshold_ms: float = 100.0,
warn_threshold_ms: float = 500.0, warn_threshold_ms: float = 500.0,
include_args: bool = False, include_args: bool = False,
): ) -> Callable[[Callable[P, R]], Callable[P, R]]:
""" """
Decorator for automatic function logging with temporal benchmarking. Decorator for automatic function logging with temporal benchmarking.
@@ -102,7 +112,7 @@ def logged(
@logged(slow_threshold_ms=50, warn_threshold_ms=200) @logged(slow_threshold_ms=50, warn_threshold_ms=200)
def critical_path(): ... def critical_path(): ...
""" """
def decorator(func: Callable): def decorator(func: Callable[P, R]) -> Callable[P, R]:
nonlocal logger nonlocal logger
if logger is None: if logger is None:
logger = logging.getLogger(func.__module__) logger = logging.getLogger(func.__module__)
@@ -140,7 +150,7 @@ def logged(
logger.debug(f"[{tid}] {func_name} completed in {duration:.2f}ms") logger.debug(f"[{tid}] {func_name} completed in {duration:.2f}ms")
@functools.wraps(func) @functools.wraps(func)
async def async_wrapper(*args, **kwargs): async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
span = _create_span() span = _create_span()
token = _current_span.set(span) token = _current_span.set(span)
@@ -150,7 +160,7 @@ def logged(
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}") logger.debug(f"[{span.trace_id[:8]}] -> {func_name}")
try: try:
result = await func(*args, **kwargs) result = await cast(Any, func(*args, **kwargs))
_log_completion(span) _log_completion(span)
return result return result
except Exception as e: except Exception as e:
@@ -160,7 +170,7 @@ def logged(
_current_span.reset(token) _current_span.reset(token)
@functools.wraps(func) @functools.wraps(func)
def sync_wrapper(*args, **kwargs): def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
span = _create_span() span = _create_span()
token = _current_span.set(span) token = _current_span.set(span)
@@ -179,7 +189,11 @@ def logged(
finally: finally:
_current_span.reset(token) _current_span.reset(token)
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper # The branch is chosen at decoration time; mypy cannot narrow R to a
# coroutine on the strength of iscoroutinefunction, so the union is
# asserted here once instead of at every call site.
chosen = async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
return cast(Callable[P, R], chosen)
return decorator return decorator
+8 -3
View File
@@ -79,7 +79,7 @@ def calculate_backoff(
Delay in seconds Delay in seconds
""" """
# Exponential backoff: base_delay * 2^attempt # Exponential backoff: base_delay * 2^attempt
delay = min(base_delay * (2 ** attempt), max_delay) delay: float = min(base_delay * (2 ** attempt), max_delay)
if jitter: if jitter:
# Add up to 25% random jitter # Add up to 25% random jitter
@@ -114,6 +114,11 @@ def with_retry(
return response.json() return response.json()
""" """
extra_exceptions = retryable_exceptions or () extra_exceptions = retryable_exceptions or ()
# Bound to a named, typed tuple: mypy cannot verify that a star-unpacked
# tuple in an `except` clause holds exception classes, and reports it as
# "exception type must be derived from BaseException" — which reads like a
# real defect rather than an inference limit.
retry_on: tuple[type[Exception], ...] = (*RETRYABLE_EXCEPTIONS, *extra_exceptions)
def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]: def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
@wraps(func) @wraps(func)
@@ -124,7 +129,7 @@ def with_retry(
try: try:
return await func(*args, **kwargs) return await func(*args, **kwargs)
except (*RETRYABLE_EXCEPTIONS, *extra_exceptions) as e: except retry_on as e:
last_exception = e last_exception = e
should_retry = True should_retry = True
@@ -145,7 +150,7 @@ def with_retry(
await asyncio.sleep(delay) await asyncio.sleep(delay)
elif not should_retry: elif not should_retry:
# Non-retryable HTTP error # Non-retryable HTTP error
raise last_exception # type: ignore raise last_exception
# All retries exhausted # All retries exhausted
logger.error( logger.error(
-1
View File
@@ -21,7 +21,6 @@ from httpx import ASGITransport, AsyncClient
from src.main import app from src.main import app
# ============================================================================= # =============================================================================
# Command Line Options # Command Line Options
# ============================================================================= # =============================================================================
+8 -4
View File
@@ -4,8 +4,9 @@ Tests for agent REST API endpoints.
Includes integration tests that verify real code paths work correctly Includes integration tests that verify real code paths work correctly
without over-mocking (only LLM calls are mocked). without over-mocking (only LLM calls are mocked).
""" """
from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from src.domains.agents.schemas import PermissionMode from src.domains.agents.schemas import PermissionMode
@@ -312,9 +313,10 @@ class TestAgentMethodSignatures:
def test_task_agent_run_accepts_mode(self): def test_task_agent_run_accepts_mode(self):
"""Verify TaskAgentImpl.run() accepts mode parameter.""" """Verify TaskAgentImpl.run() accepts mode parameter."""
from src.domains.agents.task.agent import TaskAgentImpl
import inspect import inspect
from src.domains.agents.task.agent import TaskAgentImpl
sig = inspect.signature(TaskAgentImpl.run) sig = inspect.signature(TaskAgentImpl.run)
params = list(sig.parameters.keys()) params = list(sig.parameters.keys())
@@ -325,9 +327,10 @@ class TestAgentMethodSignatures:
def test_task_agent_run_stream_accepts_mode(self): def test_task_agent_run_stream_accepts_mode(self):
"""Verify TaskAgentImpl.run_stream() accepts mode parameter.""" """Verify TaskAgentImpl.run_stream() accepts mode parameter."""
from src.domains.agents.task.agent import TaskAgentImpl
import inspect import inspect
from src.domains.agents.task.agent import TaskAgentImpl
sig = inspect.signature(TaskAgentImpl.run_stream) sig = inspect.signature(TaskAgentImpl.run_stream)
params = list(sig.parameters.keys()) params = list(sig.parameters.keys())
@@ -337,9 +340,10 @@ class TestAgentMethodSignatures:
def test_trace_span_signature(self): def test_trace_span_signature(self):
"""Verify trace_span only accepts expected parameters.""" """Verify trace_span only accepts expected parameters."""
from src.shared.logging import trace_span
import inspect import inspect
from src.shared.logging import trace_span
sig = inspect.signature(trace_span.__init__) sig = inspect.signature(trace_span.__init__)
params = list(sig.parameters.keys()) params = list(sig.parameters.keys())
+13 -11
View File
@@ -3,15 +3,14 @@ Tests for conversations domain.
Tests conversation CRUD, context building, and API endpoints. Tests conversation CRUD, context building, and API endpoints.
""" """
import pytest
from uuid import uuid4 from uuid import uuid4
import pytest
from src.domains.conversations.models import Conversation, Message from src.domains.conversations.models import Conversation, Message
from src.domains.conversations.schemas import ( from src.domains.conversations.schemas import (
CreateConversationRequest,
AddMessageRequest, AddMessageRequest,
ConversationResponse, CreateConversationRequest,
MessageResponse,
) )
@@ -173,9 +172,10 @@ class TestConversationService:
@pytest.mark.anyio @pytest.mark.anyio
async def test_context_prompt_no_history(self): async def test_context_prompt_no_history(self):
"""Test building context prompt with no history.""" """Test building context prompt with no history."""
from src.domains.conversations.service import ConversationService
from unittest.mock import MagicMock from unittest.mock import MagicMock
from src.domains.conversations.service import ConversationService
# Create mock session # Create mock session
mock_session = MagicMock() mock_session = MagicMock()
service = ConversationService(mock_session) service = ConversationService(mock_session)
@@ -190,10 +190,11 @@ class TestConversationService:
@pytest.mark.anyio @pytest.mark.anyio
async def test_context_prompt_with_history(self): async def test_context_prompt_with_history(self):
"""Test building context prompt with message history.""" """Test building context prompt with message history."""
from src.domains.conversations.service import ConversationService
from src.domains.conversations.models import Message
from unittest.mock import MagicMock from unittest.mock import MagicMock
from src.domains.conversations.models import Message
from src.domains.conversations.service import ConversationService
mock_session = MagicMock() mock_session = MagicMock()
service = ConversationService(mock_session) service = ConversationService(mock_session)
@@ -221,10 +222,11 @@ class TestConversationService:
@pytest.mark.anyio @pytest.mark.anyio
async def test_context_prompt_with_summary(self): async def test_context_prompt_with_summary(self):
"""Test building context prompt with summary message.""" """Test building context prompt with summary message."""
from src.domains.conversations.service import ConversationService
from src.domains.conversations.models import Message
from unittest.mock import MagicMock from unittest.mock import MagicMock
from src.domains.conversations.models import Message
from src.domains.conversations.service import ConversationService
mock_session = MagicMock() mock_session = MagicMock()
service = ConversationService(mock_session) service = ConversationService(mock_session)
@@ -253,8 +255,8 @@ class TestSummarization:
def test_format_messages_for_summary(self): def test_format_messages_for_summary(self):
"""Test formatting messages for summarization.""" """Test formatting messages for summarization."""
from src.domains.conversations.summarize import format_messages_for_summary
from src.domains.conversations.models import Message from src.domains.conversations.models import Message
from src.domains.conversations.summarize import format_messages_for_summary
messages = [ messages = [
Message( Message(
@@ -276,8 +278,8 @@ class TestSummarization:
def test_format_messages_with_summary(self): def test_format_messages_with_summary(self):
"""Test formatting messages that include a summary.""" """Test formatting messages that include a summary."""
from src.domains.conversations.summarize import format_messages_for_summary
from src.domains.conversations.models import Message from src.domains.conversations.models import Message
from src.domains.conversations.summarize import format_messages_for_summary
messages = [ messages = [
Message( Message(
+1 -1
View File
@@ -6,8 +6,8 @@ from pathlib import Path
import pytest import pytest
from src.domains.tools.gitignore import GitignoreFilter, filter_gitignored
from src.domains.tools.file.glob import GlobFilesTool from src.domains.tools.file.glob import GlobFilesTool
from src.domains.tools.gitignore import GitignoreFilter, filter_gitignored
from src.domains.tools.search.grep import GrepContentTool from src.domains.tools.search.grep import GrepContentTool
+55
View File
@@ -0,0 +1,55 @@
"""The sanitising Ollama client must actually be reachable.
src/ollama/provider.py exists to work around Ollama rejecting assistant
messages that carry `content: null` alongside `tool_calls`. On 2026-08-11 it
raised AttributeError the moment anything touched `.chat`: it fetched the
parent's getter via `AsyncOpenAI.chat.fget`, and openai had made `chat` a
functools.cached_property, whose getter is `.func`.
Nothing caught it. The line carried a bare `# type: ignore`, so mypy stayed
quiet, and the endpoints that reach this code had served no requests in 30 days,
so no user hit it either. The mitigation was dead and everything looked fine.
These tests exercise the path rather than the types, because the failure was a
runtime attribute lookup that no annotation would have caught.
"""
from openai import AsyncOpenAI
from src.ollama.provider import _parent_chat, _SanitizedAsyncOpenAI, get_ollama_provider
class TestSanitizedClientIsReachable:
def test_chat_can_be_accessed(self):
"""The regression: this raised AttributeError, not a type error."""
client = _SanitizedAsyncOpenAI(base_url="http://localhost:11434/v1")
chat = client.chat
assert chat is not None
assert chat.completions is not None
def test_provider_reaches_completions(self):
"""The full chain an agent request walks, short of the network call."""
provider = get_ollama_provider()
assert provider._openai_client.chat.completions is not None
def test_parent_lookup_survives_either_descriptor_shape(self):
"""openai has used both property and cached_property for `chat`.
Whichever it is, the parent's own getter must be found — the previous
code hardcoded `.fget` and broke on the switch to cached_property.
"""
descriptor = AsyncOpenAI.__dict__["chat"]
assert hasattr(descriptor, "func") or hasattr(descriptor, "fget"), (
"AsyncOpenAI.chat exposes neither getter; _parent_chat needs updating"
)
client = _SanitizedAsyncOpenAI(base_url="http://localhost:11434/v1")
assert _parent_chat(client) is not None
def test_parent_chat_is_not_the_override(self):
"""It must return openai's Chat, not recurse into the subclass property.
Returning the subclass's own `chat` would be infinite recursion, and the
sanitiser would wrap itself instead of the real completions resource.
"""
client = _SanitizedAsyncOpenAI(base_url="http://localhost:11434/v1")
assert type(_parent_chat(client)).__name__ != "_SanitizedChat"
+1 -1
View File
@@ -6,7 +6,7 @@ Tests registration, API endpoints, and tool restrictions.
import pytest import pytest
from src.domains.agents.base import get_agent, list_agents from src.domains.agents.base import get_agent, list_agents
from src.domains.agents.plan import plan_agent, PlanAgentImpl from src.domains.agents.plan import PlanAgentImpl, plan_agent
class TestPlanAgentRegistration: class TestPlanAgentRegistration:
+4 -5
View File
@@ -1,17 +1,16 @@
""" """
Tests for retry utilities. Tests for retry utilities.
""" """
import pytest
from unittest.mock import AsyncMock, patch
import httpx import httpx
import pytest
from src.shared.retry import ( from src.shared.retry import (
with_retry, calculate_backoff,
retry_async,
is_retryable_exception, is_retryable_exception,
is_retryable_http_status, is_retryable_http_status,
calculate_backoff, retry_async,
with_retry,
) )
+3 -3
View File
@@ -6,10 +6,10 @@ from pathlib import Path
import pytest import pytest
from src.domains.tools.file.read import ReadFileTool
from src.domains.tools.file.write import WriteFileTool
from src.domains.tools.file.edit import EditFileTool from src.domains.tools.file.edit import EditFileTool
from src.domains.tools.file.glob import GlobFilesTool from src.domains.tools.file.glob import GlobFilesTool
from src.domains.tools.file.read import ReadFileTool
from src.domains.tools.file.write import WriteFileTool
from src.domains.tools.shell.bash_full import BashTool from src.domains.tools.shell.bash_full import BashTool
@@ -275,5 +275,5 @@ class TestResourceLimits:
assert result.success assert result.success
# Should only return 5 files # Should only return 5 files
lines = [l for l in result.data.strip().split("\n") if l] lines = [line for line in result.data.strip().split("\n") if line]
assert len(lines) <= 5 assert len(lines) <= 5
+16 -27
View File
@@ -3,11 +3,11 @@ Tests for the Task agent.
Tests registration, API endpoints, tool access, and spawn_agent functionality. Tests registration, API endpoints, tool access, and spawn_agent functionality.
""" """
import pytest import pytest
from unittest.mock import AsyncMock, patch
from src.domains.agents.base import get_agent, list_agents from src.domains.agents.base import get_agent, list_agents
from src.domains.agents.task import task_agent, TaskAgentImpl from src.domains.agents.task import TaskAgentImpl, task_agent
class TestTaskAgentRegistration: class TestTaskAgentRegistration:
@@ -90,39 +90,28 @@ class TestTaskAgentTools:
class TestSpawnAgentTool: class TestSpawnAgentTool:
"""Tests for spawn_agent orchestration functionality.""" """Tests for spawn_agent orchestration functionality."""
@pytest.mark.skip(
reason="never finished — the body built a mock context and then asserted "
"nothing, so it counted as a passing test while verifying nothing"
)
@pytest.mark.anyio @pytest.mark.anyio
async def test_spawn_explore_agent(self): async def test_spawn_explore_agent(self):
"""Test spawning an explore agent.""" """Spawning an explore agent should delegate to the explore agent.
from src.domains.agents.task.tools import register_task_tools
from src.domains.agents.base import AgentContext
from pydantic_ai import Agent, RunContext
from unittest.mock import MagicMock
# Create a mock context The scaffolding that used to sit here a MagicMock RunContext, an
ctx = MagicMock(spec=RunContext) AgentContext with a /tmp working dir, and a patch of
ctx.deps = AgentContext( src.domains.agents.base.get_agent ran and then stopped at the comment
working_dir="/tmp", "For now, verify the explore agent would be called correctly". There was
allowed_paths=["/tmp"], no assertion, so it passed unconditionally.
timeout_seconds=30
)
# Mock the explore agent Removed rather than tidied: ruff flagged its imports as unused, and
with patch("src.domains.agents.base.get_agent") as mock_get_agent: deleting those would have made the test look clean while leaving it
mock_explore = AsyncMock() hollow. git history has the setup for whoever finishes this.
mock_explore.run = AsyncMock(return_value="Found 5 Python files") """
mock_get_agent.return_value = mock_explore
# Import and call spawn_agent directly
from src.domains.agents.task import tools
# We need to test the actual tool function
# For now, verify the explore agent would be called correctly
@pytest.mark.anyio @pytest.mark.anyio
async def test_spawn_unknown_agent_returns_error(self): async def test_spawn_unknown_agent_returns_error(self):
"""Test that spawning unknown agent type returns error.""" """Test that spawning unknown agent type returns error."""
from src.domains.agents.base import AgentContext
from unittest.mock import MagicMock
from pydantic_ai import RunContext
# We can't easily test the tool directly, but we can verify # We can't easily test the tool directly, but we can verify
# the agent type validation logic # the agent type validation logic
+1 -2
View File
@@ -1,9 +1,8 @@
""" """
Tests for token counting utilities. Tests for token counting utilities.
""" """
import pytest
from src.shared.tokens import count_tokens, count_message_tokens, estimate_tokens from src.shared.tokens import count_message_tokens, count_tokens, estimate_tokens
class TestTokenCounting: class TestTokenCounting:
+1 -1
View File
@@ -6,8 +6,8 @@ from pathlib import Path
import pytest import pytest
from src.domains.tools.file.read import ReadFileTool
from src.domains.tools.file.glob import GlobFilesTool from src.domains.tools.file.glob import GlobFilesTool
from src.domains.tools.file.read import ReadFileTool
from src.domains.tools.search.grep import GrepContentTool from src.domains.tools.search.grep import GrepContentTool
from src.domains.tools.shell.bash import BashReadOnlyTool from src.domains.tools.shell.bash import BashReadOnlyTool
+3 -2
View File
@@ -1,8 +1,9 @@
""" """
Tests for WebSearchTool. Tests for WebSearchTool.
""" """
from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from src.domains.tools.search.web import WebSearchTool from src.domains.tools.search.web import WebSearchTool
@@ -12,7 +13,7 @@ class TestWebSearchTool:
@pytest.fixture @pytest.fixture
def tool(self): def tool(self):
return WebSearchTool(searxng_url="http://localhost:8087", timeout=5) return WebSearchTool(searxng_url="http://searxng:8080", timeout=5)
@pytest.fixture @pytest.fixture
def mock_search_response(self): def mock_search_response(self):