Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65feb8b87a | ||
|
|
1769ec2803 | ||
|
|
9d34b94bfb | ||
|
|
648b848747 | ||
|
|
7172927a79 | ||
|
|
3e495daa73 | ||
|
|
eb3467d06a | ||
|
|
9f5e331d11 | ||
|
|
225dc74385 | ||
|
|
80d5cc1c3e | ||
|
|
964c071d3f | ||
|
|
0883101b17 | ||
|
|
992ff8256e | ||
|
|
b4d5c4d9b2 | ||
|
|
8eec3b68d6 | ||
|
|
401c7f4e8c | ||
|
|
9612c7af05 | ||
|
|
9d58df85f3 | ||
|
|
3de96fe070 | ||
|
|
e2acb7de39 | ||
|
|
5998571890 | ||
|
|
6ac1d15591 | ||
|
|
22af23827d | ||
|
|
d385f47395 | ||
|
|
6ea520519c | ||
|
|
2f11ad79cf | ||
|
|
9be877e9a0 | ||
|
|
b87e61248e |
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"env": {
|
||||
"PQL_VAULT": "/mnt/media/Projects/webber"
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(pql)",
|
||||
"Bash(pql *)",
|
||||
"Bash(/home/jpmschweitzer/.local/bin/pql:*)",
|
||||
"Bash(git status:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git branch:*)",
|
||||
"Bash(.venv/bin/python -m pytest:*)",
|
||||
"Bash(.venv/bin/pytest:*)",
|
||||
"Bash(pytest:*)",
|
||||
"Bash(ruff *)",
|
||||
"Bash(mypy *)",
|
||||
"Bash(docker logs webber:*)",
|
||||
"Bash(curl -s http://localhost:8086/*)",
|
||||
"Bash(curl -s http://localhost:8095/*)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(/mnt/media/Projects/cladmin/ops/bin/toj)",
|
||||
"Bash(/mnt/media/Projects/cladmin/ops/bin/toj:*)",
|
||||
"Bash(chmod -R 777 *)",
|
||||
"Bash(chmod 777 *)",
|
||||
"Bash(dd if=*)",
|
||||
"Bash(find * -delete*)",
|
||||
"Bash(find * -exec*)",
|
||||
"Bash(git * add --all*)",
|
||||
"Bash(git * add -A*)",
|
||||
"Bash(git * add .)",
|
||||
"Bash(git * branch -D *)",
|
||||
"Bash(git * checkout -- *)",
|
||||
"Bash(git * clean -fd*)",
|
||||
"Bash(git * clean -fdx*)",
|
||||
"Bash(git * commit --no-verify*)",
|
||||
"Bash(git * merge --no-ff*)",
|
||||
"Bash(git * push --force*)",
|
||||
"Bash(git * push -f*)",
|
||||
"Bash(git * reset --hard*)",
|
||||
"Bash(git * restore .*)",
|
||||
"Bash(git add --all*)",
|
||||
"Bash(git add -A*)",
|
||||
"Bash(git add .)",
|
||||
"Bash(git branch -D *)",
|
||||
"Bash(git checkout -- *)",
|
||||
"Bash(git clean -fd*)",
|
||||
"Bash(git clean -fdx*)",
|
||||
"Bash(git commit --no-verify*)",
|
||||
"Bash(git merge --no-ff*)",
|
||||
"Bash(git push --force*)",
|
||||
"Bash(git push -f*)",
|
||||
"Bash(git reset --hard*)",
|
||||
"Bash(git restore .*)",
|
||||
"Bash(mkfs*)",
|
||||
"Bash(rm -rf $HOME)",
|
||||
"Bash(rm -rf /)",
|
||||
"Bash(rm -rf ~)",
|
||||
"Bash(su *)",
|
||||
"Bash(sudo *)",
|
||||
"Bash(toj)",
|
||||
"Bash(toj:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
.pql/changelog/*.sql merge=union
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- 'api/v*'
|
||||
|
||||
env:
|
||||
IMAGE_NAME: git.schweitz.internal/jpmschweitzer/webber-api
|
||||
IMAGE_NAME: git.schweitz.net/jpmschweitzer/webber-api
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.schweitz.internal
|
||||
registry: git.schweitz.net
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Trigger only. The checks live in the Makefile, where they can be read, run by
|
||||
# hand (`make pre-push`), and changed under review.
|
||||
#
|
||||
# This file is identical in every repo in this workspace, deliberately: the call
|
||||
# surface is the same everywhere even though what each gate runs is not, so
|
||||
# nobody has to read a repo to find out how to check it (D-27).
|
||||
#
|
||||
# Enable per clone with: git config core.hooksPath .githooks
|
||||
# Never bypass with --no-verify. Suppress a specific finding deliberately
|
||||
# instead, with a reason — see `make pre-push`.
|
||||
set -euo pipefail
|
||||
exec make -C "$(git rev-parse --show-toplevel)" pre-push
|
||||
+13
@@ -75,3 +75,16 @@ webber-sandbox/.current_template
|
||||
|
||||
# Ruff cache
|
||||
.ruff_cache/
|
||||
|
||||
# Claude Code user-specific settings
|
||||
.claude/settings.local.json
|
||||
.pql/*
|
||||
!.pql/changelog/
|
||||
|
||||
# pql shims planted by `pql init` into the dir core.hooksPath points at.
|
||||
# Per-clone: each embeds the absolute path of the pql binary that planted it.
|
||||
# Only .githooks/pre-push is shared.
|
||||
.githooks/pre-commit
|
||||
.githooks/post-merge
|
||||
.githooks/post-checkout
|
||||
.githooks/post-rewrite
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Changelog format marker, written by pql. Comments only: this file
|
||||
-- is never executed — Import descends into the per-table directories
|
||||
-- and does not read the changelog root.
|
||||
--
|
||||
-- A changelog carrying no marker is format 1, the shape that existed
|
||||
-- before formats were versioned. An older format is migrated forward
|
||||
-- by `pql plan upgrade` (and automatically from the post-merge hook);
|
||||
-- a newer one is refused rather than replayed under rules this binary
|
||||
-- does not know. See D-28 and docs/versions.md.
|
||||
-- pql:changelog_format: 2.0.0
|
||||
-- pql:written_by: 2.2.0
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -1,279 +0,0 @@
|
||||
# Webber Monorepo - Agent Instructions
|
||||
|
||||
> **Start every session by reading this file.**
|
||||
> This file contains everything you need to work with this codebase efficiently.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Action | Command |
|
||||
|--------|---------|
|
||||
| Start API server | `cd webber-api && ./wakeup.sh` |
|
||||
| View API logs | `tail -f webber-api/logs/server.log` |
|
||||
| Run API tests | `cd webber-api && .venv/bin/python -m pytest tests/ -v` |
|
||||
| Check CLI status | `cd webber-cli && .venv/bin/webber-cli status` |
|
||||
| Load sandbox | `./sandbox.sh load calculator-cli` |
|
||||
| Explore sandbox | `cd webber-cli && .venv/bin/webber-cli explore "query" -d ../webber-sandbox` |
|
||||
|
||||
---
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
webber/
|
||||
├── webber-api/ # FastAPI backend server
|
||||
│ ├── src/ # API source code
|
||||
│ ├── tests/ # API tests (pytest)
|
||||
│ ├── docs/ # Architecture docs, COVERAGE.md
|
||||
│ ├── logs/ # Runtime logs (server.log)
|
||||
│ ├── .venv/ # API virtual environment
|
||||
│ ├── wakeup.sh # Dev server startup script
|
||||
│ └── AGENTS.md # API-specific development guide
|
||||
│
|
||||
├── webber-cli/ # CLI client
|
||||
│ ├── webber_cli/ # Python package (underscore!)
|
||||
│ ├── .venv/ # CLI virtual environment
|
||||
│ └── README.md # CLI usage guide
|
||||
│
|
||||
├── webber-sandbox/ # Active test project (contents swappable)
|
||||
│ ├── src/ # Current project source
|
||||
│ ├── tests/ # Current project tests
|
||||
│ ├── .venv/ # Sandbox virtual environment
|
||||
│ └── TASKS.md # Tasks for Webber to complete
|
||||
│
|
||||
├── sandbox-templates/ # Template storage
|
||||
│ ├── calculator-cli/ # Simple CLI with intentional bugs
|
||||
│ └── empty/ # Blank starter project
|
||||
│
|
||||
├── sandbox.sh # Sandbox management script
|
||||
└── AGENTS.md # THIS FILE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### 1. Start the API Server
|
||||
|
||||
```bash
|
||||
cd webber-api
|
||||
./wakeup.sh
|
||||
```
|
||||
|
||||
- **Port:** 8095 (dev), 8086 (production Docker)
|
||||
- **Logs:** `webber-api/logs/server.log`
|
||||
- **Health check:** `curl http://localhost:8095/health`
|
||||
- **API docs:** http://localhost:8095/docs
|
||||
|
||||
To stop: `Ctrl+C` or `pkill -f "uvicorn src.main:app"`
|
||||
|
||||
### 2. Run Tests
|
||||
|
||||
```bash
|
||||
# API tests (39 tests)
|
||||
cd webber-api
|
||||
.venv/bin/python -m pytest tests/ -v
|
||||
|
||||
# With coverage
|
||||
.venv/bin/python -m pytest tests/ --cov=src
|
||||
|
||||
# Single test file
|
||||
.venv/bin/python -m pytest tests/test_tools.py -v
|
||||
```
|
||||
|
||||
### 3. Use the CLI
|
||||
|
||||
```bash
|
||||
cd webber-cli
|
||||
|
||||
# Check API connection
|
||||
.venv/bin/webber-cli status
|
||||
|
||||
# Interactive chat (default mode - full capabilities)
|
||||
.venv/bin/webber-cli chat -d ../webber-sandbox
|
||||
|
||||
# Read-only mode (safe exploration and planning)
|
||||
.venv/bin/webber-cli chat --mode plan -d ../webber-sandbox
|
||||
|
||||
# Auto-accept mode (no approval prompts - use with caution)
|
||||
.venv/bin/webber-cli chat --mode auto_accept -d ../webber-sandbox
|
||||
|
||||
# List previous sessions
|
||||
.venv/bin/webber-cli sessions
|
||||
|
||||
# Resume a previous session
|
||||
.venv/bin/webber-cli chat --resume <session-id>
|
||||
```
|
||||
|
||||
**CLI Features:**
|
||||
- **Tab completion** for commands and file paths
|
||||
- **Command history** persisted to `~/.webber_history`
|
||||
- **Session persistence** - conversations saved and resumable
|
||||
- **Runtime mode switching** via `mode plan|default|auto_accept`
|
||||
- **Directory navigation** via `cd <path>`
|
||||
|
||||
**Note:** The API server must be running for CLI commands to work.
|
||||
|
||||
---
|
||||
|
||||
## Sandbox Management
|
||||
|
||||
The sandbox is a swappable test project for functional testing.
|
||||
|
||||
### Available Templates
|
||||
|
||||
| Template | Description |
|
||||
|----------|-------------|
|
||||
| `calculator-cli` | Python CLI with intentional bugs (div-by-zero, missing tests) |
|
||||
| `empty` | Blank starter project |
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# List available templates
|
||||
./sandbox.sh list
|
||||
|
||||
# Load a template (clears sandbox, preserves .venv)
|
||||
./sandbox.sh load calculator-cli
|
||||
|
||||
# Reset to last loaded template
|
||||
./sandbox.sh reset
|
||||
|
||||
# Save current sandbox as new template
|
||||
./sandbox.sh save my-template
|
||||
|
||||
# Check current status
|
||||
./sandbox.sh status
|
||||
```
|
||||
|
||||
### After Loading a Template
|
||||
|
||||
```bash
|
||||
cd webber-sandbox
|
||||
source .venv/bin/activate # Create .venv first if missing
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Read the tasks
|
||||
cat TASKS.md
|
||||
|
||||
# Run the project's tests
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Webber's Capabilities
|
||||
|
||||
### Scenario: Find bugs in calculator-cli
|
||||
|
||||
```bash
|
||||
# 1. Load the template
|
||||
./sandbox.sh load calculator-cli
|
||||
|
||||
# 2. Have Webber explore it (plan mode = read-only)
|
||||
cd webber-cli
|
||||
.venv/bin/webber-cli chat --mode plan -d ../webber-sandbox
|
||||
# Then ask: "find all bugs in the code"
|
||||
|
||||
# 3. Check TASKS.md for expected bugs
|
||||
cat ../webber-sandbox/TASKS.md
|
||||
```
|
||||
|
||||
### Known bugs in calculator-cli:
|
||||
- Division by zero not handled (`operations.py:divide`)
|
||||
- Invalid operation causes KeyError (`main.py:get_operation`)
|
||||
- Power function broken for fractional exponents
|
||||
- Missing tests for divide and power functions
|
||||
|
||||
---
|
||||
|
||||
## Key Files for Debugging
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `webber-api/logs/server.log` | API server logs |
|
||||
| `webber-api/src/domains/agents/explore/prompts.py` | Explore agent system prompts |
|
||||
| `webber-api/src/domains/agents/explore/agent.py` | Explore agent implementation |
|
||||
| `webber-api/src/ollama/provider.py` | Ollama integration (sanitizes content:null) |
|
||||
| `webber-api/docs/COVERAGE.md` | Feature coverage and known issues |
|
||||
|
||||
---
|
||||
|
||||
## Versioning & Releases
|
||||
|
||||
Uses prefixed tags:
|
||||
- `api/vX.Y.Z` → Triggers API Docker build
|
||||
- `cli/vX.Y.Z` → Triggers CLI build (future)
|
||||
|
||||
### MANDATORY Release Procedure
|
||||
|
||||
**NEVER push a tag before updating version files.** Follow this exact order:
|
||||
|
||||
```bash
|
||||
# For API releases:
|
||||
# 1. Update version in webber-api/pyproject.toml
|
||||
# 2. Update webber-api/CHANGELOG.md with release notes
|
||||
# 3. Commit the version bump
|
||||
git add -A && git commit -m "chore: release api vX.Y.Z"
|
||||
# 4. Create the tag (AFTER the commit)
|
||||
git tag api/vX.Y.Z
|
||||
# 5. Push everything together
|
||||
git push origin main --tags
|
||||
|
||||
# For CLI releases:
|
||||
# 1. Update version in webber-cli/pyproject.toml
|
||||
# 2. Update webber-cli/CHANGELOG.md with release notes
|
||||
# 3. Commit the version bump
|
||||
git add -A && git commit -m "chore: release cli vX.Y.Z"
|
||||
# 4. Create the tag (AFTER the commit)
|
||||
git tag cli/vX.Y.Z
|
||||
# 5. Push everything together
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
**Why this matters:** Pushing a tag before the version commit requires deleting and recreating the tag, which can trigger CI/CD pipelines prematurely and cause deployment issues.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### API server won't start
|
||||
```bash
|
||||
# Check if port is in use
|
||||
lsof -i :8095
|
||||
|
||||
# Kill stuck process
|
||||
pkill -f "uvicorn src.main:app"
|
||||
```
|
||||
|
||||
### CLI can't connect
|
||||
```bash
|
||||
# Check API is running
|
||||
curl http://localhost:8095/health
|
||||
|
||||
# Check CLI config
|
||||
echo $WEBBER_API_URL # Should be http://localhost:8095
|
||||
```
|
||||
|
||||
### Ollama errors
|
||||
```bash
|
||||
# Check Ollama is running
|
||||
curl http://192.168.86.149:11434/api/tags
|
||||
|
||||
# Check model is available
|
||||
curl http://192.168.86.149:11434/api/tags | grep mistral-nemo
|
||||
```
|
||||
|
||||
### Tests failing
|
||||
```bash
|
||||
# Run with verbose output
|
||||
cd webber-api
|
||||
.venv/bin/python -m pytest tests/ -v --tb=short
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Model hallucination** - Mistral Nemo sometimes makes up file contents instead of using tool results
|
||||
|
||||
See `webber-api/docs/COVERAGE.md` for full feature coverage status.
|
||||
@@ -0,0 +1,189 @@
|
||||
# CLAUDE.md — webber
|
||||
|
||||
Local-LLM multi-agent development assistant — "similar to Claude Code but running locally"
|
||||
(`webber-api/docs/architecture.md`), backed by Ollama via PydanticAI. Logical monorepo, single
|
||||
`.git`, three subprojects: `webber-api/` (FastAPI server, deployed), `webber-cli/` (Typer CLI
|
||||
client), `webber-sandbox/` (swappable test project used by `sandbox.sh`, not shipped).
|
||||
|
||||
## Ports
|
||||
|
||||
| | Port | How |
|
||||
|---|---|---|
|
||||
| Local dev | **8095** | `cd webber-api && ./wakeup.sh`, uvicorn `--reload`, logs to `webber-api/logs/server.log` |
|
||||
| Production | **8086** | container `webber`, confirmed running (`docker ps`) on `docker-dataplane` |
|
||||
|
||||
`wakeup.sh` refuses to start if 8095 is already bound — it does not silently pick another
|
||||
port. Testing `localhost:8086` on the dev box hits the *container*, not your reload server.
|
||||
|
||||
## Live contract
|
||||
|
||||
`http://localhost:8086/openapi.json` — 10 paths, `version: 1.0.1` (verified 2026-08-09,
|
||||
matches `webber-api/pyproject.toml` and the live `/health` response). Human docs at
|
||||
`http://localhost:8086/docs`. Query the live spec rather than inferring routes from source —
|
||||
`src/domains/router.py` currently has two routers commented out (see Architecture), so a
|
||||
source read alone will overcount if you don't check whether an include is live.
|
||||
|
||||
```
|
||||
/, /health, /agents/, /agents/run, /agents/stream, /agents/{agent_type},
|
||||
/conversations/, /conversations/{conversation_id},
|
||||
/conversations/{conversation_id}/messages, /conversations/{conversation_id}/save
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Domain-first layout under `webber-api/src/domains/<name>/`. `src/main.py` includes exactly one
|
||||
router, `src.domains.router.root_router`, which composes the domain routers. A full directory
|
||||
map lives in `webber-api/docs/architecture.md` — read that before adding a domain rather than
|
||||
duplicating it here.
|
||||
|
||||
**How liveness below was established:** `docker exec webber python3 -c "import src.main; import
|
||||
sys; print(sorted(m for m in sys.modules if m.startswith('src.')))"` — i.e. importing the real
|
||||
app inside the running container and reading `sys.modules`, not grepping `main.py`. Re-run that
|
||||
command to re-check; a grep of imports will miss function-body imports, and this repo has one
|
||||
that matters.
|
||||
|
||||
- **Wired at startup, serving routes:** `src.domains.health`, `src.domains.agents` (router +
|
||||
`explore`/`plan`/`task` agent packages), `src.domains.conversations`, `src.shared.*`,
|
||||
`src.ollama`, `src.db` (imported both by `conversations/router.py` at module scope and by
|
||||
`main.py`'s lifespan shutdown handler).
|
||||
- **Present in source, explicitly disabled:** `src/domains/router.py` has
|
||||
`# from src.domains.auth.router import router as auth_router` and the equivalent for
|
||||
`tools_router` — both commented out with the include calls also commented out. `src/domains/auth/`
|
||||
is just an empty `__init__.py`. This one *is* dead — the disabling is visible in the same file,
|
||||
not a matter of tracing an indirect import.
|
||||
- **The trap: `src/domains/tools/` is not in `sys.modules` right after `import src.main`, but it
|
||||
is not dead.** `src/domains/agents/{explore,plan,task}/agent.py` each have a method
|
||||
(e.g. `PlanAgent._register_tools`) that does `from src.domains.agents.plan.tools import
|
||||
register_plan_tools` **inside the function body**, called every time that agent is
|
||||
constructed — i.e. on every `/agents/run` or `/agents/stream` request for that agent type.
|
||||
That nested module then imports the real tool classes from `src.domains.tools.file`,
|
||||
`.search`, `.shell` at module scope. A static snapshot taken before any request is served
|
||||
will not show `src.domains.tools` loaded; that is a timing artifact, not evidence it is
|
||||
unused. Don't delete `src/domains/tools/` on the strength of a `sys.modules` check alone —
|
||||
confirm by hitting `/agents/run` and re-checking, or by tracing the call graph from each
|
||||
agent's `_register_tools`.
|
||||
- **`src/cli/`** is the implementation behind `webber-cli`'s `pyproject.toml` script entry —
|
||||
it is a separate Typer app, not imported by the API (`src.main`) at all. Its liveness is
|
||||
"is the CLI installed and invoked", not "is it wired into the API process".
|
||||
|
||||
Group new work by domain, not file type — `webber-api/docs/fastapi-best-practices.md` is the
|
||||
house reference (mirrors the convention used across the other in-house FastAPI services here).
|
||||
|
||||
## Database
|
||||
|
||||
SQLite by default (`database_url = "sqlite+aiosqlite:///./webber.db"` in
|
||||
`src/shared/config.py`), not Postgres — confirmed by reading `src/shared/config.py` and
|
||||
`src/db/database.py` (the latter's docstring says the pattern is ported from core-api, but
|
||||
the backend differs). Models under `webber-api/src/domains/<name>/models.py` import `Base`
|
||||
from `src/db/models.py`. No Alembic here (unlike core-api) — did not find a migrations
|
||||
directory; unverified whether schema changes have any managed migration path at all. Check
|
||||
before assuming one exists.
|
||||
|
||||
## Working here
|
||||
|
||||
**Test locally first.** `cd webber-api && ./wakeup.sh` auto-reloads on code changes (not on
|
||||
`requirements.txt` changes — restart after adding a dependency). Deploy only once a feature
|
||||
is complete and tested.
|
||||
|
||||
```bash
|
||||
cd webber-api
|
||||
.venv/bin/python -m pytest tests/ # all tests
|
||||
.venv/bin/python -m pytest tests/ -v --cov # verbose + coverage
|
||||
.venv/bin/python -m pytest tests/test_tools.py -v # single file
|
||||
```
|
||||
|
||||
`webber-api/pyproject.toml` declares `[tool.ruff]` and `[tool.mypy]` — unlike core-api, this
|
||||
repo does have ruff/mypy config; whether either runs in CI is a separate question (see CI below
|
||||
— it does not).
|
||||
|
||||
Copy `webber-api/.env.example` to `webber-api/.env`. Notable defaults: `OLLAMA_URL` points at
|
||||
`192.168.86.149:11434` (the host's Ollama, not a container), `OLLAMA_AGENT_MODEL=gemma4:e2b`,
|
||||
optional Tatlock integration via `TATLOCK_API_URL`/`INTERNAL_API_KEY`, optional SearXNG via
|
||||
`SEARXNG_URL` for the `web_search` tool.
|
||||
|
||||
### Sandbox
|
||||
|
||||
`webber-sandbox/` is a disposable project used to exercise the agents end-to-end, managed by
|
||||
`./sandbox.sh {list,load,reset,save,status}` from the repo root. `sandbox-templates/` holds the
|
||||
reusable templates (`calculator-cli` has intentionally-seeded bugs for testing Explore/Task).
|
||||
This directory is fixture material, not shipped code — do not treat bugs in it as real bugs.
|
||||
|
||||
### CLI
|
||||
|
||||
`webber-cli/` is a Typer client (`webber-cli status|chat|explore|sessions|config`) with tab
|
||||
completion, session persistence (`~/.webber_history`, `~/.webber/config.toml`), and three chat
|
||||
modes (`plan` read-only, `default`, `auto_accept`). It talks to the API over HTTP — it does not
|
||||
share a process with `webber-api`. Run it from its own venv: `cd webber-cli && .venv/bin/webber-cli status`.
|
||||
|
||||
## CI
|
||||
|
||||
`.gitea/workflows/build-api.yml` triggers only on `api/vX.Y.Z` tags: creates a Gitea release,
|
||||
builds/pushes `git.schweitz.net/jpmschweitzer/webber-api`, then pings Watchtower.
|
||||
`build-cli.yml` triggers on `cli/vX.Y.Z` tags but is a placeholder — it only echoes a TODO, it
|
||||
does not build or publish anything. **No test or lint gate runs in CI for either package** —
|
||||
pytest and ruff only run locally or on request. Verify tests pass before tagging.
|
||||
|
||||
## Work tracking
|
||||
|
||||
Work lives in **pql**, not a markdown TODO or `docs/COVERAGE.md`. **This repo's vault is
|
||||
standalone** — its tickets and its internal decisions live here in `.pql/` and `governance/`,
|
||||
and travel with a clone, because `.pql/changelog/` is committed and replayed by the git hooks
|
||||
(workspace D-15). The databases are gitignored and rebuildable with `pql plan rebuild`.
|
||||
|
||||
`pql` is **not** on the non-interactive `PATH` — invoke it as
|
||||
`/home/jpmschweitzer/.local/bin/pql`. From inside this repo no `--vault` is needed: pql anchors
|
||||
at the nearest `.git/` ancestor, which is this repo.
|
||||
|
||||
```bash
|
||||
/home/jpmschweitzer/.local/bin/pql ticket list # this repo's open work
|
||||
/home/jpmschweitzer/.local/bin/pql plan whatsnext # next unblocked item, with context
|
||||
/home/jpmschweitzer/.local/bin/pql decisions list # this repo's own decisions
|
||||
```
|
||||
|
||||
Stack-level decisions that constrain this service live in the **workspace** vault and need the
|
||||
flag:
|
||||
|
||||
```bash
|
||||
/home/jpmschweitzer/.local/bin/pql --vault /mnt/media/Projects decisions list --domain webber
|
||||
```
|
||||
|
||||
Note `ticket new --decision D-N` resolves ids within **one** vault, so a ticket here cannot link
|
||||
to a workspace decision. Cite the id in the ticket body instead.
|
||||
|
||||
Do not add a TODO section to a markdown file.
|
||||
|
||||
## Git
|
||||
|
||||
- **History is linear — no merge commits.** Work on `main`, or a short-lived branch that is
|
||||
fast-forwarded and deleted. (This repo's `AGENTS.md` previously mandated `feature/...` or
|
||||
`fix/...` branches for every change and forbade committing to `main` directly — that rule was
|
||||
retired workspace-wide on 2026-08-08 and does not apply here anymore.)
|
||||
- **Conventional Commits**: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`.
|
||||
- **Atomic commits** — one logical change each.
|
||||
- **Stage explicitly. Never `git add -A`** — denied by policy; it sweeps in whatever else is
|
||||
dirty, including secrets.
|
||||
- Each package versions independently via prefixed tags (`api/vX.Y.Z`, `cli/vX.Y.Z`) and its
|
||||
own `CHANGELOG.md` (`webber-api/CHANGELOG.md`, `webber-cli/CHANGELOG.md`); the root
|
||||
`CHANGELOG.md` is just an index pointing at both.
|
||||
|
||||
## Releasing (API)
|
||||
|
||||
Ask whether a deploy is wanted first — it is not automatic.
|
||||
|
||||
1. Bump the version in `webber-api/pyproject.toml`.
|
||||
2. Move `[Unreleased]` entries into a dated version section in `webber-api/CHANGELOG.md`.
|
||||
3. Stage the changed files by name, commit, tag `api/vX.Y.Z`, `git push origin main --tags`.
|
||||
4. Gitea CI (`build-api.yml`) builds and pushes the image on the tag; Watchtower deploys it.
|
||||
5. Verify: `curl http://192.168.86.149:8086/health`.
|
||||
|
||||
CLI releases (`cli/vX.Y.Z`) currently only log a TODO in CI — there is no build/publish step
|
||||
to trigger yet.
|
||||
|
||||
## Known issues (carried over, unverified beyond what's stated)
|
||||
|
||||
- **Model hallucination**: the Explore agent's model can hallucinate file contents instead of
|
||||
using actual tool results, per `webber-api/AGENTS.md` — a mitigation (stronger model or
|
||||
response validation) was suggested there but not confirmed implemented.
|
||||
- **Ollama `content: null` workaround**: `src/ollama/provider.py` (confirmed present, loaded at
|
||||
startup per the `sys.modules` check above) sanitizes `content: null` to `content: ""` for
|
||||
assistant messages with tool calls, working around an Ollama API limitation.
|
||||
@@ -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
|
||||
@@ -107,13 +107,13 @@ This project uses prefixed tags for independent release cycles:
|
||||
## Requirements
|
||||
|
||||
- Python 3.12+
|
||||
- Ollama running with `mistral-nemo:latest` model
|
||||
- Ollama running with `gemma4:e2b` model
|
||||
- Docker (for production deployment)
|
||||
- SearXNG (optional, for web search)
|
||||
|
||||
## Documentation
|
||||
|
||||
- `webber-api/AGENTS.md` - API development guidelines
|
||||
- `CLAUDE.md` - Agent development guidelines (repo-wide)
|
||||
- `webber-api/docs/COVERAGE.md` - Feature coverage and roadmap
|
||||
- `webber-api/docs/architecture.md` - System architecture
|
||||
- `webber-cli/README.md` - CLI usage guide
|
||||
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Secret scan over the commits about to be pushed.
|
||||
#
|
||||
# Lives here rather than inside .githooks/pre-push so it can be read, run by
|
||||
# hand (`make secrets`), and changed under review. A hook is a trigger; it is
|
||||
# not a home for logic. Identical in every repo in this workspace (D-27).
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
# A non-login shell — which is what git gives a hook — skips /etc/profile.d
|
||||
# and never sees ~/.local/bin, where the gitleaks release tarball lands.
|
||||
# Without this the scan reports "not installed" on every push.
|
||||
[ -d "$HOME/.local/bin" ] && PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
if ! command -v gitleaks >/dev/null 2>&1; then
|
||||
echo "FAIL secrets — gitleaks not installed, so this check would be a no-op pretending to pass." >&2
|
||||
echo " https://github.com/gitleaks/gitleaks/releases → ~/.local/bin/gitleaks" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Scan the outgoing range, not full history. History here carries findings
|
||||
# that are settled — test fixtures and vendored third-party code — and a gate
|
||||
# that fails on something unfixable gets bypassed within a week. What matters
|
||||
# is what is about to leave this machine.
|
||||
if upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null); then
|
||||
range="$upstream..HEAD"
|
||||
elif git rev-parse --verify --quiet origin/main >/dev/null; then
|
||||
range="origin/main..HEAD"
|
||||
else
|
||||
range=""
|
||||
fi
|
||||
|
||||
if [ -z "$range" ]; then
|
||||
gitleaks dir . --redact --no-banner --exit-code 1 || {
|
||||
echo "FAIL secrets — gitleaks found a credential in the working tree." >&2; exit 1; }
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[ -n "$(git log --oneline "$range" 2>/dev/null)" ] || exit 0
|
||||
|
||||
gitleaks git . --log-opts="$range" --redact --no-banner --exit-code 1 >/dev/null 2>&1 || {
|
||||
echo "FAIL secrets — gitleaks found a credential in the commits being pushed." >&2
|
||||
echo " inspect (values redacted): gitleaks git . --log-opts=\"$range\" --redact" >&2
|
||||
echo " then remove and rotate it, or suppress deliberately:" >&2
|
||||
echo " inline '# gitleaks:allow <reason>'" >&2
|
||||
echo " or add the fingerprint to .gitleaksignore WITH a reason" >&2
|
||||
exit 1
|
||||
}
|
||||
echo " ok secrets"
|
||||
@@ -0,0 +1,54 @@
|
||||
# Decisions, Questions, Rejected
|
||||
|
||||
This directory holds structured planning records that pql parses
|
||||
into pql.db. Each record is a `### [DQR]-N: Title` heading inside
|
||||
a markdown file. Files live in three per-type subdirectories:
|
||||
|
||||
- `decisions/<domain>.md` — confirmed design decisions
|
||||
- `questions/<domain>.md` — open questions that may resolve into
|
||||
decisions or rejected proposals
|
||||
- `rejected/<domain>.md` — rejected proposals (kept for the audit
|
||||
trail)
|
||||
|
||||
The parser infers domain from the filename stem and record type
|
||||
from the parent subdirectory.
|
||||
|
||||
D-records that propose implementation work link to `initiative`-type
|
||||
tickets via `decision_ref`. Run `pql decisions show <id>
|
||||
--with-tickets` to inspect implementation status.
|
||||
|
||||
## Recommended domains
|
||||
|
||||
Start with this canonical set; create files as records land in
|
||||
each domain:
|
||||
|
||||
- **architecture** — structural commitments (storage, layering,
|
||||
languages, libraries)
|
||||
- **process** — team workflow (commits, branches, releases, reviews)
|
||||
- **design** — user-facing surface (UX, UI, public APIs)
|
||||
- **coding-conventions** — team-internal code shape (style, lint,
|
||||
file layout)
|
||||
- **testing** — quality strategy (coverage, layers, gates)
|
||||
|
||||
You might also want, project-permitting:
|
||||
|
||||
- `accessibility` — if you ship user-facing software
|
||||
- `security` — if you handle user data or network surfaces
|
||||
- `licensing` — if you release open-source or commercial
|
||||
- `documentation` — if user-docs are non-trivial
|
||||
- `deployment` — if shipping is non-trivial
|
||||
- `performance` — if you have perf budgets / SLOs
|
||||
|
||||
<!-- pql:records (auto-generated; do not edit manually) -->
|
||||
|
||||
## Decisions
|
||||
|
||||
- _(none)_
|
||||
|
||||
## Open questions
|
||||
|
||||
- _(none)_
|
||||
|
||||
## Rejected
|
||||
|
||||
- _(none)_
|
||||
@@ -14,13 +14,16 @@ CORS_ORIGINS=["http://localhost:3000","http://localhost:8080"]
|
||||
|
||||
# LLM - Ollama (tower-of-joy)
|
||||
OLLAMA_URL=http://192.168.86.149:11434
|
||||
OLLAMA_AGENT_MODEL=mistral-nemo-large:latest
|
||||
OLLAMA_AGENT_MODEL=gemma4:e2b
|
||||
OLLAMA_EMBED_MODEL=nomic-embed-text:latest
|
||||
|
||||
# Auth - Tatlock integration (optional)
|
||||
# TATLOCK_API_URL=http://192.168.86.149:8000
|
||||
# TATLOCK_API_URL=http://tatlock:8000
|
||||
# INTERNAL_API_KEY=your-internal-key
|
||||
|
||||
# Web search - SearXNG (container name on docker-dataplane; internal port 8080)
|
||||
# SEARXNG_URL=http://searxng:8080
|
||||
|
||||
# Tool execution
|
||||
TOOL_TIMEOUT_SECONDS=120
|
||||
SANDBOX_ENABLED=true
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
|
||||
# AGENTS.md
|
||||
|
||||
> **Start every session by reading this file.**
|
||||
> This file outlines the operational protocols, coding standards, and architectural decisions for this FastAPI project.
|
||||
|
||||
## 1. Agent Operational Protocols
|
||||
|
||||
### 🧠 Work Patterns (Plan-Act-Reflect)
|
||||
* **Plan:** Before writing code, briefly outline your plan. Identify which files you will touch and what the side effects might be.
|
||||
* **Act:** Execute the changes in small, atomic steps.
|
||||
* **Reflect:** After coding, verify your work. Did you break existing tests? Did you add new tests?
|
||||
|
||||
### 🛡️ Git Discipline
|
||||
* **ALWAYS add the relevant tests for the added code** Make sure to keep the test coverage up as we go, and run tests before commiting.
|
||||
* **NEVER commit to `main` or `master` directly.** Always create a feature branch: `feature/your-feature-name` or `fix/issue-description`.
|
||||
* **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format.
|
||||
* `feat: add user login endpoint`
|
||||
* `fix: resolve database connection timeout`
|
||||
* `refactor: split monolith dependency file`
|
||||
* **Atomic Commits:** Keep commits small. One logical change = one commit.
|
||||
|
||||
### 📝 Changelog Maintenance
|
||||
* **Update `CHANGELOG.md`** with every user-facing change.
|
||||
* Format: `## [Unreleased] - YYYY-MM-DD` followed by `### Added`, `### Changed`, or `### Fixed`.
|
||||
|
||||
### 🚀 Release Flow
|
||||
When changes are ready for deployment:
|
||||
|
||||
1. **Ask user if deploy cycle is desired **
|
||||
|
||||
2. **Update version** in `pyproject.toml`:
|
||||
- Bug fixes: bump patch version (1.8.3 → 1.8.4)
|
||||
- New features: bump minor version (1.8.4 → 1.9.0)
|
||||
|
||||
3. **Update CHANGELOG.md**:
|
||||
- Move items from `[Unreleased]` to new version section
|
||||
- Add release date: `## [1.8.4] - 2025-12-16`
|
||||
|
||||
4. **Commit and tag**:
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: description of changes"
|
||||
git tag v1.8.4
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
5. **CI/CD triggers automatically**:
|
||||
- Gitea CI builds Docker image on new version tag (starts with "v")
|
||||
- Watchtower pulls and deploys to production
|
||||
- Verify deployment: `curl http://192.168.86.149:8086/health`
|
||||
|
||||
---
|
||||
|
||||
### 🧪 Local Development Setup
|
||||
|
||||
* **Always test locally first** before committing and deploying. The build-deploy loop is slow.
|
||||
* **Only deploy** when a phase or feature is complete and tested locally
|
||||
* **Environment**: Copy `.env.example` to `.env` and configure for your local setup
|
||||
|
||||
#### ⚠️ CRITICAL: Starting the Local Server
|
||||
|
||||
**ALWAYS use `./wakeup.sh` to start the local server. NEVER use raw uvicorn commands.**
|
||||
|
||||
```bash
|
||||
./wakeup.sh
|
||||
```
|
||||
|
||||
The wakeup script provides:
|
||||
- **Port conflict detection** - Warns if port 8086 is already in use
|
||||
- **Virtual environment activation** - Ensures correct Python environment
|
||||
- **Centralized logging** - All logs written to `logs/server.log` for easy tailing
|
||||
- **Auto-reload** - Code changes picked up automatically (except requirements.txt changes)
|
||||
- **Consistent configuration** - Same startup every time
|
||||
|
||||
To monitor logs in another terminal:
|
||||
```bash
|
||||
tail -f logs/server.log
|
||||
```
|
||||
|
||||
To stop the server: Press `Ctrl+C`
|
||||
|
||||
To kill a stuck server:
|
||||
```bash
|
||||
pkill -f "uvicorn src.main:app"
|
||||
# or
|
||||
kill $(lsof -t -i:8086)
|
||||
```
|
||||
|
||||
#### Testing
|
||||
|
||||
**Test REST endpoints** against `http://localhost:8086`:
|
||||
```bash
|
||||
curl http://localhost:8086/health
|
||||
curl http://localhost:8086/
|
||||
curl http://localhost:8086/docs # Swagger UI
|
||||
```
|
||||
|
||||
**Running tests**: Always use the venv explicitly to avoid environment mismatches:
|
||||
```bash
|
||||
.venv/bin/python -m pytest tests/ # All tests
|
||||
.venv/bin/python -m pytest tests/ -v # Verbose output
|
||||
.venv/bin/python -m pytest tests/ --cov # With coverage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1.5 Known Issues & Future Improvements
|
||||
|
||||
### Explore Agent
|
||||
|
||||
- **Model Hallucination**: Mistral Nemo sometimes hallucinates file contents instead of using actual tool results. Consider using a more capable model (codestral, qwen2.5-coder) or adding response validation.
|
||||
|
||||
- **Ollama Provider**: We use a custom `WebberOllamaProvider` (ported from tatlock) that sanitizes `content: null` to `content: ""` for assistant messages with tool calls. This works around an Ollama API limitation.
|
||||
|
||||
- **Gitignore Support**: ✅ Fixed - The filesystem tools now honor `.gitignore` patterns and default ignores (`.venv/`, `__pycache__/`, `node_modules/`, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 2. FastAPI Architecture & Best Practices
|
||||
*Reference: [FastAPI Best Practices](https://github.com/zhanymkanov/fastapi-best-practices)*
|
||||
|
||||
### 📂 Project Structure (Directory-based, NOT File-type based)
|
||||
Do **not** group files by type (e.g., one huge `routers` folder). Group by **domain/module** inside a `src/` directory.
|
||||
|
||||
**Correct Structure:**
|
||||
```text
|
||||
to be determined
|
||||
@@ -7,6 +7,82 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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
|
||||
- Default `OLLAMA_AGENT_MODEL` is now `gemma4:e2b` instead of `mistral-nemo-large:latest`, so a deployment without an explicit override no longer exhausts shared GPU memory
|
||||
- `BaseAgent` is generic over its context type and `run_stream` is typed as
|
||||
`AsyncIterator[str | StreamEvent]`, matching what callers already receive.
|
||||
|
||||
## [1.0.1] - 2026-07-19
|
||||
|
||||
### Changed
|
||||
- Default `TATLOCK_API_URL` and `SEARXNG_URL` now use docker container names (`http://tatlock:8000`, `http://searxng:8080`) instead of host IP:port, for container-to-container traffic on the docker-dataplane network
|
||||
- CI workflow now pushes Docker images via the `git.schweitz.net` registry route
|
||||
|
||||
## [1.0.0] - 2026-01-15
|
||||
|
||||
### Added
|
||||
- Event-based streaming for task agent (`StreamEvent` objects instead of raw text)
|
||||
- New `tools_streaming.py` with all tools emitting structured events
|
||||
- Event types: `tool_start`, `tool_done`, `thinking`, `response`, `error`, `done`
|
||||
- Retry logic when LLM responds without calling tools (max 2 retries)
|
||||
- Tracks `tools_called` counter on TaskContext
|
||||
- Stronger retry prompt forces tool use
|
||||
- Working directory context injected into all agent prompts
|
||||
|
||||
### Changed
|
||||
- Hardened system prompts to enforce tool use before responding
|
||||
- Added "CRITICAL RULE" section requiring tool calls first
|
||||
- Made "MANDATORY WORKFLOW" more emphatic
|
||||
- Updated explore and plan agents with `_build_prompt_with_context()` method
|
||||
|
||||
### Fixed
|
||||
- Agent path hallucination - now explicitly communicates working directory to LLM
|
||||
|
||||
### Note
|
||||
- Project paused: Local LLMs (Mistral Nemo 12B on available hardware) are not capable enough for reliable agentic tool use. Models frequently hallucinate responses instead of calling tools, even with prompt hardening and retry logic. Would require larger models (70B+) or cloud API integration to continue.
|
||||
|
||||
## [0.4.2] - 2026-01-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -136,7 +136,7 @@ Last updated: 2026-01-14
|
||||
|---------|----------|-------------|------------|
|
||||
| **Notebook editing** | Tools | Jupyter cell manipulation | Medium |
|
||||
| **MCP support** | Infrastructure | Model Context Protocol | High |
|
||||
| **Config file** | CLI | `~/.webber/config.toml` | Low |
|
||||
| ~~**Config file**~~ | CLI | ✅ `~/.webber/config.toml` with `config` command | Low |
|
||||
| **IDE integration** | CLI | VS Code extension | High |
|
||||
| **Parallel agents** | Orchestration | Concurrent agent execution | High |
|
||||
| **Agent memory** | Orchestration | Shared context between agents | Medium |
|
||||
|
||||
@@ -13,7 +13,7 @@ Webber is a FastAPI-based agent orchestration service that provides:
|
||||
**Port:** 8086
|
||||
**Runtime:** Python 3.12, FastAPI, Uvicorn
|
||||
**Agent Framework:** PydanticAI
|
||||
**Default LLM:** Ollama with mistral-nemo-large:latest
|
||||
**Default LLM:** Ollama with gemma4:e2b
|
||||
|
||||
---
|
||||
|
||||
@@ -205,9 +205,11 @@ All settings via environment variables or `.env`:
|
||||
| HOST | 0.0.0.0 | Server host |
|
||||
| PORT | 8086 | Server port |
|
||||
| OLLAMA_URL | http://192.168.86.149:11434 | Ollama API URL |
|
||||
| OLLAMA_AGENT_MODEL | mistral-nemo-large:latest | Agent reasoning model |
|
||||
| OLLAMA_AGENT_MODEL | gemma4:e2b | Agent reasoning model |
|
||||
| OLLAMA_EMBED_MODEL | nomic-embed-text:latest | Embedding model |
|
||||
| TATLOCK_API_URL | http://192.168.86.149:8000 | Tatlock auth service |
|
||||
| TATLOCK_API_URL | http://tatlock:8000 | Tatlock auth service |
|
||||
| SEARXNG_URL | http://searxng:8080 | SearXNG web search instance |
|
||||
| SEARXNG_TIMEOUT | 10 | SearXNG request timeout (seconds) |
|
||||
| TOOL_TIMEOUT_SECONDS | 120 | Tool execution timeout |
|
||||
| SANDBOX_ENABLED | true | Enable sandboxed execution |
|
||||
| ALLOWED_PATHS | [] | Paths accessible to tools |
|
||||
@@ -357,7 +359,7 @@ Gitea Actions workflow:
|
||||
|
||||
Deployed in Portainer `agents` stack alongside Tatlock:
|
||||
- Network: `docker-dataplane`
|
||||
- Registry: `git.schweitz.internal/jpmschweitzer/webber`
|
||||
- Registry: `git.schweitz.net/jpmschweitzer/webber`
|
||||
- Auto-update: Watchtower with label `com.centurylinklabs.watchtower.enable=true`
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "webber-api"
|
||||
version = "0.4.2"
|
||||
version = "1.2.0"
|
||||
description = "Webber API - Multi-Agent AI Development Server"
|
||||
authors = [
|
||||
{name = "jpmschweitzer"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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,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__)
|
||||
|
||||
|
||||
@@ -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,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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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]:
|
||||
@@ -79,6 +79,14 @@ class ExploreAgentImpl(BaseAgent):
|
||||
from src.domains.agents.explore.tools import register_explore_tools
|
||||
register_explore_tools(agent)
|
||||
|
||||
def _build_prompt_with_context(self, prompt: str, working_dir: str) -> str:
|
||||
"""Build the prompt with working directory context."""
|
||||
return f"""Working directory: {working_dir}
|
||||
|
||||
Use paths within this working directory for file operations.
|
||||
|
||||
User request: {prompt}"""
|
||||
|
||||
@logged()
|
||||
async def run(
|
||||
self,
|
||||
@@ -98,16 +106,20 @@ class ExploreAgentImpl(BaseAgent):
|
||||
Returns:
|
||||
Agent response with findings
|
||||
"""
|
||||
effective_working_dir = working_dir or os.getcwd()
|
||||
|
||||
ctx = ExploreContext(
|
||||
working_dir=working_dir or os.getcwd(),
|
||||
working_dir=effective_working_dir,
|
||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||
)
|
||||
|
||||
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
|
||||
|
||||
async with trace_span("explore_agent_run"):
|
||||
try:
|
||||
# Use run() not run_stream() - Ollama has bugs with streaming + tools
|
||||
result = await self.agent.run(prompt, deps=ctx)
|
||||
result = await self.agent.run(full_prompt, deps=ctx)
|
||||
return result.output
|
||||
except Exception as e:
|
||||
logger.exception(f"Explore agent error: {e}")
|
||||
@@ -126,15 +138,19 @@ class ExploreAgentImpl(BaseAgent):
|
||||
|
||||
Yields text chunks as they become available.
|
||||
"""
|
||||
effective_working_dir = working_dir or os.getcwd()
|
||||
|
||||
ctx = ExploreContext(
|
||||
working_dir=working_dir or os.getcwd(),
|
||||
working_dir=effective_working_dir,
|
||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||
)
|
||||
|
||||
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
|
||||
|
||||
async with trace_span("explore_agent_stream"):
|
||||
try:
|
||||
async with self.agent.run_stream(prompt, deps=ctx) as result:
|
||||
async with self.agent.run_stream(full_prompt, deps=ctx) as result:
|
||||
async for chunk in result.stream_text():
|
||||
yield chunk
|
||||
except Exception as e:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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]:
|
||||
@@ -82,6 +82,14 @@ class PlanAgentImpl(BaseAgent):
|
||||
from src.domains.agents.plan.tools import register_plan_tools
|
||||
register_plan_tools(agent)
|
||||
|
||||
def _build_prompt_with_context(self, prompt: str, working_dir: str) -> str:
|
||||
"""Build the prompt with working directory context."""
|
||||
return f"""Working directory: {working_dir}
|
||||
|
||||
Use paths within this working directory for file operations.
|
||||
|
||||
User request: {prompt}"""
|
||||
|
||||
@logged()
|
||||
async def run(
|
||||
self,
|
||||
@@ -101,16 +109,20 @@ class PlanAgentImpl(BaseAgent):
|
||||
Returns:
|
||||
Implementation plan with steps and critical files
|
||||
"""
|
||||
effective_working_dir = working_dir or os.getcwd()
|
||||
|
||||
ctx = PlanContext(
|
||||
working_dir=working_dir or os.getcwd(),
|
||||
working_dir=effective_working_dir,
|
||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||
)
|
||||
|
||||
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
|
||||
|
||||
async with trace_span("plan_agent_run"):
|
||||
try:
|
||||
# Use run() not run_stream() - Ollama has bugs with streaming + tools
|
||||
result = await self.agent.run(prompt, deps=ctx)
|
||||
result = await self.agent.run(full_prompt, deps=ctx)
|
||||
return result.output
|
||||
except Exception as e:
|
||||
logger.exception(f"Plan agent error: {e}")
|
||||
@@ -128,15 +140,19 @@ class PlanAgentImpl(BaseAgent):
|
||||
|
||||
Yields text chunks as they become available.
|
||||
"""
|
||||
effective_working_dir = working_dir or os.getcwd()
|
||||
|
||||
ctx = PlanContext(
|
||||
working_dir=working_dir or os.getcwd(),
|
||||
working_dir=effective_working_dir,
|
||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||
)
|
||||
|
||||
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
|
||||
|
||||
async with trace_span("plan_agent_stream"):
|
||||
try:
|
||||
async with self.agent.run_stream(prompt, deps=ctx) as result:
|
||||
async with self.agent.run_stream(full_prompt, deps=ctx) as result:
|
||||
async for chunk in result.stream_text():
|
||||
yield chunk
|
||||
except Exception as e:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,25 +5,36 @@ Supports permission modes for controlling agent tool access:
|
||||
- default: All tools available (approval may be required)
|
||||
- plan: Read-only tools only
|
||||
- auto_accept: All tools, no approval prompts
|
||||
|
||||
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 logged, get_logger
|
||||
from src.shared.logging import get_logger, logged
|
||||
|
||||
|
||||
def _get_mode(mode_value: str | PermissionMode) -> PermissionMode:
|
||||
"""Convert mode string to enum (handles use_enum_values=True)."""
|
||||
if isinstance(mode_value, PermissionMode):
|
||||
return mode_value
|
||||
return PermissionMode(mode_value)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -56,18 +67,21 @@ async def run_agent(request: AgentRunRequest) -> AgentRunResponse:
|
||||
detail=f"Unknown agent type: {request.agent_type}"
|
||||
)
|
||||
|
||||
# Convert mode string to enum (use_enum_values=True in schema)
|
||||
mode = _get_mode(request.mode)
|
||||
|
||||
try:
|
||||
# Run the agent with mode
|
||||
response = await agent.run(
|
||||
request.prompt,
|
||||
working_dir=request.working_dir,
|
||||
mode=request.mode,
|
||||
mode=mode,
|
||||
)
|
||||
|
||||
return AgentRunResponse(
|
||||
response=response,
|
||||
agent_type=request.agent_type,
|
||||
mode=request.mode,
|
||||
mode=request.mode, # Keep original for response
|
||||
success=True,
|
||||
)
|
||||
|
||||
@@ -88,13 +102,16 @@ async def stream_agent(request: AgentRunRequest) -> StreamingResponse:
|
||||
"""
|
||||
Run an agent with streaming response.
|
||||
|
||||
Returns Server-Sent Events (SSE) with text chunks.
|
||||
Returns Server-Sent Events (SSE) with structured events.
|
||||
Permission mode controls which tools are available.
|
||||
|
||||
Event types:
|
||||
- "chunk": Text chunk from the agent
|
||||
- "done": Stream complete
|
||||
- "error": Error occurred
|
||||
Event types (from StreamEvent):
|
||||
- tool_start: Tool execution beginning
|
||||
- tool_done: Tool execution complete
|
||||
- thinking: Agent status update
|
||||
- response: Final response text chunk
|
||||
- error: Error occurred
|
||||
- done: Stream complete
|
||||
"""
|
||||
agent = get_agent(request.agent_type)
|
||||
if not agent:
|
||||
@@ -103,23 +120,28 @@ async def stream_agent(request: AgentRunRequest) -> StreamingResponse:
|
||||
detail=f"Unknown agent type: {request.agent_type}"
|
||||
)
|
||||
|
||||
# Convert mode string to enum (use_enum_values=True in schema)
|
||||
mode = _get_mode(request.mode)
|
||||
|
||||
async def generate():
|
||||
try:
|
||||
async for chunk in agent.run_stream(
|
||||
async for event in agent.run_stream(
|
||||
request.prompt,
|
||||
working_dir=request.working_dir,
|
||||
mode=request.mode,
|
||||
mode=mode,
|
||||
):
|
||||
# SSE format: data: {json}\n\n
|
||||
event = {"event": "chunk", "data": chunk}
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
|
||||
# Signal completion
|
||||
yield f"data: {json.dumps({'event': 'done', 'mode': request.mode.value})}\n\n"
|
||||
# Handle both StreamEvent objects and legacy string chunks
|
||||
if isinstance(event, StreamEvent):
|
||||
# New structured event format
|
||||
event_data = event.model_dump(exclude_none=True)
|
||||
yield f"data: {json.dumps(event_data)}\n\n"
|
||||
else:
|
||||
# Legacy string chunk (for explore/plan agents)
|
||||
yield f"data: {json.dumps({'event': 'chunk', 'data': event})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Stream error: {e}")
|
||||
error_event = {"event": "error", "data": str(e)}
|
||||
error_event = {"event": "error", "error_message": str(e)}
|
||||
yield f"data: {json.dumps(error_event)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -127,3 +133,36 @@ class AgentInfo(BaseSchema):
|
||||
class AgentListResponse(BaseSchema):
|
||||
"""List of available agents."""
|
||||
agents: list[AgentInfo]
|
||||
|
||||
|
||||
# Streaming event types for event-based streaming
|
||||
class StreamEventType(str, Enum):
|
||||
"""
|
||||
Event types for structured agent streaming.
|
||||
|
||||
Instead of streaming raw text (which gets garbled during tool calls),
|
||||
we emit structured events that the CLI can render appropriately.
|
||||
"""
|
||||
tool_start = "tool_start" # Tool execution starting
|
||||
tool_done = "tool_done" # Tool execution complete
|
||||
thinking = "thinking" # Agent reasoning status
|
||||
response = "response" # Final response text chunk
|
||||
error = "error" # Error occurred
|
||||
done = "done" # Stream complete
|
||||
|
||||
|
||||
class StreamEvent(BaseSchema):
|
||||
"""
|
||||
Structured streaming event from agent execution.
|
||||
|
||||
Events are emitted instead of raw text to provide clean
|
||||
progress feedback during multi-tool agent loops.
|
||||
"""
|
||||
event: StreamEventType
|
||||
tool: str | None = None # Tool name (for tool_start/tool_done)
|
||||
args: dict | None = None # Tool arguments (for tool_start)
|
||||
result_summary: str | None = None # Brief result (for tool_done)
|
||||
message: str | None = None # Status message (for thinking)
|
||||
text: str | None = None # Response text (for response)
|
||||
error_message: str | None = None # Error details (for error)
|
||||
mode: str | None = None # Permission mode (for done)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -5,7 +5,10 @@ Full orchestrator agent that can:
|
||||
- Execute multi-step tasks autonomously
|
||||
- Use all tools (read + write) based on permission mode
|
||||
- Spawn sub-agents (Explore, Plan) for focused work
|
||||
- Stream structured events instead of raw text
|
||||
"""
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, field
|
||||
@@ -14,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.schemas import PermissionMode
|
||||
from src.domains.agents.task.prompts import TASK_SYSTEM_PROMPT, TASK_PLAN_MODE_PROMPT
|
||||
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_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__)
|
||||
|
||||
@@ -30,14 +33,36 @@ class TaskContext(AgentContext):
|
||||
Context for task agent tools.
|
||||
|
||||
Passed to all tool functions via RunContext.
|
||||
Extends base AgentContext with permission mode.
|
||||
Extends base AgentContext with permission mode and event queue.
|
||||
"""
|
||||
mode: PermissionMode = PermissionMode.default
|
||||
# Prep for approval flow - tools can check this
|
||||
pending_approvals: list[str] = field(default_factory=list)
|
||||
# Event queue for streaming events from tools
|
||||
event_queue: asyncio.Queue | None = field(default=None, repr=False)
|
||||
# Track tool calls for retry logic
|
||||
tools_called: int = 0
|
||||
|
||||
|
||||
class TaskAgentImpl(BaseAgent):
|
||||
def _emit_event(ctx: AgentContext, event: StreamEvent) -> None:
|
||||
"""Emit an event to the queue if available."""
|
||||
if hasattr(ctx, 'event_queue') and ctx.event_queue is not None:
|
||||
ctx.event_queue.put_nowait(event)
|
||||
|
||||
|
||||
def _summarize_result(result: str, max_len: int = 80) -> str:
|
||||
"""Create a brief summary of a tool result."""
|
||||
# Count lines if multiline
|
||||
lines = result.strip().split('\n')
|
||||
if len(lines) > 1:
|
||||
return f"{len(lines)} lines"
|
||||
# Single line - truncate if needed
|
||||
if len(result) > max_len:
|
||||
return result[:max_len] + "..."
|
||||
return result
|
||||
|
||||
|
||||
class TaskAgentImpl(BaseAgent[TaskContext]):
|
||||
"""
|
||||
Full orchestrator agent for autonomous task execution.
|
||||
|
||||
@@ -102,14 +127,41 @@ 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 import register_task_tools, register_readonly_tools
|
||||
from src.domains.agents.task.tools_streaming import (
|
||||
register_readonly_tools_streaming,
|
||||
register_task_tools_streaming,
|
||||
)
|
||||
|
||||
if mode == PermissionMode.plan:
|
||||
# Plan mode: read-only tools only
|
||||
register_readonly_tools(agent)
|
||||
register_readonly_tools_streaming(agent)
|
||||
else:
|
||||
# Default and auto_accept: all tools
|
||||
register_task_tools(agent)
|
||||
register_task_tools_streaming(agent)
|
||||
|
||||
# Maximum retries when no tools are called
|
||||
MAX_NO_TOOL_RETRIES = 2
|
||||
|
||||
def _build_prompt_with_context(self, prompt: str, working_dir: str) -> str:
|
||||
"""Build the prompt with working directory context."""
|
||||
return f"""Working directory: {working_dir}
|
||||
|
||||
When using file tools, use paths relative to or within this working directory.
|
||||
For example, to read a file at {working_dir}/README.md, use file_path="{working_dir}/README.md".
|
||||
|
||||
User request: {prompt}"""
|
||||
|
||||
def _build_retry_prompt(self, prompt: str, working_dir: str) -> str:
|
||||
"""Build a stronger prompt for retry after no tool calls."""
|
||||
return f"""Working directory: {working_dir}
|
||||
|
||||
IMPORTANT: Your previous response was REJECTED because you did not call any tools.
|
||||
You MUST call a tool (like glob_files, bash_readonly, or read_file) BEFORE responding.
|
||||
DO NOT answer from memory. DO NOT fabricate information.
|
||||
|
||||
Call a tool NOW to gather real information, then respond based on the results.
|
||||
|
||||
User request: {prompt}"""
|
||||
|
||||
@logged()
|
||||
async def run(
|
||||
@@ -132,23 +184,47 @@ class TaskAgentImpl(BaseAgent):
|
||||
Returns:
|
||||
Consolidated task summary with results
|
||||
"""
|
||||
ctx = TaskContext(
|
||||
working_dir=working_dir or os.getcwd(),
|
||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||
mode=mode,
|
||||
)
|
||||
effective_working_dir = working_dir or os.getcwd()
|
||||
|
||||
# Get agent configured for this mode
|
||||
agent = self._get_agent_for_mode(mode)
|
||||
|
||||
async with trace_span("task_agent_run", mode=mode.value):
|
||||
try:
|
||||
result = await agent.run(prompt, deps=ctx)
|
||||
return result.output
|
||||
except Exception as e:
|
||||
logger.exception(f"Task agent error: {e}")
|
||||
raise
|
||||
async with trace_span("task_agent_run"):
|
||||
retries = 0
|
||||
while retries <= self.MAX_NO_TOOL_RETRIES:
|
||||
# Create fresh context for each attempt
|
||||
ctx = TaskContext(
|
||||
working_dir=effective_working_dir,
|
||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||
mode=mode,
|
||||
)
|
||||
|
||||
# Build prompt - use retry prompt if this is a retry
|
||||
if retries == 0:
|
||||
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
|
||||
else:
|
||||
full_prompt = self._build_retry_prompt(prompt, effective_working_dir)
|
||||
logger.warning(f"Retry {retries}/{self.MAX_NO_TOOL_RETRIES}: No tools called, retrying with stronger prompt")
|
||||
|
||||
try:
|
||||
result = await agent.run(full_prompt, deps=ctx)
|
||||
|
||||
# Check if tools were called
|
||||
if ctx.tools_called == 0 and retries < self.MAX_NO_TOOL_RETRIES:
|
||||
retries += 1
|
||||
continue
|
||||
|
||||
if ctx.tools_called == 0:
|
||||
logger.warning("Agent responded without calling tools after all retries")
|
||||
|
||||
return result.output
|
||||
except Exception as e:
|
||||
logger.exception(f"Task agent error: {e}")
|
||||
raise
|
||||
|
||||
# Should not reach here, but just in case
|
||||
return result.output
|
||||
|
||||
async def run_stream(
|
||||
self,
|
||||
@@ -157,9 +233,12 @@ class TaskAgentImpl(BaseAgent):
|
||||
allowed_paths: list[str] | None = None,
|
||||
mode: PermissionMode = PermissionMode.default,
|
||||
**kwargs: Any
|
||||
) -> AsyncIterator[str]:
|
||||
) -> AsyncIterator[StreamEvent]:
|
||||
"""
|
||||
Run the task agent with streaming output.
|
||||
Run the task agent with structured event streaming.
|
||||
|
||||
Instead of streaming raw text (which gets garbled during tool calls),
|
||||
yields structured events that clients can render appropriately.
|
||||
|
||||
Args:
|
||||
prompt: Task description
|
||||
@@ -168,26 +247,134 @@ class TaskAgentImpl(BaseAgent):
|
||||
mode: Permission mode controlling tool access
|
||||
|
||||
Yields:
|
||||
Text chunks as they become available.
|
||||
StreamEvent objects for tool progress and final response.
|
||||
|
||||
Event types:
|
||||
- tool_start: Tool execution beginning
|
||||
- tool_done: Tool execution complete with summary
|
||||
- thinking: Agent status update
|
||||
- response: Final response text
|
||||
- error: Error occurred
|
||||
- done: Stream complete
|
||||
"""
|
||||
ctx = TaskContext(
|
||||
working_dir=working_dir or os.getcwd(),
|
||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||
mode=mode,
|
||||
)
|
||||
effective_working_dir = working_dir or os.getcwd()
|
||||
|
||||
# Get agent configured for this mode
|
||||
agent = self._get_agent_for_mode(mode)
|
||||
|
||||
async with trace_span("task_agent_stream", mode=mode.value):
|
||||
try:
|
||||
async with agent.run_stream(prompt, deps=ctx) as result:
|
||||
async for chunk in result.stream_text():
|
||||
yield chunk
|
||||
except Exception as e:
|
||||
logger.exception(f"Task agent stream error: {e}")
|
||||
raise
|
||||
async with trace_span("task_agent_stream"):
|
||||
# Emit initial thinking event
|
||||
yield StreamEvent(
|
||||
event=StreamEventType.thinking,
|
||||
message="Starting task execution..."
|
||||
)
|
||||
|
||||
retries = 0
|
||||
response = ""
|
||||
|
||||
while retries <= self.MAX_NO_TOOL_RETRIES:
|
||||
# Create fresh event queue and context for each attempt
|
||||
event_queue: asyncio.Queue[StreamEvent] = asyncio.Queue()
|
||||
|
||||
ctx = TaskContext(
|
||||
working_dir=effective_working_dir,
|
||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||
mode=mode,
|
||||
event_queue=event_queue,
|
||||
)
|
||||
|
||||
# Build prompt - use retry prompt if this is a retry
|
||||
if retries == 0:
|
||||
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
|
||||
else:
|
||||
full_prompt = self._build_retry_prompt(prompt, effective_working_dir)
|
||||
yield StreamEvent(
|
||||
event=StreamEventType.thinking,
|
||||
message=f"Retrying (attempt {retries + 1})..."
|
||||
)
|
||||
|
||||
# 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
|
||||
except Exception as e:
|
||||
logger.exception(f"Task agent stream error: {e}")
|
||||
raise
|
||||
|
||||
agent_task = asyncio.create_task(run_agent())
|
||||
|
||||
# Yield events from queue while agent runs
|
||||
try:
|
||||
while not agent_task.done():
|
||||
try:
|
||||
# Check for events with timeout
|
||||
event = await asyncio.wait_for(
|
||||
event_queue.get(),
|
||||
timeout=0.1
|
||||
)
|
||||
yield event
|
||||
except TimeoutError:
|
||||
# No events, check if agent is done
|
||||
continue
|
||||
|
||||
# Drain remaining events
|
||||
while not event_queue.empty():
|
||||
yield event_queue.get_nowait()
|
||||
|
||||
# Get final result
|
||||
response = await agent_task
|
||||
|
||||
# Check if tools were called - if not, retry
|
||||
if ctx.tools_called == 0 and retries < self.MAX_NO_TOOL_RETRIES:
|
||||
logger.warning(f"No tools called, retrying ({retries + 1}/{self.MAX_NO_TOOL_RETRIES})")
|
||||
retries += 1
|
||||
continue
|
||||
|
||||
if ctx.tools_called == 0:
|
||||
logger.warning("Agent responded without calling tools after all retries")
|
||||
|
||||
# Success - break out of retry loop
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Stream error: {e}")
|
||||
yield StreamEvent(
|
||||
event=StreamEventType.error,
|
||||
error_message=str(e)
|
||||
)
|
||||
# Cancel agent if still running
|
||||
if not agent_task.done():
|
||||
agent_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await agent_task
|
||||
return
|
||||
|
||||
# Yield response in chunks for streaming feel
|
||||
chunk_size = 100
|
||||
for i in range(0, len(response), chunk_size):
|
||||
chunk = response[i:i + chunk_size]
|
||||
yield StreamEvent(
|
||||
event=StreamEventType.response,
|
||||
text=chunk
|
||||
)
|
||||
# Small delay for streaming effect
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
# Signal completion
|
||||
yield StreamEvent(
|
||||
event=StreamEventType.done,
|
||||
mode=mode.value
|
||||
)
|
||||
|
||||
|
||||
# Create and register the singleton instance
|
||||
@@ -208,7 +395,7 @@ async def task_stream(
|
||||
prompt: str,
|
||||
working_dir: str | None = None,
|
||||
**kwargs: Any
|
||||
) -> AsyncIterator[str]:
|
||||
"""Run task execution with streaming."""
|
||||
async for chunk in task_agent.run_stream(prompt, working_dir=working_dir, **kwargs):
|
||||
yield chunk
|
||||
) -> AsyncIterator[StreamEvent]:
|
||||
"""Run task execution with event streaming."""
|
||||
async for event in task_agent.run_stream(prompt, working_dir=working_dir, **kwargs):
|
||||
yield event
|
||||
|
||||
@@ -9,6 +9,11 @@ The Task agent is a full orchestrator that can:
|
||||
|
||||
TASK_PLAN_MODE_PROMPT = """You are a codebase analysis and planning agent in READ-ONLY mode.
|
||||
|
||||
CRITICAL RULE: You MUST call a tool BEFORE responding to ANY request.
|
||||
- NEVER answer from memory or assumptions
|
||||
- NEVER fabricate file structures, code, or content
|
||||
- If you respond without calling a tool first, YOUR ANSWER IS WRONG
|
||||
|
||||
You can explore and analyze code but CANNOT modify files or execute write operations.
|
||||
|
||||
AVAILABLE TOOLS (read-only):
|
||||
@@ -24,12 +29,10 @@ Shell:
|
||||
Orchestration:
|
||||
- spawn_agent: Launch sub-agents for focused tasks (explore, plan only)
|
||||
|
||||
WORKFLOW:
|
||||
1. Understand the request
|
||||
2. Explore the codebase to gather context
|
||||
3. Analyze code structure and patterns
|
||||
4. Create detailed implementation plans
|
||||
5. Return findings with actionable recommendations
|
||||
MANDATORY WORKFLOW:
|
||||
1. FIRST: Call a tool to gather real information
|
||||
2. THEN: Analyze the actual tool results
|
||||
3. FINALLY: Respond based only on what tools returned
|
||||
|
||||
TOOL CALL EXAMPLES:
|
||||
|
||||
@@ -42,22 +45,25 @@ To search for a function:
|
||||
To check git status:
|
||||
Call bash_readonly with command="git status"
|
||||
|
||||
To list directory contents:
|
||||
Call bash_readonly with command="ls -la"
|
||||
|
||||
To get deeper analysis:
|
||||
Call spawn_agent with agent_type="explore" and prompt="find authentication code"
|
||||
|
||||
RULES:
|
||||
- ALWAYS use tools first, then analyze results
|
||||
- Never guess file contents - read them first
|
||||
- ALWAYS call a tool FIRST - no exceptions
|
||||
- Never guess or fabricate - only report what tools return
|
||||
- Be thorough in exploration
|
||||
- Provide specific file paths and line numbers in findings
|
||||
- Provide specific file paths and line numbers from tool results
|
||||
|
||||
OUTPUT FORMAT:
|
||||
Structure your response with:
|
||||
|
||||
### Analysis
|
||||
- What was found
|
||||
- What was found (from tool results)
|
||||
- Key patterns identified
|
||||
- Relevant files
|
||||
- Relevant files (actual paths from tools)
|
||||
|
||||
### Recommendations
|
||||
- Suggested approach
|
||||
@@ -67,6 +73,11 @@ Structure your response with:
|
||||
|
||||
TASK_SYSTEM_PROMPT = """You are an autonomous task execution agent.
|
||||
|
||||
CRITICAL RULE: You MUST call a tool BEFORE responding to ANY request.
|
||||
- NEVER answer from memory or assumptions
|
||||
- NEVER fabricate file structures, code, or content
|
||||
- If you respond without calling a tool first, YOUR ANSWER IS WRONG
|
||||
|
||||
You have access to ALL tools including file editing, writing, and bash execution.
|
||||
You can also spawn sub-agents to help with complex tasks.
|
||||
|
||||
@@ -89,16 +100,21 @@ External:
|
||||
Orchestration:
|
||||
- spawn_agent: Launch sub-agents for focused tasks
|
||||
|
||||
WORKFLOW:
|
||||
1. Understand the task requirements
|
||||
2. Break down into sub-tasks if complex
|
||||
3. Use spawn_agent for research (explore) or planning (plan)
|
||||
4. Execute implementation steps using write tools
|
||||
5. Validate changes (run tests if applicable)
|
||||
6. Return consolidated summary
|
||||
MANDATORY WORKFLOW:
|
||||
1. FIRST: Call a tool to gather real information
|
||||
2. THEN: Analyze the actual tool results
|
||||
3. Execute implementation using write tools if needed
|
||||
4. Validate changes (run tests if applicable)
|
||||
5. FINALLY: Return summary based only on what tools returned
|
||||
|
||||
TOOL CALL EXAMPLES:
|
||||
|
||||
To list directory contents:
|
||||
Call bash_readonly with command="ls -la"
|
||||
|
||||
To find all Python files:
|
||||
Call glob_files with pattern="**/*.py"
|
||||
|
||||
To spawn an Explore agent for research:
|
||||
Call spawn_agent with agent_type="explore" and prompt="find all config files"
|
||||
|
||||
@@ -124,8 +140,8 @@ GIT DISCIPLINE:
|
||||
- Run tests before committing
|
||||
|
||||
RULES:
|
||||
- ALWAYS use tools first, then analyze results
|
||||
- Never guess file contents - read them first
|
||||
- ALWAYS call a tool FIRST - no exceptions
|
||||
- Never guess or fabricate - only report what tools return
|
||||
- Prefer edit_file over write_file for existing files
|
||||
- Use spawn_agent to keep context focused
|
||||
- Validate changes by running tests when applicable
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
"""
|
||||
Tool registrations for the Task agent with event streaming.
|
||||
|
||||
Same tools as tools.py but emit StreamEvent events for progress tracking.
|
||||
Tools push events to the context's event_queue when available.
|
||||
"""
|
||||
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.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
|
||||
from src.domains.tools.shell.bash import BashReadOnlyTool
|
||||
from src.domains.tools.shell.bash_full import BashTool
|
||||
|
||||
|
||||
def _emit_event(ctx: AgentContext, event: StreamEvent) -> None:
|
||||
"""Emit an event to the queue if available."""
|
||||
if hasattr(ctx, 'event_queue') and ctx.event_queue is not None:
|
||||
ctx.event_queue.put_nowait(event)
|
||||
|
||||
|
||||
def _track_tool_call(ctx: AgentContext) -> None:
|
||||
"""Increment tool call counter for retry logic."""
|
||||
if hasattr(ctx, 'tools_called'):
|
||||
ctx.tools_called += 1
|
||||
|
||||
|
||||
def _summarize_result(result: str, max_len: int = 80) -> str:
|
||||
"""Create a brief summary of a tool result."""
|
||||
lines = result.strip().split('\n')
|
||||
if len(lines) > 3:
|
||||
return f"{len(lines)} lines"
|
||||
if len(result) > max_len:
|
||||
return result[:max_len] + "..."
|
||||
return result.replace('\n', ' ')
|
||||
|
||||
|
||||
def _register_read_file(agent: Agent[TaskContext, str]) -> None:
|
||||
"""Register read_file tool with event streaming."""
|
||||
@agent.tool
|
||||
async def read_file(
|
||||
ctx: RunContext[TaskContext],
|
||||
file_path: str,
|
||||
offset: int = 0,
|
||||
limit: int = 2000
|
||||
) -> str:
|
||||
"""Read contents of a file with line numbers.
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the file to read
|
||||
offset: Line number to start from (0-based, default: 0)
|
||||
limit: Maximum number of lines to read (default: 2000)
|
||||
|
||||
Returns:
|
||||
File contents with line numbers, or error message.
|
||||
|
||||
IMPORTANT: Always use absolute paths. Read files before editing them.
|
||||
"""
|
||||
_track_tool_call(ctx.deps)
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_start,
|
||||
tool="read_file",
|
||||
args={"file_path": file_path, "offset": offset, "limit": limit}
|
||||
))
|
||||
|
||||
tool = ReadFileTool(allowed_paths=ctx.deps.allowed_paths)
|
||||
result = await tool.execute(
|
||||
file_path=file_path,
|
||||
offset=offset,
|
||||
limit=limit
|
||||
)
|
||||
result_str = result.to_string()
|
||||
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="read_file",
|
||||
result_summary=_summarize_result(result_str)
|
||||
))
|
||||
|
||||
return result_str
|
||||
|
||||
|
||||
def _register_glob_files(agent: Agent[TaskContext, str]) -> None:
|
||||
"""Register glob_files tool with event streaming."""
|
||||
@agent.tool
|
||||
async def glob_files(
|
||||
ctx: RunContext[TaskContext],
|
||||
pattern: str,
|
||||
path: str | None = None,
|
||||
limit: int = 100
|
||||
) -> str:
|
||||
"""Find files matching a glob pattern.
|
||||
|
||||
Args:
|
||||
pattern: Glob pattern (e.g., "**/*.py", "src/**/*.ts", "*.md")
|
||||
path: Directory to search in (default: working directory)
|
||||
limit: Maximum number of files to return (default: 100)
|
||||
|
||||
Returns:
|
||||
List of absolute file paths, sorted by modification time (newest first).
|
||||
|
||||
Examples:
|
||||
- "**/*.py" finds all Python files
|
||||
- "src/**/*.ts" finds TypeScript files in src/
|
||||
- "**/test_*.py" finds all test files
|
||||
"""
|
||||
_track_tool_call(ctx.deps)
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_start,
|
||||
tool="glob_files",
|
||||
args={"pattern": pattern, "path": path}
|
||||
))
|
||||
|
||||
tool = GlobFilesTool(allowed_paths=ctx.deps.allowed_paths)
|
||||
search_path = path or ctx.deps.working_dir
|
||||
result = await tool.execute(
|
||||
pattern=pattern,
|
||||
path=search_path,
|
||||
limit=limit
|
||||
)
|
||||
result_str = result.to_string()
|
||||
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="glob_files",
|
||||
result_summary=_summarize_result(result_str)
|
||||
))
|
||||
|
||||
return result_str
|
||||
|
||||
|
||||
def _register_grep_content(agent: Agent[TaskContext, str]) -> None:
|
||||
"""Register grep_content tool with event streaming."""
|
||||
@agent.tool
|
||||
async def grep_content(
|
||||
ctx: RunContext[TaskContext],
|
||||
pattern: str,
|
||||
path: str | None = None,
|
||||
file_glob: str | None = None,
|
||||
context_lines: int = 0,
|
||||
case_sensitive: bool = True
|
||||
) -> str:
|
||||
"""Search file contents using regex pattern.
|
||||
|
||||
Args:
|
||||
pattern: Regex pattern to search for (Python re syntax)
|
||||
path: Directory or file to search (default: working directory)
|
||||
file_glob: Filter files by glob (e.g., "*.py", "*.ts")
|
||||
context_lines: Lines of context before/after matches (default: 0)
|
||||
case_sensitive: Case-sensitive search (default: True)
|
||||
|
||||
Returns:
|
||||
Matching lines with file paths and line numbers.
|
||||
Format: "filepath:line_num: content"
|
||||
"""
|
||||
_track_tool_call(ctx.deps)
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_start,
|
||||
tool="grep_content",
|
||||
args={"pattern": pattern, "path": path, "file_glob": file_glob}
|
||||
))
|
||||
|
||||
tool = GrepContentTool(allowed_paths=ctx.deps.allowed_paths)
|
||||
search_path = path or ctx.deps.working_dir
|
||||
result = await tool.execute(
|
||||
pattern=pattern,
|
||||
path=search_path,
|
||||
file_glob=file_glob,
|
||||
context_lines=context_lines,
|
||||
case_sensitive=case_sensitive
|
||||
)
|
||||
result_str = result.to_string()
|
||||
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="grep_content",
|
||||
result_summary=_summarize_result(result_str)
|
||||
))
|
||||
|
||||
return result_str
|
||||
|
||||
|
||||
def _register_bash_readonly(agent: Agent[TaskContext, str]) -> None:
|
||||
"""Register bash_readonly tool with event streaming."""
|
||||
@agent.tool
|
||||
async def bash_readonly(
|
||||
ctx: RunContext[TaskContext],
|
||||
command: str,
|
||||
cwd: str | None = None,
|
||||
timeout: int = 30
|
||||
) -> str:
|
||||
"""Execute a read-only bash command.
|
||||
|
||||
ALLOWED commands:
|
||||
- File inspection: ls, find, cat, head, tail, wc, file, stat, tree, du
|
||||
- Git (read-only): git status, git log, git diff, git show, git branch
|
||||
- Text processing: grep, awk, sed (read-only), sort, uniq
|
||||
- System info: pwd, whoami, hostname, which
|
||||
|
||||
FORBIDDEN:
|
||||
- File modification (rm, mv, cp, mkdir, touch)
|
||||
- Redirects (>, >>)
|
||||
- Command chaining (&&, ||, ;)
|
||||
- Network (curl, wget)
|
||||
|
||||
Args:
|
||||
command: The bash command to execute
|
||||
cwd: Working directory (default: agent working directory)
|
||||
timeout: Timeout in seconds (default: 30)
|
||||
"""
|
||||
_track_tool_call(ctx.deps)
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_start,
|
||||
tool="bash_readonly",
|
||||
args={"command": command}
|
||||
))
|
||||
|
||||
tool = BashReadOnlyTool(allowed_paths=ctx.deps.allowed_paths)
|
||||
working_dir = cwd or ctx.deps.working_dir
|
||||
result = await tool.execute(
|
||||
command=command,
|
||||
cwd=working_dir,
|
||||
timeout=min(timeout, ctx.deps.timeout_seconds)
|
||||
)
|
||||
result_str = result.to_string()
|
||||
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="bash_readonly",
|
||||
result_summary=_summarize_result(result_str)
|
||||
))
|
||||
|
||||
return result_str
|
||||
|
||||
|
||||
def _register_spawn_agent(agent: Agent[TaskContext, str], readonly_only: bool = False) -> None:
|
||||
"""Register spawn_agent tool with event streaming."""
|
||||
@agent.tool
|
||||
async def spawn_agent(
|
||||
ctx: RunContext[TaskContext],
|
||||
agent_type: str,
|
||||
prompt: str,
|
||||
working_dir: str | None = None
|
||||
) -> str:
|
||||
"""Spawn a sub-agent to handle a focused task.
|
||||
|
||||
Use this to offload work to specialized agents:
|
||||
- "explore": Fast codebase searches and analysis (read-only)
|
||||
- "plan": Design implementation strategies (read-only)
|
||||
|
||||
Args:
|
||||
agent_type: Type of agent to spawn ("explore" or "plan")
|
||||
prompt: Task description for the sub-agent
|
||||
working_dir: Working directory for the sub-agent (default: current)
|
||||
|
||||
Returns:
|
||||
Sub-agent's consolidated response.
|
||||
|
||||
Examples:
|
||||
- spawn_agent(agent_type="explore", prompt="find all test files")
|
||||
- spawn_agent(agent_type="plan", prompt="design user auth feature")
|
||||
|
||||
IMPORTANT:
|
||||
- Use sub-agents to keep context focused and efficient
|
||||
- Explore agent for research, Plan agent for design
|
||||
- Cannot spawn nested Task agents (recursion risk)
|
||||
"""
|
||||
from src.domains.agents.base import get_agent
|
||||
|
||||
_track_tool_call(ctx.deps)
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_start,
|
||||
tool="spawn_agent",
|
||||
args={"agent_type": agent_type, "prompt": prompt[:50] + "..." if len(prompt) > 50 else prompt}
|
||||
))
|
||||
|
||||
# Validate agent type
|
||||
allowed_types = ["explore", "plan"]
|
||||
if agent_type not in allowed_types:
|
||||
if agent_type == "task":
|
||||
result = "Error: Cannot spawn nested Task agents (recursion risk)"
|
||||
else:
|
||||
result = f"Error: Unknown agent type '{agent_type}'. Allowed: {allowed_types}"
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="spawn_agent",
|
||||
result_summary=result
|
||||
))
|
||||
return result
|
||||
|
||||
sub_agent = get_agent(agent_type)
|
||||
if not sub_agent:
|
||||
result = f"Error: Agent '{agent_type}' not found in registry"
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="spawn_agent",
|
||||
result_summary=result
|
||||
))
|
||||
return result
|
||||
|
||||
try:
|
||||
result = await sub_agent.run(
|
||||
prompt=prompt,
|
||||
working_dir=working_dir or ctx.deps.working_dir,
|
||||
allowed_paths=ctx.deps.allowed_paths,
|
||||
)
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="spawn_agent",
|
||||
result_summary=_summarize_result(result)
|
||||
))
|
||||
return result
|
||||
except Exception as e:
|
||||
result = f"Sub-agent error: {e}"
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="spawn_agent",
|
||||
result_summary=result
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
def register_readonly_tools_streaming(agent: Agent[TaskContext, str]) -> None:
|
||||
"""
|
||||
Register read-only tools with event streaming.
|
||||
|
||||
Used in plan mode. Includes:
|
||||
- read_file, glob_files, grep_content, bash_readonly
|
||||
- spawn_agent (restricted to explore/plan)
|
||||
"""
|
||||
_register_read_file(agent)
|
||||
_register_glob_files(agent)
|
||||
_register_grep_content(agent)
|
||||
_register_bash_readonly(agent)
|
||||
_register_spawn_agent(agent, readonly_only=True)
|
||||
|
||||
|
||||
def register_task_tools_streaming(agent: Agent[TaskContext, str]) -> None:
|
||||
"""
|
||||
Register all tools with event streaming.
|
||||
|
||||
Includes:
|
||||
- Read-only tools: read_file, glob_files, grep_content, bash_readonly
|
||||
- Write tools: edit_file, write_file, bash
|
||||
- External: web_search
|
||||
- Orchestration: spawn_agent
|
||||
"""
|
||||
# Register read-only tools via helpers
|
||||
_register_read_file(agent)
|
||||
_register_glob_files(agent)
|
||||
_register_grep_content(agent)
|
||||
_register_bash_readonly(agent)
|
||||
|
||||
# === Write tools ===
|
||||
|
||||
@agent.tool
|
||||
async def edit_file(
|
||||
ctx: RunContext[TaskContext],
|
||||
file_path: str,
|
||||
old_string: str,
|
||||
new_string: str,
|
||||
replace_all: bool = False
|
||||
) -> str:
|
||||
"""Make targeted edits to a file using find-and-replace.
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the file to edit
|
||||
old_string: The exact text to find and replace (must exist in file)
|
||||
new_string: The replacement text
|
||||
replace_all: If True, replace all occurrences. If False (default),
|
||||
old_string must be unique (appear exactly once).
|
||||
|
||||
Returns:
|
||||
Success message with diff preview, or error.
|
||||
|
||||
IMPORTANT:
|
||||
- old_string must exactly match file content (including whitespace)
|
||||
- By default, old_string must appear exactly once (for safety)
|
||||
- Always read the file first to verify exact content before editing
|
||||
"""
|
||||
_track_tool_call(ctx.deps)
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_start,
|
||||
tool="edit_file",
|
||||
args={"file_path": file_path, "replace_all": replace_all}
|
||||
))
|
||||
|
||||
tool = EditFileTool(allowed_paths=ctx.deps.allowed_paths)
|
||||
result = await tool.execute(
|
||||
file_path=file_path,
|
||||
old_string=old_string,
|
||||
new_string=new_string,
|
||||
replace_all=replace_all
|
||||
)
|
||||
result_str = result.to_string()
|
||||
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="edit_file",
|
||||
result_summary=_summarize_result(result_str)
|
||||
))
|
||||
|
||||
return result_str
|
||||
|
||||
@agent.tool
|
||||
async def write_file(
|
||||
ctx: RunContext[TaskContext],
|
||||
file_path: str,
|
||||
content: str
|
||||
) -> str:
|
||||
"""Create a new file or overwrite an existing file.
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the file to create/write
|
||||
content: The content to write to the file
|
||||
|
||||
Returns:
|
||||
Success message with file path and size.
|
||||
|
||||
IMPORTANT:
|
||||
- Parent directory must exist (use bash mkdir first if needed)
|
||||
- For editing existing files, prefer edit_file instead
|
||||
- Will overwrite existing files without confirmation
|
||||
"""
|
||||
_track_tool_call(ctx.deps)
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_start,
|
||||
tool="write_file",
|
||||
args={"file_path": file_path, "content_length": len(content)}
|
||||
))
|
||||
|
||||
tool = WriteFileTool(allowed_paths=ctx.deps.allowed_paths)
|
||||
result = await tool.execute(
|
||||
file_path=file_path,
|
||||
content=content
|
||||
)
|
||||
result_str = result.to_string()
|
||||
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="write_file",
|
||||
result_summary=_summarize_result(result_str)
|
||||
))
|
||||
|
||||
return result_str
|
||||
|
||||
@agent.tool
|
||||
async def bash(
|
||||
ctx: RunContext[TaskContext],
|
||||
command: str,
|
||||
cwd: str | None = None,
|
||||
timeout: int = 60
|
||||
) -> str:
|
||||
"""Execute a bash command with write capabilities.
|
||||
|
||||
ALLOWED:
|
||||
- File operations: ls, find, mkdir, touch, cp, mv, rm (single files)
|
||||
- Git (full): git add, git commit, git checkout, git merge, git pull
|
||||
- Python: python, pip install, pytest, mypy, ruff
|
||||
- Text processing: grep, awk, sed, sort
|
||||
- Command chaining: && and || are allowed
|
||||
|
||||
FORBIDDEN:
|
||||
- sudo, su (privilege escalation)
|
||||
- Network: curl, wget, ssh, scp, rsync
|
||||
- Dangerous: rm -rf, chmod 777, dd, mkfs
|
||||
|
||||
Args:
|
||||
command: The bash command to execute
|
||||
cwd: Working directory (default: agent working directory)
|
||||
timeout: Timeout in seconds (default: 60)
|
||||
|
||||
Examples:
|
||||
- "mkdir -p src/utils" creates directory
|
||||
- "git add . && git commit -m 'fix: bug'" commits changes
|
||||
- "pytest tests/ -v" runs tests
|
||||
"""
|
||||
_track_tool_call(ctx.deps)
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_start,
|
||||
tool="bash",
|
||||
args={"command": command}
|
||||
))
|
||||
|
||||
tool = BashTool(allowed_paths=ctx.deps.allowed_paths)
|
||||
working_dir = cwd or ctx.deps.working_dir
|
||||
result = await tool.execute(
|
||||
command=command,
|
||||
cwd=working_dir,
|
||||
timeout=min(timeout, ctx.deps.timeout_seconds)
|
||||
)
|
||||
result_str = result.to_string()
|
||||
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="bash",
|
||||
result_summary=_summarize_result(result_str)
|
||||
))
|
||||
|
||||
return result_str
|
||||
|
||||
# === External tools ===
|
||||
|
||||
@agent.tool
|
||||
async def web_search(
|
||||
ctx: RunContext[TaskContext],
|
||||
query: str,
|
||||
num_results: int = 5,
|
||||
categories: str | None = None
|
||||
) -> str:
|
||||
"""Search the web for current information.
|
||||
|
||||
Args:
|
||||
query: Search query (e.g., "Python 3.12 new features")
|
||||
num_results: Number of results to return (1-10, default: 5)
|
||||
categories: Optional category filter ("general", "it", "news", "science")
|
||||
|
||||
Returns:
|
||||
Search results with titles, URLs, and snippets.
|
||||
|
||||
Use this for:
|
||||
- Current events or recent information
|
||||
- Documentation updates
|
||||
- Technical references with URLs
|
||||
"""
|
||||
_track_tool_call(ctx.deps)
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_start,
|
||||
tool="web_search",
|
||||
args={"query": query}
|
||||
))
|
||||
|
||||
tool = WebSearchTool()
|
||||
result = await tool.execute(
|
||||
query=query,
|
||||
num_results=num_results,
|
||||
categories=categories
|
||||
)
|
||||
result_str = result.to_string()
|
||||
|
||||
_emit_event(ctx.deps, StreamEvent(
|
||||
event=StreamEventType.tool_done,
|
||||
tool="web_search",
|
||||
result_summary=_summarize_result(result_str)
|
||||
))
|
||||
|
||||
return result_str
|
||||
|
||||
# === Orchestration tools ===
|
||||
_register_spawn_agent(agent, readonly_only=False)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
import httpx
|
||||
from openai import AsyncOpenAI
|
||||
from pydantic_ai.providers.ollama import OllamaProvider
|
||||
|
||||
@@ -17,6 +28,85 @@ from src.shared.logging import get_logger
|
||||
|
||||
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):
|
||||
"""
|
||||
@@ -38,10 +128,14 @@ class WebberOllamaProvider(OllamaProvider):
|
||||
clean_host = settings.ollama_url.rstrip("/")
|
||||
base_url = f"{clean_host}/v1"
|
||||
|
||||
super().__init__(base_url=base_url)
|
||||
|
||||
# Override the client with our sanitized version
|
||||
self._openai_client = _SanitizedAsyncOpenAI(base_url=base_url)
|
||||
# The sanitized client goes through the official constructor
|
||||
# parameter: the provider's `.client` property serves `_client`,
|
||||
# and the previous pattern — poking `self._openai_client` after
|
||||
# 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}")
|
||||
|
||||
@@ -54,17 +148,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":
|
||||
@@ -88,7 +219,11 @@ class _SanitizedCompletions:
|
||||
if "messages" in kwargs:
|
||||
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]]:
|
||||
@@ -110,8 +245,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'])}"
|
||||
|
||||
@@ -64,15 +64,22 @@ class Settings(BaseSettings):
|
||||
|
||||
# LLM - Ollama (always hot in VRAM on tower-of-joy)
|
||||
ollama_url: str = "http://192.168.86.149:11434"
|
||||
ollama_agent_model: str = "mistral-nemo-large:latest"
|
||||
ollama_agent_model: str = "gemma4:e2b"
|
||||
ollama_embed_model: str = "nomic-embed-text:latest"
|
||||
|
||||
# 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
|
||||
tatlock_api_url: str | None = "http://192.168.86.149:8000"
|
||||
tatlock_api_url: str | None = "http://tatlock:8000"
|
||||
internal_api_key: str | None = None
|
||||
|
||||
# Web search - SearXNG (use SEARXNG_URL env var to override)
|
||||
searxng_url: str = "http://192.168.86.149:8087"
|
||||
searxng_url: str = "http://searxng:8080"
|
||||
searxng_timeout: int = 10
|
||||
|
||||
# Tool execution
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -21,7 +21,6 @@ from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from src.main import app
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command Line Options
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
"""
|
||||
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 src.domains.agents.schemas import PermissionMode
|
||||
|
||||
|
||||
class TestAgentListEndpoint:
|
||||
"""Tests for GET /agents/ endpoint."""
|
||||
@@ -160,3 +167,185 @@ class TestAgentStreamEndpoint:
|
||||
)
|
||||
# Unknown agent returns 400, not streaming
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
class TestPermissionModeIntegration:
|
||||
"""
|
||||
Integration tests for permission mode handling.
|
||||
|
||||
These tests verify that mode strings are correctly converted to enums
|
||||
and that the full request->router->agent flow works for each mode.
|
||||
"""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_with_default_mode(self, auth_client):
|
||||
"""Test running agent with default mode passes through correctly."""
|
||||
# Mock the agent.run method to avoid LLM calls
|
||||
mock_result = MagicMock()
|
||||
mock_result.output = "Test response"
|
||||
|
||||
with patch("src.domains.agents.task.agent.TaskAgentImpl._get_agent_for_mode") as mock_get_agent:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run = AsyncMock(return_value=mock_result)
|
||||
mock_get_agent.return_value = mock_agent
|
||||
|
||||
response = await auth_client.post(
|
||||
"/agents/run",
|
||||
json={
|
||||
"prompt": "test prompt",
|
||||
"agent_type": "task",
|
||||
"working_dir": ".",
|
||||
"mode": "default"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["mode"] == "default"
|
||||
|
||||
# Verify mode was converted to enum and passed correctly
|
||||
mock_get_agent.assert_called_once_with(PermissionMode.default)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_with_plan_mode(self, auth_client):
|
||||
"""Test running agent with plan mode passes through correctly."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.output = "Plan response"
|
||||
|
||||
with patch("src.domains.agents.task.agent.TaskAgentImpl._get_agent_for_mode") as mock_get_agent:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run = AsyncMock(return_value=mock_result)
|
||||
mock_get_agent.return_value = mock_agent
|
||||
|
||||
response = await auth_client.post(
|
||||
"/agents/run",
|
||||
json={
|
||||
"prompt": "test prompt",
|
||||
"agent_type": "task",
|
||||
"working_dir": ".",
|
||||
"mode": "plan"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["mode"] == "plan"
|
||||
mock_get_agent.assert_called_once_with(PermissionMode.plan)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_with_auto_accept_mode(self, auth_client):
|
||||
"""Test running agent with auto_accept mode passes through correctly."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.output = "Auto accept response"
|
||||
|
||||
with patch("src.domains.agents.task.agent.TaskAgentImpl._get_agent_for_mode") as mock_get_agent:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run = AsyncMock(return_value=mock_result)
|
||||
mock_get_agent.return_value = mock_agent
|
||||
|
||||
response = await auth_client.post(
|
||||
"/agents/run",
|
||||
json={
|
||||
"prompt": "test prompt",
|
||||
"agent_type": "task",
|
||||
"working_dir": ".",
|
||||
"mode": "auto_accept"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["mode"] == "auto_accept"
|
||||
mock_get_agent.assert_called_once_with(PermissionMode.auto_accept)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_with_invalid_mode(self, auth_client):
|
||||
"""Test running agent with invalid mode returns validation error."""
|
||||
response = await auth_client.post(
|
||||
"/agents/run",
|
||||
json={
|
||||
"prompt": "test prompt",
|
||||
"agent_type": "task",
|
||||
"working_dir": ".",
|
||||
"mode": "invalid_mode"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stream_with_plan_mode(self, auth_client):
|
||||
"""Test streaming agent with plan mode passes through correctly."""
|
||||
from src.domains.agents.schemas import StreamEvent, StreamEventType
|
||||
|
||||
async def mock_event_stream(*args, **kwargs):
|
||||
"""Mock event-based stream."""
|
||||
yield StreamEvent(event=StreamEventType.thinking, message="Starting...")
|
||||
yield StreamEvent(event=StreamEventType.response, text="chunk1")
|
||||
yield StreamEvent(event=StreamEventType.response, text="chunk2")
|
||||
yield StreamEvent(event=StreamEventType.done, mode="plan")
|
||||
|
||||
with patch("src.domains.agents.task.agent.TaskAgentImpl.run_stream") as mock_run_stream:
|
||||
mock_run_stream.return_value = mock_event_stream()
|
||||
|
||||
response = await auth_client.post(
|
||||
"/agents/stream",
|
||||
json={
|
||||
"prompt": "test prompt",
|
||||
"agent_type": "task",
|
||||
"working_dir": ".",
|
||||
"mode": "plan"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
||||
mock_run_stream.assert_called_once()
|
||||
|
||||
|
||||
class TestAgentMethodSignatures:
|
||||
"""
|
||||
Tests to verify agent method signatures match expected interfaces.
|
||||
|
||||
These catch issues like passing invalid kwargs to methods.
|
||||
"""
|
||||
|
||||
def test_task_agent_run_accepts_mode(self):
|
||||
"""Verify TaskAgentImpl.run() accepts mode parameter."""
|
||||
import inspect
|
||||
|
||||
from src.domains.agents.task.agent import TaskAgentImpl
|
||||
|
||||
sig = inspect.signature(TaskAgentImpl.run)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
assert "mode" in params
|
||||
# Verify mode has correct type annotation
|
||||
mode_param = sig.parameters["mode"]
|
||||
assert mode_param.default == PermissionMode.default
|
||||
|
||||
def test_task_agent_run_stream_accepts_mode(self):
|
||||
"""Verify TaskAgentImpl.run_stream() accepts mode parameter."""
|
||||
import inspect
|
||||
|
||||
from src.domains.agents.task.agent import TaskAgentImpl
|
||||
|
||||
sig = inspect.signature(TaskAgentImpl.run_stream)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
assert "mode" in params
|
||||
mode_param = sig.parameters["mode"]
|
||||
assert mode_param.default == PermissionMode.default
|
||||
|
||||
def test_trace_span_signature(self):
|
||||
"""Verify trace_span only accepts expected parameters."""
|
||||
import inspect
|
||||
|
||||
from src.shared.logging import trace_span
|
||||
|
||||
sig = inspect.signature(trace_span.__init__)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
# Should only have self, name, logger - not mode or other extras
|
||||
assert params == ["self", "name", "logger"]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,7 +13,7 @@ class TestWebSearchTool:
|
||||
|
||||
@pytest.fixture
|
||||
def tool(self):
|
||||
return WebSearchTool(searxng_url="http://localhost:8087", timeout=5)
|
||||
return WebSearchTool(searxng_url="http://searxng:8080", timeout=5)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_search_response(self):
|
||||
|
||||
@@ -26,6 +26,41 @@ class PermissionMode(str, Enum):
|
||||
auto_accept = "auto_accept"
|
||||
|
||||
|
||||
class StreamEventType(str, Enum):
|
||||
"""Event types for structured agent streaming."""
|
||||
tool_start = "tool_start"
|
||||
tool_done = "tool_done"
|
||||
thinking = "thinking"
|
||||
response = "response"
|
||||
error = "error"
|
||||
done = "done"
|
||||
chunk = "chunk" # Legacy text chunk
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamEvent:
|
||||
"""
|
||||
Structured streaming event from agent execution.
|
||||
|
||||
Different event types carry different data:
|
||||
- tool_start: tool, args
|
||||
- tool_done: tool, result_summary
|
||||
- thinking: message
|
||||
- response: text
|
||||
- error: error_message
|
||||
- done: mode
|
||||
- chunk: text (legacy)
|
||||
"""
|
||||
event: StreamEventType
|
||||
tool: str | None = None
|
||||
args: dict | None = None
|
||||
result_summary: str | None = None
|
||||
message: str | None = None
|
||||
text: str | None = None
|
||||
error_message: str | None = None
|
||||
mode: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentResponse:
|
||||
"""Response from agent execution."""
|
||||
@@ -202,7 +237,7 @@ class WebberClient:
|
||||
prompt: str,
|
||||
working_dir: str = ".",
|
||||
mode: PermissionMode = PermissionMode.default,
|
||||
) -> AsyncIterator[str]:
|
||||
) -> AsyncIterator[StreamEvent]:
|
||||
"""
|
||||
Run an agent with streaming response.
|
||||
|
||||
@@ -213,7 +248,7 @@ class WebberClient:
|
||||
mode: Permission mode controlling tool access
|
||||
|
||||
Yields:
|
||||
Text chunks as they arrive
|
||||
StreamEvent objects as they arrive
|
||||
"""
|
||||
# Use a fresh client for streaming with longer timeout
|
||||
async with httpx.AsyncClient(
|
||||
@@ -235,13 +270,30 @@ class WebberClient:
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
data = json.loads(line[6:])
|
||||
event = data.get("event")
|
||||
if event == "chunk":
|
||||
yield data.get("data", "")
|
||||
elif event == "error":
|
||||
raise Exception(data.get("data", "Unknown error"))
|
||||
elif event == "done":
|
||||
event_type = data.get("event")
|
||||
|
||||
# Parse event type
|
||||
try:
|
||||
evt_type = StreamEventType(event_type)
|
||||
except ValueError:
|
||||
continue # Unknown event type
|
||||
|
||||
# Build StreamEvent from response data
|
||||
yield StreamEvent(
|
||||
event=evt_type,
|
||||
tool=data.get("tool"),
|
||||
args=data.get("args"),
|
||||
result_summary=data.get("result_summary"),
|
||||
message=data.get("message"),
|
||||
text=data.get("text") or data.get("data"), # 'data' for legacy chunk
|
||||
error_message=data.get("error_message") or data.get("data"),
|
||||
mode=data.get("mode"),
|
||||
)
|
||||
|
||||
# Stop on done or error
|
||||
if evt_type in (StreamEventType.done, StreamEventType.error):
|
||||
break
|
||||
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Configuration management for Webber CLI.
|
||||
|
||||
Loads settings from ~/.webber/config.toml with environment variable overrides.
|
||||
"""
|
||||
import os
|
||||
import tomllib
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# Config directory and file paths
|
||||
CONFIG_DIR = Path.home() / ".webber"
|
||||
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
||||
|
||||
# Default configuration template
|
||||
DEFAULT_CONFIG = """\
|
||||
# Webber CLI Configuration
|
||||
# https://github.com/jpmschweitzer/webber
|
||||
|
||||
[api]
|
||||
# API server URL (dev: 8095, prod: 8086)
|
||||
url = "http://localhost:8095"
|
||||
|
||||
# API key for authentication (optional for dev mode)
|
||||
# key = "your-api-key"
|
||||
|
||||
[cli]
|
||||
# Default permission mode: default, plan, auto_accept
|
||||
mode = "default"
|
||||
|
||||
# Enable streaming by default
|
||||
stream = true
|
||||
|
||||
[history]
|
||||
# Command history file location
|
||||
file = "~/.webber_history"
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApiConfig:
|
||||
"""API connection settings."""
|
||||
url: str = "http://localhost:8095"
|
||||
key: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliConfig:
|
||||
"""CLI behavior settings."""
|
||||
mode: str = "default"
|
||||
stream: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class HistoryConfig:
|
||||
"""History settings."""
|
||||
file: str = "~/.webber_history"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""Complete configuration."""
|
||||
api: ApiConfig = field(default_factory=ApiConfig)
|
||||
cli: CliConfig = field(default_factory=CliConfig)
|
||||
history: HistoryConfig = field(default_factory=HistoryConfig)
|
||||
|
||||
|
||||
def load_config() -> Config:
|
||||
"""
|
||||
Load configuration from file with environment variable overrides.
|
||||
|
||||
Priority (highest to lowest):
|
||||
1. Environment variables (WEBBER_API_URL, WEBBER_API_KEY, WEBBER_MODE)
|
||||
2. Config file (~/.webber/config.toml)
|
||||
3. Built-in defaults
|
||||
|
||||
Returns:
|
||||
Config object with merged settings
|
||||
"""
|
||||
config = Config()
|
||||
|
||||
# Load from file if exists
|
||||
if CONFIG_FILE.exists():
|
||||
try:
|
||||
with open(CONFIG_FILE, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
config = _parse_config(data)
|
||||
except Exception:
|
||||
# If config file is invalid, use defaults
|
||||
pass
|
||||
|
||||
# Apply environment variable overrides
|
||||
if url := os.environ.get("WEBBER_API_URL"):
|
||||
config.api.url = url
|
||||
if key := os.environ.get("WEBBER_API_KEY"):
|
||||
config.api.key = key
|
||||
if mode := os.environ.get("WEBBER_MODE"):
|
||||
config.cli.mode = mode
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def _parse_config(data: dict[str, Any]) -> Config:
|
||||
"""Parse config dict into Config object."""
|
||||
config = Config()
|
||||
|
||||
if api := data.get("api"):
|
||||
config.api.url = api.get("url", config.api.url)
|
||||
config.api.key = api.get("key", config.api.key)
|
||||
|
||||
if cli := data.get("cli"):
|
||||
config.cli.mode = cli.get("mode", config.cli.mode)
|
||||
config.cli.stream = cli.get("stream", config.cli.stream)
|
||||
|
||||
if history := data.get("history"):
|
||||
config.history.file = history.get("file", config.history.file)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def init_config() -> Path:
|
||||
"""
|
||||
Initialize config directory and file with defaults.
|
||||
|
||||
Returns:
|
||||
Path to the created config file
|
||||
"""
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not CONFIG_FILE.exists():
|
||||
CONFIG_FILE.write_text(DEFAULT_CONFIG)
|
||||
|
||||
return CONFIG_FILE
|
||||
|
||||
|
||||
def get_config_path() -> Path | None:
|
||||
"""Get path to config file if it exists."""
|
||||
return CONFIG_FILE if CONFIG_FILE.exists() else None
|
||||
|
||||
|
||||
# Module-level cached config
|
||||
_config: Config | None = None
|
||||
|
||||
|
||||
def get_config() -> Config:
|
||||
"""Get cached config, loading if necessary."""
|
||||
global _config
|
||||
if _config is None:
|
||||
_config = load_config()
|
||||
return _config
|
||||
+171
-44
@@ -8,7 +8,6 @@ Usage:
|
||||
webber-cli status
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -22,14 +21,17 @@ from rich.markdown import Markdown
|
||||
from rich.panel import Panel
|
||||
from rich.prompt import Confirm
|
||||
|
||||
from webber_cli.client import WebberClient, PermissionMode
|
||||
from webber_cli.client import WebberClient, PermissionMode, StreamEvent, StreamEventType
|
||||
from webber_cli.config import get_config, init_config, get_config_path, CONFIG_FILE
|
||||
from webber_cli.theme import get_console, get_theme
|
||||
|
||||
|
||||
# === Prompt Toolkit Setup ===
|
||||
|
||||
# History file location
|
||||
HISTORY_FILE = Path.home() / ".webber_history"
|
||||
def _get_history_file() -> Path:
|
||||
"""Get history file path from config."""
|
||||
config = get_config()
|
||||
return Path(config.history.file).expanduser()
|
||||
|
||||
# Built-in commands for completion
|
||||
BUILTIN_COMMANDS = [
|
||||
@@ -87,7 +89,7 @@ PROMPT_STYLE = Style.from_dict({
|
||||
def create_prompt_session(working_dir: str) -> PromptSession:
|
||||
"""Create a configured prompt session with history and completion."""
|
||||
return PromptSession(
|
||||
history=FileHistory(str(HISTORY_FILE)),
|
||||
history=FileHistory(str(_get_history_file())),
|
||||
auto_suggest=AutoSuggestFromHistory(),
|
||||
completer=WebberCompleter(working_dir),
|
||||
style=PROMPT_STYLE,
|
||||
@@ -97,18 +99,22 @@ def create_prompt_session(working_dir: str) -> PromptSession:
|
||||
app = typer.Typer(
|
||||
name="webber-cli",
|
||||
help="CLI client for the Webber API",
|
||||
no_args_is_help=True,
|
||||
no_args_is_help=False,
|
||||
invoke_without_command=True,
|
||||
add_completion=False,
|
||||
)
|
||||
|
||||
console = get_console()
|
||||
|
||||
# Default API URL (can be overridden via env or option)
|
||||
# Development port is 8095, production is 8086
|
||||
DEFAULT_API_URL = os.environ.get("WEBBER_API_URL", "http://localhost:8095")
|
||||
|
||||
# Default API key for conversation API (dev mode accepts any non-empty key)
|
||||
DEFAULT_API_KEY = os.environ.get("WEBBER_API_KEY", "webber-cli-dev-key")
|
||||
def _get_api_url() -> str:
|
||||
"""Get API URL from config (with env override already applied)."""
|
||||
return get_config().api.url
|
||||
|
||||
|
||||
def _get_api_key() -> str:
|
||||
"""Get API key from config (with env override already applied)."""
|
||||
return get_config().api.key or "webber-cli-dev-key"
|
||||
|
||||
|
||||
def version_callback(value: bool) -> None:
|
||||
@@ -136,8 +142,9 @@ def _confirm_auto_accept() -> bool:
|
||||
)
|
||||
|
||||
|
||||
@app.callback()
|
||||
@app.callback(invoke_without_command=True)
|
||||
def main(
|
||||
ctx: typer.Context,
|
||||
version: bool = typer.Option(
|
||||
False,
|
||||
"--version",
|
||||
@@ -148,16 +155,24 @@ def main(
|
||||
),
|
||||
) -> None:
|
||||
"""Webber CLI - Talk to the Webber API."""
|
||||
pass
|
||||
# Default to chat command if no subcommand given
|
||||
if ctx.invoked_subcommand is None:
|
||||
chat(
|
||||
directory=".",
|
||||
api_url=None,
|
||||
mode=None,
|
||||
resume=None,
|
||||
stream=None,
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def sessions(
|
||||
api_url: str = typer.Option(
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
"--api",
|
||||
"-a",
|
||||
help="Webber API URL",
|
||||
help="Webber API URL (default from config)",
|
||||
),
|
||||
limit: int = typer.Option(
|
||||
20,
|
||||
@@ -171,12 +186,13 @@ def sessions(
|
||||
|
||||
Shows recent sessions that can be resumed with 'chat --resume <id>'.
|
||||
"""
|
||||
asyncio.run(_list_sessions(api_url, limit))
|
||||
url = api_url or _get_api_url()
|
||||
asyncio.run(_list_sessions(url, limit))
|
||||
|
||||
|
||||
async def _list_sessions(api_url: str, limit: int) -> None:
|
||||
"""List conversation sessions."""
|
||||
async with WebberClient(api_url, api_key=DEFAULT_API_KEY) as client:
|
||||
async with WebberClient(api_url, api_key=_get_api_key()) as client:
|
||||
# Check API health
|
||||
if not await client.health_check():
|
||||
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}")
|
||||
@@ -223,16 +239,16 @@ def chat(
|
||||
help="Working directory for the agent",
|
||||
),
|
||||
api_url: str = typer.Option(
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
"--api",
|
||||
"-a",
|
||||
help="Webber API URL",
|
||||
help="Webber API URL (default from config)",
|
||||
),
|
||||
mode: str = typer.Option(
|
||||
"default",
|
||||
None,
|
||||
"--mode",
|
||||
"-m",
|
||||
help="Permission mode: default, plan (read-only), auto_accept (no prompts)",
|
||||
help="Permission mode: default, plan, auto_accept (default from config)",
|
||||
),
|
||||
resume: str = typer.Option(
|
||||
None,
|
||||
@@ -241,10 +257,10 @@ def chat(
|
||||
help="Resume a previous session by ID (use 'sessions' to list)",
|
||||
),
|
||||
stream: bool = typer.Option(
|
||||
True,
|
||||
None,
|
||||
"--stream/--no-stream",
|
||||
"-s",
|
||||
help="Stream responses in real-time",
|
||||
help="Stream responses in real-time (default from config)",
|
||||
),
|
||||
) -> None:
|
||||
"""
|
||||
@@ -263,6 +279,12 @@ def chat(
|
||||
|
||||
Use --resume to continue a previous session.
|
||||
"""
|
||||
# Resolve config defaults
|
||||
config = get_config()
|
||||
url = api_url or config.api.url
|
||||
mode_str = mode or config.cli.mode
|
||||
use_stream = stream if stream is not None else config.cli.stream
|
||||
|
||||
working_dir = str(Path(directory).resolve())
|
||||
|
||||
if not Path(working_dir).exists():
|
||||
@@ -271,9 +293,9 @@ def chat(
|
||||
|
||||
# Parse and validate mode
|
||||
try:
|
||||
permission_mode = PermissionMode(mode)
|
||||
permission_mode = PermissionMode(mode_str)
|
||||
except ValueError:
|
||||
console.print(f"[error]Error:[/] Invalid mode: {mode}")
|
||||
console.print(f"[error]Error:[/] Invalid mode: {mode_str}")
|
||||
console.print("[dim]Valid modes: default, plan, auto_accept[/]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -284,7 +306,7 @@ def chat(
|
||||
permission_mode = PermissionMode.default
|
||||
|
||||
try:
|
||||
asyncio.run(_chat_loop(api_url, working_dir, permission_mode, stream, resume))
|
||||
asyncio.run(_chat_loop(url, working_dir, permission_mode, use_stream, resume))
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Goodbye![/]")
|
||||
|
||||
@@ -302,7 +324,7 @@ async def _chat_loop(
|
||||
conversation_id: str | None = None
|
||||
conversation_title: str | None = None
|
||||
|
||||
async with WebberClient(api_url, api_key=DEFAULT_API_KEY) as client:
|
||||
async with WebberClient(api_url, api_key=_get_api_key()) as client:
|
||||
# Check API health
|
||||
if not await client.health_check():
|
||||
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}")
|
||||
@@ -449,15 +471,56 @@ async def _chat_loop(
|
||||
console.print()
|
||||
|
||||
if stream:
|
||||
# Stream response in real-time, buffering for persistence
|
||||
# Stream response with structured events
|
||||
response_chunks: list[str] = []
|
||||
current_tool: str | None = None
|
||||
try:
|
||||
async for chunk in client.run_agent_stream(
|
||||
async for event in client.run_agent_stream(
|
||||
agent_type, user_input, working_dir, current_mode
|
||||
):
|
||||
sys.stdout.write(chunk)
|
||||
sys.stdout.flush()
|
||||
response_chunks.append(chunk)
|
||||
if event.event == StreamEventType.thinking:
|
||||
# Show thinking status
|
||||
console.print(f"[dim]{event.message or 'Thinking...'}[/]")
|
||||
|
||||
elif event.event == StreamEventType.tool_start:
|
||||
# Show tool starting
|
||||
current_tool = event.tool
|
||||
args_display = ""
|
||||
if event.args:
|
||||
# Format key args for display
|
||||
key_args = []
|
||||
for k, v in list(event.args.items())[:2]:
|
||||
v_str = str(v)[:40] + "..." if len(str(v)) > 40 else str(v)
|
||||
key_args.append(f"{k}={v_str}")
|
||||
args_display = f" ({', '.join(key_args)})"
|
||||
console.print(f"[info]→ {event.tool}[/]{args_display}", end="")
|
||||
|
||||
elif event.event == StreamEventType.tool_done:
|
||||
# Show tool completed
|
||||
result = event.result_summary or "done"
|
||||
console.print(f" [success]✓[/] [dim]{result}[/]")
|
||||
current_tool = None
|
||||
|
||||
elif event.event == StreamEventType.response:
|
||||
# Stream response text
|
||||
if event.text:
|
||||
sys.stdout.write(event.text)
|
||||
sys.stdout.flush()
|
||||
response_chunks.append(event.text)
|
||||
|
||||
elif event.event == StreamEventType.chunk:
|
||||
# Legacy text chunk (for other agents)
|
||||
if event.text:
|
||||
sys.stdout.write(event.text)
|
||||
sys.stdout.flush()
|
||||
response_chunks.append(event.text)
|
||||
|
||||
elif event.event == StreamEventType.error:
|
||||
console.print(f"\n[error]Error:[/] {event.error_message}")
|
||||
|
||||
elif event.event == StreamEventType.done:
|
||||
pass # Stream complete
|
||||
|
||||
console.print() # Newline after streaming
|
||||
|
||||
# Save messages to conversation if we have a session
|
||||
@@ -510,14 +573,15 @@ async def _chat_loop(
|
||||
@app.command()
|
||||
def status(
|
||||
api_url: str = typer.Option(
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
"--api",
|
||||
"-a",
|
||||
help="Webber API URL",
|
||||
help="Webber API URL (default from config)",
|
||||
),
|
||||
) -> None:
|
||||
"""Check API status and list available agents."""
|
||||
asyncio.run(_status(api_url))
|
||||
url = api_url or _get_api_url()
|
||||
asyncio.run(_status(url))
|
||||
|
||||
|
||||
async def _status(api_url: str) -> None:
|
||||
@@ -536,6 +600,54 @@ async def _status(api_url: str) -> None:
|
||||
console.print("[error]Status:[/] Cannot connect")
|
||||
|
||||
|
||||
@app.command()
|
||||
def config(
|
||||
init: bool = typer.Option(
|
||||
False,
|
||||
"--init",
|
||||
"-i",
|
||||
help="Initialize config file with defaults",
|
||||
),
|
||||
) -> None:
|
||||
"""
|
||||
Show or initialize configuration.
|
||||
|
||||
Without --init, displays current config and source.
|
||||
With --init, creates ~/.webber/config.toml with defaults.
|
||||
"""
|
||||
if init:
|
||||
path = init_config()
|
||||
console.print(f"[success]Config initialized:[/] {path}")
|
||||
console.print("[dim]Edit this file to customize settings.[/]")
|
||||
return
|
||||
|
||||
# Show current config
|
||||
cfg = get_config()
|
||||
config_path = get_config_path()
|
||||
|
||||
console.print("[title]Webber Configuration[/]\n")
|
||||
|
||||
if config_path:
|
||||
console.print(f"[dim]Config file:[/] {config_path}")
|
||||
else:
|
||||
console.print(f"[dim]Config file:[/] [warning]Not found[/] (using defaults)")
|
||||
console.print(f"[dim]Run 'webber-cli config --init' to create {CONFIG_FILE}[/]")
|
||||
|
||||
console.print()
|
||||
console.print("[info]API Settings[/]")
|
||||
console.print(f" url: {cfg.api.url}")
|
||||
console.print(f" key: {'***' if cfg.api.key else '[dim]not set[/]'}")
|
||||
|
||||
console.print()
|
||||
console.print("[info]CLI Settings[/]")
|
||||
console.print(f" mode: {cfg.cli.mode}")
|
||||
console.print(f" stream: {cfg.cli.stream}")
|
||||
|
||||
console.print()
|
||||
console.print("[info]History[/]")
|
||||
console.print(f" file: {cfg.history.file}")
|
||||
|
||||
|
||||
# Keep 'explore' as an alias for 'chat --mode plan' for backwards compatibility
|
||||
@app.command(hidden=True)
|
||||
def explore(
|
||||
@@ -547,16 +659,16 @@ def explore(
|
||||
help="Working directory",
|
||||
),
|
||||
api_url: str = typer.Option(
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
"--api",
|
||||
"-a",
|
||||
help="Webber API URL",
|
||||
help="Webber API URL (default from config)",
|
||||
),
|
||||
stream: bool = typer.Option(
|
||||
True,
|
||||
None,
|
||||
"--stream/--no-stream",
|
||||
"-s",
|
||||
help="Stream responses in real-time",
|
||||
help="Stream responses in real-time (default from config)",
|
||||
),
|
||||
) -> None:
|
||||
"""
|
||||
@@ -564,6 +676,11 @@ def explore(
|
||||
|
||||
Runs the Task agent in plan (read-only) mode for a single query.
|
||||
"""
|
||||
# Resolve config defaults
|
||||
config = get_config()
|
||||
url = api_url or config.api.url
|
||||
use_stream = stream if stream is not None else config.cli.stream
|
||||
|
||||
console.print("[dim]Note: 'explore' is deprecated. Use 'chat --mode plan' for interactive mode.[/]")
|
||||
console.print()
|
||||
|
||||
@@ -573,7 +690,7 @@ def explore(
|
||||
console.print(f"[error]Error:[/] Directory not found: {working_dir}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
asyncio.run(_explore(api_url, query, working_dir, stream))
|
||||
asyncio.run(_explore(url, query, working_dir, use_stream))
|
||||
|
||||
|
||||
async def _explore(
|
||||
@@ -596,13 +713,23 @@ async def _explore(
|
||||
console.print()
|
||||
|
||||
if stream:
|
||||
# Stream response in real-time
|
||||
# Stream response with structured events
|
||||
try:
|
||||
async for chunk in client.run_agent_stream(
|
||||
async for event in client.run_agent_stream(
|
||||
"task", query, working_dir, mode
|
||||
):
|
||||
sys.stdout.write(chunk)
|
||||
sys.stdout.flush()
|
||||
if event.event == StreamEventType.thinking:
|
||||
console.print(f"[dim]{event.message or 'Thinking...'}[/]")
|
||||
elif event.event == StreamEventType.tool_start:
|
||||
console.print(f"[info]→ {event.tool}[/]", end="")
|
||||
elif event.event == StreamEventType.tool_done:
|
||||
console.print(f" [success]✓[/] [dim]{event.result_summary or 'done'}[/]")
|
||||
elif event.event in (StreamEventType.response, StreamEventType.chunk):
|
||||
if event.text:
|
||||
sys.stdout.write(event.text)
|
||||
sys.stdout.flush()
|
||||
elif event.event == StreamEventType.error:
|
||||
console.print(f"\n[error]Error:[/] {event.error_message}")
|
||||
console.print() # Newline after streaming
|
||||
except Exception as e:
|
||||
console.print(f"\n[error]Stream error:[/] {e}")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
This directory contains an application to analyse for the webber coding agent to see what it reports this directory is for. It is to test the llm.
|
||||
Reference in New Issue
Block a user