14 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Fable 5 65feb8b87a release api/v1.2.0
Build and Push API / release (push) Successful in 3s
Build and Push API / build (push) Successful in 1m18s
Webber through the boilerroom wrapper: flavor-adaptive tool_choice,
session identity at rank 20, and the sanitized-client wiring fix
that makes the choke point real.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-13 10:02:59 +02:00
jpmschweitzerandClaude Fable 5 1769ec2803 feat(backend): adapt every completion to the detected backend (workspace T-137)
One choke point in the sanitized client: the flavor is probed once
(the boilerroom wrapper names itself on /health, a bare llama-server
serves /props, Ollama answers neither) and every completion adapts.
The agents' tool_choice "required" survives only on Ollama — advisory
there, enforced by llama-server, an unbreakable tool loop through the
wrapper. Through the wrapper every completion carries webber's session
identity: session webber, eviction_order 20 in the decided ranking,
configurable via settings. The wrapper's balancing and compaction
signals are read from the response body's extra fields — an httpx
event-hook variant was tried and never fires under the openai SDK.

The enabling fix: the sanitized client was never in the request path.
The provider assigned self._openai_client, an attribute nobody reads —
OllamaProvider.client serves self._client — so every completion has
bypassed the null-content sanitizer since the class was introduced.
Exposed when the wrapper 503'd a session-less request the choke point
should have named; the client now goes through the constructor's
official openai_client parameter, and a wiring test pins
provider.client to the sanitized type. The same bug exists in tatlock
(its T-6, filed).

Verified against the live wrapper from the dev server: flavor
boilerroom detected, a tool-using explore run answered in 4.8 s with
no tool loop, webber resident at rank 20, and the wrapper parked
librarian and tatlock-experts to seat it — the ranking doing exactly
its job. Six new tests (227 green), five mutation-checked: the rank
default, the strip condition, the session-add condition, the
no-cache-on-failure rule, and the client wiring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-13 10:02:14 +02:00
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
65 changed files with 878 additions and 274 deletions
+36 -29
View File
@@ -1,4 +1,7 @@
{ {
"env": {
"PQL_VAULT": "/mnt/media/Projects/webber"
},
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(pql)", "Bash(pql)",
@@ -18,43 +21,47 @@
"Bash(curl -s http://localhost:8095/*)" "Bash(curl -s http://localhost:8095/*)"
], ],
"deny": [ "deny": [
"Bash(sudo *)", "Bash(/mnt/media/Projects/cladmin/ops/bin/toj)",
"Bash(su *)", "Bash(/mnt/media/Projects/cladmin/ops/bin/toj:*)",
"Bash(rm -rf /*)",
"Bash(rm -rf ~*)",
"Bash(rm -rf $HOME*)",
"Bash(mkfs*)",
"Bash(dd if=*)",
"Bash(chmod 777 *)",
"Bash(chmod -R 777 *)", "Bash(chmod -R 777 *)",
"Bash(find * -exec*)", "Bash(chmod 777 *)",
"Bash(dd if=*)",
"Bash(find * -delete*)", "Bash(find * -delete*)",
"Bash(git add -A*)", "Bash(find * -exec*)",
"Bash(git * add -A*)",
"Bash(git add --all*)",
"Bash(git * add --all*)", "Bash(git * add --all*)",
"Bash(git add .)", "Bash(git * add -A*)",
"Bash(git * add .)", "Bash(git * add .)",
"Bash(git push --force*)",
"Bash(git * push --force*)",
"Bash(git push -f*)",
"Bash(git * push -f*)",
"Bash(git reset --hard*)",
"Bash(git * reset --hard*)",
"Bash(git clean -fd*)",
"Bash(git * clean -fd*)",
"Bash(git clean -fdx*)",
"Bash(git * clean -fdx*)",
"Bash(git branch -D *)",
"Bash(git * branch -D *)", "Bash(git * branch -D *)",
"Bash(git checkout -- *)",
"Bash(git * checkout -- *)", "Bash(git * checkout -- *)",
"Bash(git restore .*)", "Bash(git * clean -fd*)",
"Bash(git * restore .*)", "Bash(git * clean -fdx*)",
"Bash(git commit --no-verify*)",
"Bash(git * commit --no-verify*)", "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 merge --no-ff*)",
"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:*)"
] ]
} }
} }
+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
+8
View File
@@ -80,3 +80,11 @@ webber-sandbox/.current_template
.claude/settings.local.json .claude/settings.local.json
.pql/* .pql/*
!.pql/changelog/ !.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
+18 -11
View File
@@ -125,24 +125,31 @@ pytest and ruff only run locally or on request. Verify tests pass before tagging
## Work tracking ## Work tracking
Work lives in **pql**, not a markdown TODO or `docs/COVERAGE.md`. Tickets *and* decisions for Work lives in **pql**, not a markdown TODO or `docs/COVERAGE.md`. **This repo's vault is
this repo live in the **workspace** vault; this repo's `.pql/` and `governance/` trees stay standalone** — its tickets and its internal decisions live here in `.pql/` and `governance/`,
empty (D-15). 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`.
Two things must be spelled out on every invocation from in here, and each fails differently: `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.
- **`pql` is not on the non-interactive `PATH`** — use `/home/jpmschweitzer/.local/bin/pql`. ```bash
- **`--vault /mnt/media/Projects` is mandatory.** pql anchors a vault at the nearest `.git/` /home/jpmschweitzer/.local/bin/pql ticket list # this repo's open work
ancestor, and this repo is one, so a bare call resolves to *this repo's* empty vault. Reads /home/jpmschweitzer/.local/bin/pql plan whatsnext # next unblocked item, with context
return nothing; a **write** creates a stray vault and starts ticket ids at T-1, colliding /home/jpmschweitzer/.local/bin/pql decisions list # this repo's own decisions
with the real ones. ```
Stack-level decisions that constrain this service live in the **workspace** vault and need the
flag:
```bash ```bash
/home/jpmschweitzer/.local/bin/pql --vault /mnt/media/Projects ticket list
/home/jpmschweitzer/.local/bin/pql --vault /mnt/media/Projects plan whatsnext
/home/jpmschweitzer/.local/bin/pql --vault /mnt/media/Projects decisions list --domain webber /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. Do not add a TODO section to a markdown file.
## Git ## Git
+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
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"
+44
View File
@@ -7,8 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [1.2.0] - 2026-09-13
### Added
- Backend adaptation at the provider choke point (workspace T-137):
the local backend's flavor is probed once — the boilerroom wrapper
names itself on `/health`, a bare llama-server serves `/props`,
Ollama answers neither — and every completion adapts. The agents'
`tool_choice: "required"` survives only on Ollama, where it is a
useful advisory nudge; llama-server enforces it per request, which
through the wrapper is an unbreakable tool loop. Through the wrapper
every completion carries webber's session identity —
`session: webber`, `eviction_order: 20` per the decided ranking
(tatlock phases 40, experts 35, librarian 30; lower parks sooner),
configurable via `BACKEND_SESSION_NAME`/`BACKEND_SESSION_RANK` —
and the wrapper's `balancing`/`compaction_due` body signals are
logged. A transport failure during the probe answers "ollama"
without caching, so a backend that was down at first call is
re-probed rather than misclassified forever.
### Fixed
- The sanitized OpenAI client was never used: the provider assigned
`self._openai_client`, an attribute nobody reads —
`OllamaProvider.client` serves `self._client`. Every completion
bypassed the null-content sanitizer since the class was introduced;
exposed 2026-09-13 when the wrapper 503'd a session-less request
that the choke point should have named. The sanitized client now
goes through the constructor's official `openai_client` parameter,
and a wiring test pins `provider.client` to the sanitized type.
## [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 ### 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 - 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 ## [1.0.1] - 2026-07-19
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "webber-api" name = "webber-api"
version = "1.0.1" version = "1.2.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
+147 -10
View File
@@ -6,9 +6,20 @@ which PydanticAI sends for assistant messages that only contain tool calls.
This provider sanitizes messages to use empty strings instead of null. This provider sanitizes messages to use empty strings instead of null.
Ported from tatlock project. Ported from tatlock project.
It is also the one choke point where every completion is adapted to
the detected backend (workspace T-137): the agents author
`tool_choice: "required"` for Ollama, where it is a useful advisory
nudge — llama-server enforces it on every request in a run, which
through the boilerroom wrapper is an unbreakable tool loop (the
tatlock v2.6.0 cutover incident). The nudge therefore survives only
on Ollama, and through the wrapper every completion carries webber's
session name and eviction rank.
""" """
import asyncio
from typing import Any from typing import Any
import httpx
from openai import AsyncOpenAI from openai import AsyncOpenAI
from pydantic_ai.providers.ollama import OllamaProvider from pydantic_ai.providers.ollama import OllamaProvider
@@ -17,6 +28,85 @@ from src.shared.logging import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
# Which server answers behind ollama_url: "boilerroom", "llama-server"
# or "ollama". Probed once, on the first completion: the wrapper names
# itself on /health, a bare llama-server serves /props, Ollama answers
# neither. A pure transport failure returns "ollama" WITHOUT caching,
# so a backend that was down at first call is re-probed rather than
# nudging llama-server forever. Ported from tatlock T-4.
_local_flavor: str | None = None
_flavor_lock = asyncio.Lock()
async def _detect_flavor() -> str:
global _local_flavor
if _local_flavor is not None:
return _local_flavor
async with _flavor_lock:
if _local_flavor is not None:
return _local_flavor
base = get_settings().ollama_url.rstrip("/")
connected = False
flavor = "ollama"
async with httpx.AsyncClient(timeout=5.0) as client:
try:
health = await client.get(f"{base}/health")
connected = True
if health.status_code == 200 and health.json().get("service") == "boilerroom":
flavor = "boilerroom"
except (httpx.HTTPError, ValueError):
pass
if flavor != "boilerroom":
try:
props = await client.get(f"{base}/props")
connected = True
if props.status_code == 200:
flavor = "llama-server"
except httpx.HTTPError:
pass
if connected:
_local_flavor = flavor
logger.info(f"local backend flavor detected: {flavor}")
return flavor
async def _adapt_to_backend(kwargs: dict[str, Any]) -> dict[str, Any]:
"""Adapt one completion's extension fields to the detected backend."""
flavor = await _detect_flavor()
extra = dict(kwargs.get("extra_body") or {})
if flavor != "ollama":
# Advisory on Ollama, enforced by llama-server: an unbreakable
# tool loop through the wrapper. The nudge stays home.
extra.pop("tool_choice", None)
if flavor == "boilerroom":
settings = get_settings()
extra["session"] = settings.backend_session_name
extra["eviction_order"] = settings.backend_session_rank
if extra:
kwargs["extra_body"] = extra
else:
kwargs.pop("extra_body", None)
return kwargs
def _log_wrapper_signals(response: Any) -> None:
"""Log the wrapper's per-response session signals (workspace T-137).
Read from the parsed body's extra fields — the wrapper injects
`balancing` and `compaction_due` into non-streamed JSON answers,
and openai's pydantic models retain unknown fields in
`model_extra`. (An httpx event-hook variant was tried first and
never fired under the SDK; the body is the reliable channel, and
it is absent on any other backend, so this costs nothing there.)
Streams carry the signals in headers only and go unlogged here.
"""
extra = getattr(response, "model_extra", None) or {}
balancing = extra.get("balancing")
if balancing:
logger.info(f"backend balancing: {balancing}")
if extra.get("compaction_due"):
logger.warning("backend compaction due for webber's session")
class WebberOllamaProvider(OllamaProvider): class WebberOllamaProvider(OllamaProvider):
""" """
@@ -38,10 +128,14 @@ class WebberOllamaProvider(OllamaProvider):
clean_host = settings.ollama_url.rstrip("/") clean_host = settings.ollama_url.rstrip("/")
base_url = f"{clean_host}/v1" base_url = f"{clean_host}/v1"
super().__init__(base_url=base_url) # The sanitized client goes through the official constructor
# parameter: the provider's `.client` property serves `_client`,
# Override the client with our sanitized version # and the previous pattern — poking `self._openai_client` after
self._openai_client = _SanitizedAsyncOpenAI(base_url=base_url) # super().__init__ had built its own client — assigned an
# attribute nobody reads. Every completion bypassed the
# sanitizer and the backend adaptation until 2026-09-13, when
# the wrapper's 503 on a session-less request exposed it.
super().__init__(openai_client=_SanitizedAsyncOpenAI(base_url=base_url))
logger.debug(f"WebberOllamaProvider created with base_url={base_url}") logger.debug(f"WebberOllamaProvider created with base_url={base_url}")
@@ -54,17 +148,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":
@@ -88,7 +219,11 @@ class _SanitizedCompletions:
if "messages" in kwargs: if "messages" in kwargs:
kwargs["messages"] = _sanitize_messages(kwargs["messages"]) kwargs["messages"] = _sanitize_messages(kwargs["messages"])
return await self._original.create(**kwargs) kwargs = await _adapt_to_backend(kwargs)
response = await self._original.create(**kwargs)
_log_wrapper_signals(response)
return response
def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -110,8 +245,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'])}"
+7
View File
@@ -67,6 +67,13 @@ class Settings(BaseSettings):
ollama_agent_model: str = "gemma4:e2b" ollama_agent_model: str = "gemma4:e2b"
ollama_embed_model: str = "nomic-embed-text:latest" ollama_embed_model: str = "nomic-embed-text:latest"
# Session identity through the boilerroom wrapper (workspace T-137):
# rank 20 in the decided ordering — tatlock phases 40, experts 35,
# librarian 30, webber 20; lower parks sooner. The fields only mean
# something to the wrapper; Ollama and a bare llama-server ignore them.
backend_session_name: str = "webber"
backend_session_rank: int = 20
# Auth - Tatlock integration # Auth - Tatlock integration
tatlock_api_url: str | None = "http://tatlock:8000" tatlock_api_url: str | None = "http://tatlock:8000"
internal_api_key: str | None = None internal_api_key: str | None = None
+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
+61
View File
@@ -0,0 +1,61 @@
"""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.
Through `.client` — the property pydantic_ai actually reads. The
old assertion walked `_openai_client`, a lookalike attribute
nobody read, which is exactly how the sanitizer sat bypassed in
production until 2026-09-13.
"""
provider = get_ollama_provider()
assert provider.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:
+122
View File
@@ -0,0 +1,122 @@
"""
Backend adaptation at the provider choke point (workspace T-137).
The flavor globals are set directly so the tests are deterministic
regardless of which backend is reachable; the wire facts the probe
relies on are the wrapper's contract, pinned in its own repo.
"""
import pytest
from src.ollama import provider
from src.shared.config import get_settings
@pytest.fixture(autouse=True)
def reset_flavor(monkeypatch):
"""Each test states its flavor; nothing leaks between them."""
monkeypatch.setattr(provider, "_local_flavor", None)
@pytest.mark.anyio
async def test_ollama_keeps_the_advisory_nudge(monkeypatch):
monkeypatch.setattr(provider, "_local_flavor", "ollama")
kwargs = await provider._adapt_to_backend(
{"extra_body": {"tool_choice": "required"}}
)
assert kwargs["extra_body"] == {"tool_choice": "required"}
@pytest.mark.anyio
async def test_wrapper_strips_the_nudge_and_names_the_session(monkeypatch):
# tool_choice "required" is enforced by llama-server — through the
# wrapper it is the unbreakable tool loop, so it must not pass.
monkeypatch.setattr(provider, "_local_flavor", "boilerroom")
kwargs = await provider._adapt_to_backend(
{"extra_body": {"tool_choice": "required"}}
)
extra = kwargs["extra_body"]
assert "tool_choice" not in extra
assert extra["session"] == "webber"
assert extra["eviction_order"] == 20
@pytest.mark.anyio
async def test_bare_llama_server_strips_without_naming(monkeypatch):
monkeypatch.setattr(provider, "_local_flavor", "llama-server")
kwargs = await provider._adapt_to_backend(
{"extra_body": {"tool_choice": "required"}}
)
assert "extra_body" not in kwargs
@pytest.mark.anyio
async def test_rank_and_name_come_from_settings(monkeypatch):
monkeypatch.setattr(provider, "_local_flavor", "boilerroom")
settings = get_settings()
monkeypatch.setattr(settings, "backend_session_name", "webber-test")
monkeypatch.setattr(settings, "backend_session_rank", 7)
kwargs = await provider._adapt_to_backend({})
assert kwargs["extra_body"] == {"session": "webber-test", "eviction_order": 7}
@pytest.mark.anyio
async def test_other_extension_fields_survive_adaptation(monkeypatch):
monkeypatch.setattr(provider, "_local_flavor", "boilerroom")
kwargs = await provider._adapt_to_backend({"extra_body": {"marker": 1}})
assert kwargs["extra_body"]["marker"] == 1
assert kwargs["extra_body"]["session"] == "webber"
def test_body_signals_are_logged(monkeypatch):
# The httpx event-hook variant never fired under the SDK; the body
# read must demonstrably log, or the signal passes silently again.
calls = []
class StubLogger:
def info(self, msg):
calls.append(("info", msg))
def warning(self, msg):
calls.append(("warning", msg))
monkeypatch.setattr(provider, "logger", StubLogger())
class Busy:
def __init__(self):
self.model_extra = {
"balancing": [{"parked": "librarian"}],
"compaction_due": True,
}
provider._log_wrapper_signals(Busy())
assert any(kind == "info" and "parked" in msg for kind, msg in calls)
assert any(kind == "warning" for kind, msg in calls)
calls.clear()
class Quiet:
def __init__(self):
self.model_extra = {"balancing": [], "compaction_due": False}
provider._log_wrapper_signals(Quiet())
assert calls == []
def test_provider_serves_the_sanitized_client():
# The whole mechanism rides on `.client` returning OUR instance:
# assigning a lookalike attribute after super().__init__ built its
# own client is how the sanitizer silently died until 2026-09-13.
p = provider.WebberOllamaProvider(base_url="http://127.0.0.1:9/v1")
assert isinstance(p.client, provider._SanitizedAsyncOpenAI)
@pytest.mark.anyio
async def test_probe_failure_answers_ollama_without_caching(monkeypatch):
# A backend that is down at first call must be re-probed later —
# caching "ollama" forever would nudge llama-server into the tool
# loop the moment the wrapper came back.
settings = get_settings()
monkeypatch.setattr(settings, "ollama_url", "http://127.0.0.1:9")
flavor = await provider._detect_flavor()
assert flavor == "ollama"
assert provider._local_flavor is None
+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
+2 -1
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