10 Commits
Author SHA1 Message Date
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
63 changed files with 593 additions and 275 deletions
+42 -35
View File
@@ -1,4 +1,7 @@
{
"env": {
"PQL_VAULT": "/mnt/media/Projects/webber"
},
"permissions": {
"allow": [
"Bash(pql)",
@@ -18,43 +21,47 @@
"Bash(curl -s http://localhost:8095/*)"
],
"deny": [
"Bash(sudo *)",
"Bash(su *)",
"Bash(/mnt/media/Projects/cladmin/ops/bin/toj)",
"Bash(/mnt/media/Projects/cladmin/ops/bin/toj:*)",
"Bash(chmod -R 777 *)",
"Bash(chmod 777 *)",
"Bash(dd if=*)",
"Bash(find * -delete*)",
"Bash(find * -exec*)",
"Bash(git * add --all*)",
"Bash(git * add -A*)",
"Bash(git * add .)",
"Bash(git * branch -D *)",
"Bash(git * checkout -- *)",
"Bash(git * clean -fd*)",
"Bash(git * clean -fdx*)",
"Bash(git * commit --no-verify*)",
"Bash(git * merge --no-ff*)",
"Bash(git * push --force*)",
"Bash(git * push -f*)",
"Bash(git * reset --hard*)",
"Bash(git * restore .*)",
"Bash(git add --all*)",
"Bash(git add -A*)",
"Bash(git add .)",
"Bash(git branch -D *)",
"Bash(git checkout -- *)",
"Bash(git clean -fd*)",
"Bash(git clean -fdx*)",
"Bash(git commit --no-verify*)",
"Bash(git merge --no-ff*)",
"Bash(git push --force*)",
"Bash(git push -f*)",
"Bash(git reset --hard*)",
"Bash(git restore .*)",
"Bash(mkfs*)",
"Bash(rm -rf $HOME*)",
"Bash(rm -rf /*)",
"Bash(rm -rf ~*)",
"Bash(rm -rf $HOME*)",
"Bash(mkfs*)",
"Bash(dd if=*)",
"Bash(chmod 777 *)",
"Bash(chmod -R 777 *)",
"Bash(find * -exec*)",
"Bash(find * -delete*)",
"Bash(git add -A*)",
"Bash(git * add -A*)",
"Bash(git add --all*)",
"Bash(git * add --all*)",
"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 checkout -- *)",
"Bash(git * checkout -- *)",
"Bash(git restore .*)",
"Bash(git * restore .*)",
"Bash(git commit --no-verify*)",
"Bash(git * commit --no-verify*)",
"Bash(git merge --no-ff*)",
"Bash(git * merge --no-ff*)"
"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
.pql/*
!.pql/changelog/
# pql shims planted by `pql init` into the dir core.hooksPath points at.
# Per-clone: each embeds the absolute path of the pql binary that planted it.
# Only .githooks/pre-push is shared.
.githooks/pre-commit
.githooks/post-merge
.githooks/post-checkout
.githooks/post-rewrite
+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 lives in **pql**, not a markdown TODO or `docs/COVERAGE.md`. Tickets *and* decisions for
this repo live in the **workspace** vault; this repo's `.pql/` and `governance/` trees stay
empty (D-15).
Work lives in **pql**, not a markdown TODO or `docs/COVERAGE.md`. **This repo's vault is
standalone** — its tickets and its internal decisions live here in `.pql/` and `governance/`,
and travel with a clone, because `.pql/changelog/` is committed and replayed by the git hooks
(workspace D-15). The databases are gitignored and rebuildable with `pql plan rebuild`.
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`.
- **`--vault /mnt/media/Projects` is mandatory.** pql anchors a vault at the nearest `.git/`
ancestor, and this repo is one, so a bare call resolves to *this repo's* empty vault. Reads
return nothing; a **write** creates a stray vault and starts ticket ids at T-1, colliding
with the real ones.
```bash
/home/jpmschweitzer/.local/bin/pql ticket list # this repo's open work
/home/jpmschweitzer/.local/bin/pql plan whatsnext # next unblocked item, with context
/home/jpmschweitzer/.local/bin/pql decisions list # this repo's own decisions
```
Stack-level decisions that constrain this service live in the **workspace** vault and need the
flag:
```bash
/home/jpmschweitzer/.local/bin/pql --vault /mnt/media/Projects 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
```
Note `ticket new --decision D-N` resolves ids within **one** vault, so a ticket here cannot link
to a workspace decision. Cite the id in the ticket body instead.
Do not add a TODO section to a markdown file.
## Git
+62
View File
@@ -0,0 +1,62 @@
# 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
setup: ## Create the webber-api venv and install dev dependencies
cd $(API) && $(PYTHON) -m venv .venv && .venv/bin/pip install -e ".[dev]"
.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"
+13
View File
@@ -7,8 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.1.0] - 2026-08-11
### Added
- `webber version` command. It was implemented and imported but never registered, so the
subcommand did not exist; the fuller output includes the resolved Ollama URL and model.
### Fixed
- The Ollama `content: null` sanitiser raised `AttributeError` on first use. It looked up the
parent client's chat getter via `.fget`, and openai now exposes `chat` as a
`cached_property`. Any agent request would have failed before reaching the model.
### Changed
- Default `OLLAMA_AGENT_MODEL` is now `gemma4:e2b` instead of `mistral-nemo-large:latest`, so a deployment without an explicit override no longer exhausts shared GPU memory
- `BaseAgent` is generic over its context type and `run_stream` is typed as
`AsyncIterator[str | StreamEvent]`, matching what callers already receive.
## [1.0.1] - 2026-07-19
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "webber-api"
version = "1.0.1"
version = "1.1.0"
description = "Webber API - Multi-Agent AI Development Server"
authors = [
{name = "jpmschweitzer"}
+3
View File
@@ -15,6 +15,9 @@ pip-audit~=2.9.0
# Type checking
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
ruff~=0.9.4
+1 -1
View File
@@ -6,9 +6,9 @@ from pathlib import Path
import typer
from src.cli.session.loop import AgenticLoop
from src.cli.theme import get_theme
from src.cli.ui.console import get_console
from src.cli.session.loop import AgenticLoop
from src.shared.logging import setup_logging
console = get_console()
+7 -1
View File
@@ -53,11 +53,17 @@ def main(
# 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
app.command(name="chat")(chat.chat_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__":
+1 -1
View File
@@ -4,4 +4,4 @@ Session management for CLI.
from src.cli.session.context import SessionState
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.
"""
from typing import Any
from rich.console import Console
from src.cli.session.context import SessionState
from src.cli.ui.display import format_response
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__)
+2 -2
View File
@@ -4,7 +4,7 @@ CLI theme configuration.
Centralized color and style definitions for the Webber CLI.
All color choices should be defined here for easy customization.
"""
from dataclasses import dataclass
from dataclasses import dataclass, field
@dataclass(frozen=True)
@@ -36,7 +36,7 @@ class ThemeColors:
class ThemeConfig:
"""Complete theme configuration."""
colors: ThemeColors = ThemeColors()
colors: ThemeColors = field(default_factory=ThemeColors)
# Spinner style for loading indicators
spinner: str = "dots"
+2 -2
View File
@@ -2,6 +2,6 @@
CLI UI components.
"""
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 src.cli.theme import get_theme
from src.cli.ui.console import get_console
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
__all__ = [
"Base",
"Database",
"get_database",
"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.
"""
from src.domains.agents.base import (
BaseAgent,
AgentContext,
AgentProtocol,
register_agent,
BaseAgent,
get_agent,
list_agents,
get_registry,
list_agents,
register_agent,
)
from src.domains.agents.explore import (
ExploreAgentImpl,
ExploreContext,
explore_agent,
explore,
explore_agent,
)
__all__ = [
# Base classes
"BaseAgent",
"AgentContext",
"AgentProtocol",
# Registry functions
"register_agent",
"get_agent",
"list_agents",
"get_registry",
# Base classes
"BaseAgent",
# Explore agent
"ExploreAgentImpl",
"ExploreContext",
"explore_agent",
"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 collections.abc import AsyncIterator
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 src.domains.agents.schemas import StreamEvent
from src.shared.logging import get_logger
logger = get_logger(__name__)
@@ -27,6 +28,15 @@ class AgentContext:
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
class AgentProtocol(Protocol):
"""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.
@@ -71,9 +81,10 @@ class BaseAgent(ABC):
name = "explore"
description = "Fast codebase exploration"
def _create_agent(self) -> Agent:
# Create and configure PydanticAI agent
...
class ExploreAgent(BaseAgent[ExploreContext]):
def _create_agent(self) -> Agent[ExploreContext, str]:
# Create and configure PydanticAI agent
...
async def run(self, prompt: str, **kwargs) -> str:
# Execute agent
@@ -92,15 +103,21 @@ class BaseAgent(ABC):
"""Human-readable description."""
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
def agent(self) -> Agent:
def agent(self) -> "Agent[CtxT, str]":
"""Lazy-loaded PydanticAI agent."""
if not hasattr(self, '_agent') or self._agent is None:
if self._agent is None:
self._agent = self._create_agent()
return self._agent
@abstractmethod
def _create_agent(self) -> Agent:
def _create_agent(self) -> "Agent[CtxT, str]":
"""
Create and configure the PydanticAI agent.
@@ -115,7 +132,7 @@ class BaseAgent(ABC):
async def run_stream(
self, prompt: str, **kwargs: Any
) -> AsyncIterator[str]:
) -> AsyncIterator[str | StreamEvent]:
"""
Execute the agent with streaming output.
@@ -4,13 +4,13 @@ Explore Agent - Fast codebase exploration.
from src.domains.agents.explore.agent import (
ExploreAgentImpl,
ExploreContext,
explore_agent,
explore,
explore_agent,
)
__all__ = [
"ExploreAgentImpl",
"ExploreContext",
"explore_agent",
"explore",
"explore_agent",
]
@@ -12,11 +12,11 @@ from typing import Any
from pydantic_ai import Agent
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.ollama.provider import get_ollama_provider
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__)
@@ -31,7 +31,7 @@ class ExploreContext(AgentContext):
pass
class ExploreAgentImpl(BaseAgent):
class ExploreAgentImpl(BaseAgent[ExploreContext]):
"""
Fast codebase exploration agent.
@@ -44,7 +44,7 @@ class ExploreAgentImpl(BaseAgent):
def __init__(self):
"""Initialize the explore agent."""
self._agent: Agent[ExploreContext, str] | None = None
self._agent = None
self._settings = get_settings()
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 src.domains.agents.base import AgentContext
from src.domains.tools.file.read import ReadFileTool
from src.domains.tools.file.glob import GlobFilesTool
from src.domains.agents.explore.agent import ExploreContext
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.search.grep import GrepContentTool
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
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.
@@ -25,7 +25,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool
async def read_file(
ctx: RunContext[AgentContext],
ctx: RunContext[ExploreContext],
file_path: str,
offset: int = 0,
limit: int = 2000
@@ -52,7 +52,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool
async def glob_files(
ctx: RunContext[AgentContext],
ctx: RunContext[ExploreContext],
pattern: str,
path: str | None = None,
limit: int = 100
@@ -85,7 +85,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool
async def grep_content(
ctx: RunContext[AgentContext],
ctx: RunContext[ExploreContext],
pattern: str,
path: str | None = None,
file_glob: str | None = None,
@@ -125,7 +125,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool
async def bash_readonly(
ctx: RunContext[AgentContext],
ctx: RunContext[ExploreContext],
command: str,
cwd: str | None = None,
timeout: int = 30
@@ -170,7 +170,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool
async def edit_file(
ctx: RunContext[AgentContext],
ctx: RunContext[ExploreContext],
file_path: str,
old_string: str,
new_string: str,
@@ -204,7 +204,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool
async def write_file(
ctx: RunContext[AgentContext],
ctx: RunContext[ExploreContext],
file_path: str,
content: str
) -> str:
@@ -231,7 +231,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool
async def bash(
ctx: RunContext[AgentContext],
ctx: RunContext[ExploreContext],
command: str,
cwd: str | None = None,
timeout: int = 60
@@ -277,7 +277,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool
async def web_search(
ctx: RunContext[AgentContext],
ctx: RunContext[ExploreContext],
query: str,
num_results: int = 5,
categories: str | None = None
@@ -16,15 +16,15 @@ Usage:
from src.domains.agents.plan.agent import (
PlanAgentImpl,
PlanContext,
plan_agent,
plan,
plan_agent,
plan_stream,
)
__all__ = [
"PlanAgentImpl",
"PlanContext",
"plan_agent",
"plan",
"plan_agent",
"plan_stream",
]
+4 -4
View File
@@ -12,11 +12,11 @@ from typing import Any
from pydantic_ai import Agent
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.ollama.provider import get_ollama_provider
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__)
@@ -32,7 +32,7 @@ class PlanContext(AgentContext):
pass
class PlanAgentImpl(BaseAgent):
class PlanAgentImpl(BaseAgent[PlanContext]):
"""
Software architect agent for implementation planning.
@@ -47,7 +47,7 @@ class PlanAgentImpl(BaseAgent):
def __init__(self):
"""Initialize the plan agent."""
self._agent: Agent[PlanContext, str] | None = None
self._agent = None
self._settings = get_settings()
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 src.domains.agents.base import AgentContext
from src.domains.tools.file.read import ReadFileTool
from src.domains.agents.plan.agent import PlanContext
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.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.
@@ -28,7 +28,7 @@ def register_plan_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool
async def read_file(
ctx: RunContext[AgentContext],
ctx: RunContext[PlanContext],
file_path: str,
offset: int = 0,
limit: int = 2000
@@ -55,7 +55,7 @@ def register_plan_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool
async def glob_files(
ctx: RunContext[AgentContext],
ctx: RunContext[PlanContext],
pattern: str,
path: str | None = None,
limit: int = 100
@@ -88,7 +88,7 @@ def register_plan_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool
async def grep_content(
ctx: RunContext[AgentContext],
ctx: RunContext[PlanContext],
pattern: str,
path: str | None = None,
file_glob: str | None = None,
@@ -128,7 +128,7 @@ def register_plan_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool
async def bash_readonly(
ctx: RunContext[AgentContext],
ctx: RunContext[PlanContext],
command: str,
cwd: str | None = None,
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.
"""
import json
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from src.domains.agents.base import get_agent, list_agents
# Import agents to ensure they're registered
import src.domains.agents.explore # noqa: F401
import src.domains.agents.plan # noqa: F401
import src.domains.agents.explore
import src.domains.agents.plan
import src.domains.agents.task # noqa: F401
from src.domains.agents.base import get_agent, list_agents
from src.domains.agents.schemas import (
AgentRunRequest,
AgentRunResponse,
AgentInfo,
AgentListResponse,
AgentRunRequest,
AgentRunResponse,
PermissionMode,
StreamEvent,
)
from src.shared.logging import get_logger, logged
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):
return mode_value
return PermissionMode(mode_value)
from src.shared.logging import logged, get_logger
logger = get_logger(__name__)
+7 -1
View File
@@ -70,7 +70,13 @@ class ApprovalRuleSet(BaseSchema):
First matching rule determines the 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
@@ -19,15 +19,15 @@ Usage:
from src.domains.agents.task.agent import (
TaskAgentImpl,
TaskContext,
task_agent,
task,
task_agent,
task_stream,
)
__all__ = [
"TaskAgentImpl",
"TaskContext",
"task_agent",
"task",
"task_agent",
"task_stream",
]
+18 -11
View File
@@ -8,6 +8,7 @@ Full orchestrator agent that can:
- Stream structured events instead of raw text
"""
import asyncio
import contextlib
import os
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
@@ -16,12 +17,12 @@ from typing import Any
from pydantic_ai import Agent
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.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.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__)
@@ -61,7 +62,7 @@ def _summarize_result(result: str, max_len: int = 80) -> str:
return result
class TaskAgentImpl(BaseAgent):
class TaskAgentImpl(BaseAgent[TaskContext]):
"""
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:
"""Register tools with the agent based on permission mode."""
from src.domains.agents.task.tools_streaming import (
register_task_tools_streaming,
register_readonly_tools_streaming,
register_task_tools_streaming,
)
if mode == PermissionMode.plan:
@@ -293,8 +294,16 @@ User request: {prompt}"""
message=f"Retrying (attempt {retries + 1})..."
)
# Run agent in background task so we can yield events
async def run_agent() -> str:
# Run agent in background task so we can yield events.
#
# 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:
result = await agent.run(full_prompt, deps=ctx)
return result.output
@@ -314,7 +323,7 @@ User request: {prompt}"""
timeout=0.1
)
yield event
except asyncio.TimeoutError:
except TimeoutError:
# No events, check if agent is done
continue
@@ -346,10 +355,8 @@ User request: {prompt}"""
# Cancel agent if still running
if not agent_task.done():
agent_task.cancel()
try:
with contextlib.suppress(asyncio.CancelledError):
await agent_task
except asyncio.CancelledError:
pass
return
# 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 src.domains.agents.base import AgentContext
from src.domains.tools.file.read import ReadFileTool
from src.domains.tools.file.glob import GlobFilesTool
from src.domains.agents.task.agent import TaskContext
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.search.grep import GrepContentTool
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
def _register_read_file(agent: Agent[AgentContext, str]) -> None:
def _register_read_file(agent: Agent[TaskContext, str]) -> None:
"""Register read_file tool."""
@agent.tool
async def read_file(
ctx: RunContext[AgentContext],
ctx: RunContext[TaskContext],
file_path: str,
offset: int = 0,
limit: int = 2000
@@ -48,11 +48,11 @@ def _register_read_file(agent: Agent[AgentContext, str]) -> None:
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."""
@agent.tool
async def glob_files(
ctx: RunContext[AgentContext],
ctx: RunContext[TaskContext],
pattern: str,
path: str | None = None,
limit: int = 100
@@ -82,11 +82,11 @@ def _register_glob_files(agent: Agent[AgentContext, str]) -> None:
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."""
@agent.tool
async def grep_content(
ctx: RunContext[AgentContext],
ctx: RunContext[TaskContext],
pattern: str,
path: 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()
def _register_bash_readonly(agent: Agent[AgentContext, str]) -> None:
def _register_bash_readonly(agent: Agent[TaskContext, str]) -> None:
"""Register bash_readonly tool."""
@agent.tool
async def bash_readonly(
ctx: RunContext[AgentContext],
ctx: RunContext[TaskContext],
command: str,
cwd: str | None = None,
timeout: int = 30
@@ -156,11 +156,11 @@ def _register_bash_readonly(agent: Agent[AgentContext, str]) -> None:
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."""
@agent.tool
async def spawn_agent(
ctx: RunContext[AgentContext],
ctx: RunContext[TaskContext],
agent_type: str,
prompt: str,
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}"
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.
@@ -227,7 +227,7 @@ def register_readonly_tools(agent: Agent[AgentContext, str]) -> None:
_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.
@@ -247,7 +247,7 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool
async def edit_file(
ctx: RunContext[AgentContext],
ctx: RunContext[TaskContext],
file_path: str,
old_string: str,
new_string: str,
@@ -281,7 +281,7 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool
async def write_file(
ctx: RunContext[AgentContext],
ctx: RunContext[TaskContext],
file_path: str,
content: str
) -> str:
@@ -308,7 +308,7 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool
async def bash(
ctx: RunContext[AgentContext],
ctx: RunContext[TaskContext],
command: str,
cwd: str | None = None,
timeout: int = 60
@@ -350,7 +350,7 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
@agent.tool
async def web_search(
ctx: RunContext[AgentContext],
ctx: RunContext[TaskContext],
query: str,
num_results: int = 5,
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.schemas import StreamEvent, StreamEventType
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.glob import GlobFilesTool
from src.domains.tools.file.read import ReadFileTool
from src.domains.tools.file.write import WriteFileTool
from src.domains.tools.search.grep import GrepContentTool
from src.domains.tools.search.web import WebSearchTool
@@ -11,6 +11,6 @@ from src.domains.conversations.service import ConversationService
__all__ = [
"Conversation",
"Message",
"ConversationService",
"Message",
]
@@ -20,7 +20,7 @@ from src.domains.conversations.schemas import (
)
from src.domains.conversations.service import ConversationService
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__)
@@ -170,7 +170,7 @@ async def add_message(
logger.exception(f"Agent response failed: {e}")
raise HTTPException(
status_code=500,
detail=f"Agent error: {str(e)}"
detail=f"Agent error: {e!s}"
)
# Add assistant message
@@ -6,7 +6,6 @@ from uuid import UUID
from pydantic import BaseModel, Field
# === Request Schemas ===
class CreateConversationRequest(BaseModel):
@@ -5,7 +5,7 @@ Handles CRUD operations, context building, and summarization triggers.
"""
from uuid import UUID
from sqlalchemy import select, func
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
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 src.domains.health.router import router as health_router
from src.domains.agents.router import router as agents_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.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.
"""
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.shell import BashReadOnlyTool, BashTool
__all__ = [
"BaseTool",
"ToolResult",
"ReadFileTool",
"GlobFilesTool",
"EditFileTool",
"WriteFileTool",
"GrepContentTool",
"WebSearchTool",
"BashReadOnlyTool",
"BashTool",
"EditFileTool",
"GlobFilesTool",
"GrepContentTool",
"ReadFileTool",
"ToolResult",
"WebSearchTool",
"WriteFileTool",
]
+16 -4
View File
@@ -32,10 +32,7 @@ class ToolResult:
if not self.success:
return f"ERROR: {self.error}"
if isinstance(self.data, str):
content = self.data
else:
content = str(self.data)
content = self.data if isinstance(self.data, str) else str(self.data)
if len(content) > max_length:
self.truncated = True
@@ -90,6 +87,21 @@ class BaseTool(ABC):
"""
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:
ToolResult with success status and data or error
"""
@@ -1,9 +1,9 @@
"""
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.glob import GlobFilesTool
from src.domains.tools.file.read import ReadFileTool
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.
"""
import difflib
import aiofiles
from pathlib import Path
import aiofiles
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__)
@@ -95,7 +96,7 @@ Examples:
return "".join(diff)
@logged()
async def execute(
async def execute( # type: ignore[override] # see BaseTool.execute
self,
file_path: str,
old_string: str,
@@ -147,15 +148,15 @@ Examples:
try:
# 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()
# Check if old_string exists
count = content.count(old_string)
if count == 0:
return self._error(
f"old_string not found in file. "
f"Make sure to match exact whitespace and indentation."
"old_string not found in file. "
"Make sure to match exact whitespace and indentation."
)
# 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.gitignore import filter_gitignored
from src.shared.logging import logged, get_logger
from src.shared.logging import get_logger, logged
logger = get_logger(__name__)
@@ -63,7 +63,7 @@ IMPORTANT:
self.honor_gitignore = honor_gitignore
@logged()
async def execute(
async def execute( # type: ignore[override] # see BaseTool.execute
self,
pattern: str,
path: str | None = None,
+5 -4
View File
@@ -1,11 +1,12 @@
"""
File reading tool with line number formatting and sandboxing.
"""
import aiofiles
from pathlib import Path
import aiofiles
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__)
@@ -55,7 +56,7 @@ IMPORTANT:
self.max_line_length = max_line_length
@logged()
async def execute(
async def execute( # type: ignore[override] # see BaseTool.execute
self,
file_path: str,
offset: int = 0,
@@ -87,7 +88,7 @@ IMPORTANT:
return self._error(f"Not a file: {file_path}")
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()
lines = content.splitlines()
+4 -3
View File
@@ -1,11 +1,12 @@
"""
File writing tool for creating and overwriting files.
"""
import aiofiles
from pathlib import Path
import aiofiles
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__)
@@ -57,7 +58,7 @@ Examples:
self.max_content_size = max_content_size
@logged()
async def execute(
async def execute( # type: ignore[override] # see BaseTool.execute
self,
file_path: str,
content: str
+1 -4
View File
@@ -90,10 +90,7 @@ class GitignoreFilter:
# Make path relative to root for matching
try:
if path.is_absolute():
rel_path = path.resolve().relative_to(self.root_dir)
else:
rel_path = path
rel_path = path.resolve().relative_to(self.root_dir) if path.is_absolute() else path
except ValueError:
# Path is not under root_dir, don't filter
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.gitignore import filter_gitignored
from src.shared.logging import logged, get_logger
from src.shared.logging import get_logger, logged
logger = get_logger(__name__)
@@ -70,7 +70,7 @@ IMPORTANT:
self.honor_gitignore = honor_gitignore
@logged()
async def execute(
async def execute( # type: ignore[override] # see BaseTool.execute
self,
pattern: str,
path: str | None = None,
+7 -3
View File
@@ -3,12 +3,13 @@ Web search tool using SearXNG.
"""
from dataclasses import dataclass
from datetime import datetime
from typing import Any
import httpx
from src.domains.tools.base import BaseTool, ToolResult
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
logger = get_logger(__name__)
@@ -91,10 +92,13 @@ IMPORTANT:
params=params,
)
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()
async def execute(
async def execute( # type: ignore[override] # see BaseTool.execute
self,
query: str,
num_results: int = 5,
+3 -3
View File
@@ -8,7 +8,7 @@ import shlex
from pathlib import Path
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__)
@@ -115,7 +115,7 @@ Examples:
self.max_output_size = max_output_size
@logged()
async def execute(
async def execute( # type: ignore[override] # see BaseTool.execute
self,
command: str,
cwd: str | None = None,
@@ -190,7 +190,7 @@ Examples:
exit_code=proc.returncode
)
except asyncio.TimeoutError:
except TimeoutError:
return self._error(f"Command timed out after {timeout} seconds")
except Exception as e:
logger.exception(f"Error executing command: {command}")
@@ -8,7 +8,7 @@ import shlex
from pathlib import Path
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__)
@@ -153,7 +153,7 @@ Examples:
self.max_output_size = max_output_size
@logged()
async def execute(
async def execute( # type: ignore[override] # see BaseTool.execute
self,
command: str,
cwd: str | None = None,
@@ -228,7 +228,7 @@ Examples:
exit_code=proc.returncode
)
except asyncio.TimeoutError:
except TimeoutError:
return self._error(f"Command timed out after {timeout} seconds")
except Exception as e:
logger.exception(f"Error executing command: {command}")
@@ -340,7 +340,7 @@ Examples:
# Handle git with flags before subcommand (e.g., git -C path status)
if git_subcommand.startswith("-"):
# Find the actual subcommand
for i, token in enumerate(tokens[2:], 2):
for _i, token in enumerate(tokens[2:], 2):
if not token.startswith("-"):
git_subcommand = token
break
+44 -5
View File
@@ -54,17 +54,54 @@ class _SanitizedAsyncOpenAI(AsyncOpenAI):
super().__init__(api_key="ollama", **kwargs)
@property
def chat(self) -> "_SanitizedChat":
"""Return sanitized chat interface."""
def chat(self) -> "_SanitizedChat": # type: ignore[override]
"""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)
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:
"""Chat interface wrapper with sanitized completions."""
def __init__(self, client: _SanitizedAsyncOpenAI):
self._client = client
self._original_chat = AsyncOpenAI.chat.fget(client) # type: ignore
self._original_chat = _parent_chat(client)
@property
def completions(self) -> "_SanitizedCompletions":
@@ -110,8 +147,10 @@ def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
msg_copy = dict(msg)
# Fix null content in assistant messages with tool calls
if msg_copy.get("role") == "assistant":
if msg_copy.get("content") is None and msg_copy.get("tool_calls"):
if (
msg_copy.get("role") == "assistant"
and msg_copy.get("content") is None and msg_copy.get("tool_calls")
):
msg_copy["content"] = ""
logger.debug(
f"Sanitized null content, tool_calls={len(msg_copy['tool_calls'])}"
+20 -6
View File
@@ -16,6 +16,7 @@ from collections.abc import Callable
from contextvars import ContextVar, Token
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, ParamSpec, TypeVar, cast
from uuid import uuid4
# === Trace Context ===
@@ -80,12 +81,21 @@ def get_logger(name: str) -> logging.Logger:
# === 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(
logger: logging.Logger | None = None,
slow_threshold_ms: float = 100.0,
warn_threshold_ms: float = 500.0,
include_args: bool = False,
):
) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""
Decorator for automatic function logging with temporal benchmarking.
@@ -102,7 +112,7 @@ def logged(
@logged(slow_threshold_ms=50, warn_threshold_ms=200)
def critical_path(): ...
"""
def decorator(func: Callable):
def decorator(func: Callable[P, R]) -> Callable[P, R]:
nonlocal logger
if logger is None:
logger = logging.getLogger(func.__module__)
@@ -140,7 +150,7 @@ def logged(
logger.debug(f"[{tid}] {func_name} completed in {duration:.2f}ms")
@functools.wraps(func)
async def async_wrapper(*args, **kwargs):
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
span = _create_span()
token = _current_span.set(span)
@@ -150,7 +160,7 @@ def logged(
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}")
try:
result = await func(*args, **kwargs)
result = await cast(Any, func(*args, **kwargs))
_log_completion(span)
return result
except Exception as e:
@@ -160,7 +170,7 @@ def logged(
_current_span.reset(token)
@functools.wraps(func)
def sync_wrapper(*args, **kwargs):
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
span = _create_span()
token = _current_span.set(span)
@@ -179,7 +189,11 @@ def logged(
finally:
_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
+8 -3
View File
@@ -79,7 +79,7 @@ def calculate_backoff(
Delay in seconds
"""
# 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:
# Add up to 25% random jitter
@@ -114,6 +114,11 @@ def with_retry(
return response.json()
"""
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]]:
@wraps(func)
@@ -124,7 +129,7 @@ def with_retry(
try:
return await func(*args, **kwargs)
except (*RETRYABLE_EXCEPTIONS, *extra_exceptions) as e:
except retry_on as e:
last_exception = e
should_retry = True
@@ -145,7 +150,7 @@ def with_retry(
await asyncio.sleep(delay)
elif not should_retry:
# Non-retryable HTTP error
raise last_exception # type: ignore
raise last_exception
# All retries exhausted
logger.error(
-1
View File
@@ -21,7 +21,6 @@ from httpx import ASGITransport, AsyncClient
from src.main import app
# =============================================================================
# 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
without over-mocking (only LLM calls are mocked).
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from src.domains.agents.schemas import PermissionMode
@@ -312,9 +313,10 @@ class TestAgentMethodSignatures:
def test_task_agent_run_accepts_mode(self):
"""Verify TaskAgentImpl.run() accepts mode parameter."""
from src.domains.agents.task.agent import TaskAgentImpl
import inspect
from src.domains.agents.task.agent import TaskAgentImpl
sig = inspect.signature(TaskAgentImpl.run)
params = list(sig.parameters.keys())
@@ -325,9 +327,10 @@ class TestAgentMethodSignatures:
def test_task_agent_run_stream_accepts_mode(self):
"""Verify TaskAgentImpl.run_stream() accepts mode parameter."""
from src.domains.agents.task.agent import TaskAgentImpl
import inspect
from src.domains.agents.task.agent import TaskAgentImpl
sig = inspect.signature(TaskAgentImpl.run_stream)
params = list(sig.parameters.keys())
@@ -337,9 +340,10 @@ class TestAgentMethodSignatures:
def test_trace_span_signature(self):
"""Verify trace_span only accepts expected parameters."""
from src.shared.logging import trace_span
import inspect
from src.shared.logging import trace_span
sig = inspect.signature(trace_span.__init__)
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.
"""
import pytest
from uuid import uuid4
import pytest
from src.domains.conversations.models import Conversation, Message
from src.domains.conversations.schemas import (
CreateConversationRequest,
AddMessageRequest,
ConversationResponse,
MessageResponse,
CreateConversationRequest,
)
@@ -173,9 +172,10 @@ class TestConversationService:
@pytest.mark.anyio
async def test_context_prompt_no_history(self):
"""Test building context prompt with no history."""
from src.domains.conversations.service import ConversationService
from unittest.mock import MagicMock
from src.domains.conversations.service import ConversationService
# Create mock session
mock_session = MagicMock()
service = ConversationService(mock_session)
@@ -190,10 +190,11 @@ class TestConversationService:
@pytest.mark.anyio
async def test_context_prompt_with_history(self):
"""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 src.domains.conversations.models import Message
from src.domains.conversations.service import ConversationService
mock_session = MagicMock()
service = ConversationService(mock_session)
@@ -221,10 +222,11 @@ class TestConversationService:
@pytest.mark.anyio
async def test_context_prompt_with_summary(self):
"""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 src.domains.conversations.models import Message
from src.domains.conversations.service import ConversationService
mock_session = MagicMock()
service = ConversationService(mock_session)
@@ -253,8 +255,8 @@ class TestSummarization:
def test_format_messages_for_summary(self):
"""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.summarize import format_messages_for_summary
messages = [
Message(
@@ -276,8 +278,8 @@ class TestSummarization:
def test_format_messages_with_summary(self):
"""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.summarize import format_messages_for_summary
messages = [
Message(
+1 -1
View File
@@ -6,8 +6,8 @@ from pathlib import Path
import pytest
from src.domains.tools.gitignore import GitignoreFilter, filter_gitignored
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
+55
View File
@@ -0,0 +1,55 @@
"""The sanitising Ollama client must actually be reachable.
src/ollama/provider.py exists to work around Ollama rejecting assistant
messages that carry `content: null` alongside `tool_calls`. On 2026-08-11 it
raised AttributeError the moment anything touched `.chat`: it fetched the
parent's getter via `AsyncOpenAI.chat.fget`, and openai had made `chat` a
functools.cached_property, whose getter is `.func`.
Nothing caught it. The line carried a bare `# type: ignore`, so mypy stayed
quiet, and the endpoints that reach this code had served no requests in 30 days,
so no user hit it either. The mitigation was dead and everything looked fine.
These tests exercise the path rather than the types, because the failure was a
runtime attribute lookup that no annotation would have caught.
"""
from openai import AsyncOpenAI
from src.ollama.provider import _parent_chat, _SanitizedAsyncOpenAI, get_ollama_provider
class TestSanitizedClientIsReachable:
def test_chat_can_be_accessed(self):
"""The regression: this raised AttributeError, not a type error."""
client = _SanitizedAsyncOpenAI(base_url="http://localhost:11434/v1")
chat = client.chat
assert chat is not None
assert chat.completions is not None
def test_provider_reaches_completions(self):
"""The full chain an agent request walks, short of the network call."""
provider = get_ollama_provider()
assert provider._openai_client.chat.completions is not None
def test_parent_lookup_survives_either_descriptor_shape(self):
"""openai has used both property and cached_property for `chat`.
Whichever it is, the parent's own getter must be found — the previous
code hardcoded `.fget` and broke on the switch to cached_property.
"""
descriptor = AsyncOpenAI.__dict__["chat"]
assert hasattr(descriptor, "func") or hasattr(descriptor, "fget"), (
"AsyncOpenAI.chat exposes neither getter; _parent_chat needs updating"
)
client = _SanitizedAsyncOpenAI(base_url="http://localhost:11434/v1")
assert _parent_chat(client) is not None
def test_parent_chat_is_not_the_override(self):
"""It must return openai's Chat, not recurse into the subclass property.
Returning the subclass's own `chat` would be infinite recursion, and the
sanitiser would wrap itself instead of the real completions resource.
"""
client = _SanitizedAsyncOpenAI(base_url="http://localhost:11434/v1")
assert type(_parent_chat(client)).__name__ != "_SanitizedChat"
+1 -1
View File
@@ -6,7 +6,7 @@ Tests registration, API endpoints, and tool restrictions.
import pytest
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:
+4 -5
View File
@@ -1,17 +1,16 @@
"""
Tests for retry utilities.
"""
import pytest
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from src.shared.retry import (
with_retry,
retry_async,
calculate_backoff,
is_retryable_exception,
is_retryable_http_status,
calculate_backoff,
retry_async,
with_retry,
)
+3 -3
View File
@@ -6,10 +6,10 @@ from pathlib import Path
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.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
@@ -275,5 +275,5 @@ class TestResourceLimits:
assert result.success
# 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
+16 -27
View File
@@ -3,11 +3,11 @@ Tests for the Task agent.
Tests registration, API endpoints, tool access, and spawn_agent functionality.
"""
import pytest
from unittest.mock import AsyncMock, patch
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:
@@ -90,39 +90,28 @@ class TestTaskAgentTools:
class TestSpawnAgentTool:
"""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
async def test_spawn_explore_agent(self):
"""Test spawning an 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
"""Spawning an explore agent should delegate to the explore agent.
# Create a mock context
ctx = MagicMock(spec=RunContext)
ctx.deps = AgentContext(
working_dir="/tmp",
allowed_paths=["/tmp"],
timeout_seconds=30
)
The scaffolding that used to sit here — a MagicMock RunContext, an
AgentContext with a /tmp working dir, and a patch of
src.domains.agents.base.get_agent — ran and then stopped at the comment
"For now, verify the explore agent would be called correctly". There was
no assertion, so it passed unconditionally.
# Mock the explore agent
with patch("src.domains.agents.base.get_agent") as mock_get_agent:
mock_explore = AsyncMock()
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
Removed rather than tidied: ruff flagged its imports as unused, and
deleting those would have made the test look clean while leaving it
hollow. git history has the setup for whoever finishes this.
"""
@pytest.mark.anyio
async def test_spawn_unknown_agent_returns_error(self):
"""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
# the agent type validation logic
+1 -2
View File
@@ -1,9 +1,8 @@
"""
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:
+1 -1
View File
@@ -6,8 +6,8 @@ from pathlib import Path
import pytest
from src.domains.tools.file.read import ReadFileTool
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.shell.bash import BashReadOnlyTool
+2 -1
View File
@@ -1,8 +1,9 @@
"""
Tests for WebSearchTool.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from src.domains.tools.search.web import WebSearchTool