Compare commits
35
Commits
api/v0.3.1
...
main
@@ -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*'
|
- 'api/v*'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE_NAME: git.schweitz.internal/jpmschweitzer/webber-api
|
IMAGE_NAME: git.schweitz.net/jpmschweitzer/webber-api
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
- name: Login to Gitea Registry
|
- name: Login to Gitea Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: git.schweitz.internal
|
registry: git.schweitz.net
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
.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,245 +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
|
|
||||||
|
|
||||||
# Explore a directory
|
|
||||||
.venv/bin/webber-cli explore "find all python files" -d ../webber-sandbox
|
|
||||||
|
|
||||||
# Interactive chat mode
|
|
||||||
.venv/bin/webber-cli chat -d ../webber-sandbox
|
|
||||||
```
|
|
||||||
|
|
||||||
**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
|
|
||||||
cd webber-cli
|
|
||||||
.venv/bin/webber-cli explore "find all bugs in the code" -d ../webber-sandbox
|
|
||||||
|
|
||||||
# 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/v0.3.0` → Triggers API Docker build
|
|
||||||
- `cli/v0.1.0` → Triggers CLI build (future)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# API release
|
|
||||||
cd webber-api
|
|
||||||
# Update version in pyproject.toml
|
|
||||||
git add -A && git commit -m "chore: release api v0.3.0"
|
|
||||||
git tag api/v0.3.0
|
|
||||||
git push origin main --tags
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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
|
|
||||||
2. **No conversation memory** - CLI chat mode doesn't persist between sessions
|
|
||||||
3. **No streaming** - Responses appear all at once
|
|
||||||
|
|
||||||
See `webber-api/docs/COVERAGE.md` for full feature coverage status.
|
|
||||||
+6
-79
@@ -1,83 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
This monorepo maintains separate changelogs for each package:
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
- **[webber-api/CHANGELOG.md](webber-api/CHANGELOG.md)** - API server changes
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
- **[webber-cli/CHANGELOG.md](webber-cli/CHANGELOG.md)** - CLI client changes
|
||||||
|
|
||||||
## [Unreleased]
|
Each package is versioned independently using prefixed git tags:
|
||||||
|
- `api/vX.Y.Z` for API releases
|
||||||
## [0.3.1] - 2026-01-11
|
- `cli/vX.Y.Z` for CLI releases
|
||||||
|
|
||||||
### Added
|
|
||||||
- Integration test infrastructure with pytest markers (integration, e2e, slow)
|
|
||||||
- 10 LLM integration tests (requires Ollama)
|
|
||||||
- 12 E2E API tests (requires running server)
|
|
||||||
- Command line options: `--run-integration`, `--run-e2e`, `--ollama-url`, `--api-url`
|
|
||||||
- Sample project fixtures for testing
|
|
||||||
- 14 security tests (path traversal, command injection, input validation)
|
|
||||||
- Helper functions: `assert_contains_any`, `assert_contains_all`
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Updated COVERAGE.md to ~65% complete
|
|
||||||
|
|
||||||
## [0.3.0] - 2026-01-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Explore agent with PydanticAI tool calling and Mistral Nemo
|
|
||||||
- Coding tools: `edit_file`, `write_file`, `bash` (full)
|
|
||||||
- Web search tool using SearXNG integration
|
|
||||||
- Streaming responses via SSE for API and CLI
|
|
||||||
- CLI commands: `explore`, `chat`, `status`
|
|
||||||
- Sanitized Ollama provider (fixes `content: null` issue)
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Reorganized into monorepo structure (webber-api/, webber-cli/, webber-sandbox/)
|
|
||||||
- Added ruff linter and fixed mypy errors
|
|
||||||
|
|
||||||
## [0.2.3] - 2026-01-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Docker healthcheck for container health monitoring
|
|
||||||
|
|
||||||
## [0.2.2] - 2026-01-09
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Config parsing for empty environment variables (allowed_paths, cors_*)
|
|
||||||
- Use `env_parse_none_str=""` to treat empty strings as None
|
|
||||||
|
|
||||||
## [0.2.1] - 2026-01-09
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- CI/CD pipeline credentials configured
|
|
||||||
|
|
||||||
## [0.2.0] - 2026-01-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Reference prompts from claude-code-system-prompts for all agent types
|
|
||||||
- Detailed documentation for Explore, Plan, and Task agents
|
|
||||||
- Detailed documentation for File, Shell, and Search tools
|
|
||||||
- Utility prompts (TodoWrite, AskUserQuestion, conversation summarization, etc.)
|
|
||||||
- Security review prompt for code analysis
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Expanded agents/README.md with capabilities and use cases
|
|
||||||
- Expanded tools/README.md with parameter details and behaviors
|
|
||||||
|
|
||||||
## [0.1.0] - 2026-01-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Initial FastAPI boilerplate setup
|
|
||||||
- Domain-based project structure (src/domains/, src/shared/)
|
|
||||||
- BaseController pattern with lazy router instantiation
|
|
||||||
- Pydantic Settings configuration with env file support
|
|
||||||
- Logger decorator with temporal benchmarking and trace IDs
|
|
||||||
- UserProvider singleton for request-scoped context
|
|
||||||
- Custom exception hierarchy
|
|
||||||
- Health endpoints (/, /health)
|
|
||||||
- Placeholder domains for agents (explore, plan, task)
|
|
||||||
- Placeholder domains for tools (file, shell, search)
|
|
||||||
- Placeholder domain for auth (tatlock integration)
|
|
||||||
- CI/CD workflow for Gitea with Docker build and Watchtower deployment
|
|
||||||
- Dockerfile for containerized deployment
|
|
||||||
- CVE-checked dependencies (2026-01-09)
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -4,8 +4,9 @@ A Claude Code-inspired development assistant powered by local LLMs via Ollama.
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Explore Agent** - Search, read, and understand codebases
|
- **3 Agents** - Explore (read-only), Plan (architecture), Task (orchestrator)
|
||||||
- **8 Tools** - File read/write, glob, grep, bash, web search
|
- **8 Tools** - File read/write/edit, glob, grep, bash, web search
|
||||||
|
- **Conversations** - Multi-turn memory with context summarization
|
||||||
- **Streaming** - Real-time response display
|
- **Streaming** - Real-time response display
|
||||||
- **Self-hosted** - Runs on your own hardware with Ollama
|
- **Self-hosted** - Runs on your own hardware with Ollama
|
||||||
|
|
||||||
@@ -74,23 +75,45 @@ webber-cli chat -d /path/to/project
|
|||||||
| `bash` | Full bash with safety controls |
|
| `bash` | Full bash with safety controls |
|
||||||
| `web_search` | Search web via SearXNG |
|
| `web_search` | Search web via SearXNG |
|
||||||
|
|
||||||
|
## Agents
|
||||||
|
|
||||||
|
| Agent | Purpose | Tools |
|
||||||
|
|-------|---------|-------|
|
||||||
|
| **Explore** | Fast codebase navigation, search | Read-only (glob, grep, read, bash_readonly) |
|
||||||
|
| **Plan** | Design implementation strategies | Read-only (same as Explore) |
|
||||||
|
| **Task** | Autonomous multi-step execution | All tools + spawn_agent |
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stateless agent execution
|
||||||
|
POST /agents/run # Execute agent, get response
|
||||||
|
POST /agents/stream # Execute with SSE streaming
|
||||||
|
GET /agents/ # List available agents
|
||||||
|
|
||||||
|
# Stateful conversations (multi-turn memory)
|
||||||
|
POST /conversations/ # Create conversation
|
||||||
|
GET /conversations/ # List conversations
|
||||||
|
POST /conversations/{id}/messages # Add message, get agent response
|
||||||
|
```
|
||||||
|
|
||||||
## Versioning
|
## Versioning
|
||||||
|
|
||||||
This project uses prefixed tags for independent release cycles:
|
This project uses prefixed tags for independent release cycles:
|
||||||
|
|
||||||
- `api/v0.3.0` - Triggers API Docker build and deployment
|
- `api/v0.4.0` - Triggers API Docker build and deployment
|
||||||
- `cli/v0.1.0` - Triggers CLI installer build (future)
|
- `cli/v0.1.0` - Triggers CLI installer build (future)
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.12+
|
- Python 3.12+
|
||||||
- Ollama running with `mistral-nemo:latest` model
|
- Ollama running with `gemma4:e2b` model
|
||||||
- Docker (for production deployment)
|
- Docker (for production deployment)
|
||||||
- SearXNG (optional, for web search)
|
- SearXNG (optional, for web search)
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- `webber-api/AGENTS.md` - API development guidelines
|
- `CLAUDE.md` - Agent development guidelines (repo-wide)
|
||||||
- `webber-api/docs/COVERAGE.md` - Feature coverage and roadmap
|
- `webber-api/docs/COVERAGE.md` - Feature coverage and roadmap
|
||||||
- `webber-api/docs/architecture.md` - System architecture
|
- `webber-api/docs/architecture.md` - System architecture
|
||||||
- `webber-cli/README.md` - CLI usage guide
|
- `webber-cli/README.md` - CLI usage guide
|
||||||
|
|||||||
Executable
+50
@@ -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)
|
# LLM - Ollama (tower-of-joy)
|
||||||
OLLAMA_URL=http://192.168.86.149:11434
|
OLLAMA_URL=http://192.168.86.149:11434
|
||||||
OLLAMA_AGENT_MODEL=mistral-nemo-large:latest
|
OLLAMA_AGENT_MODEL=gemma4:e2b
|
||||||
OLLAMA_EMBED_MODEL=nomic-embed-text:latest
|
OLLAMA_EMBED_MODEL=nomic-embed-text:latest
|
||||||
|
|
||||||
# Auth - Tatlock integration (optional)
|
# Auth - Tatlock integration (optional)
|
||||||
# TATLOCK_API_URL=http://192.168.86.149:8000
|
# TATLOCK_API_URL=http://tatlock:8000
|
||||||
# INTERNAL_API_KEY=your-internal-key
|
# INTERNAL_API_KEY=your-internal-key
|
||||||
|
|
||||||
|
# Web search - SearXNG (container name on docker-dataplane; internal port 8080)
|
||||||
|
# SEARXNG_URL=http://searxng:8080
|
||||||
|
|
||||||
# Tool execution
|
# Tool execution
|
||||||
TOOL_TIMEOUT_SECONDS=120
|
TOOL_TIMEOUT_SECONDS=120
|
||||||
SANDBOX_ENABLED=true
|
SANDBOX_ENABLED=true
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# Changelog - Webber API
|
||||||
|
|
||||||
|
All notable changes to the Webber API will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-08-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `webber version` command. It was implemented and imported but never registered, so the
|
||||||
|
subcommand did not exist; the fuller output includes the resolved Ollama URL and model.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The Ollama `content: null` sanitiser raised `AttributeError` on first use. It looked up the
|
||||||
|
parent client's chat getter via `.fget`, and openai now exposes `chat` as a
|
||||||
|
`cached_property`. Any agent request would have failed before reaching the model.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Default `OLLAMA_AGENT_MODEL` is now `gemma4:e2b` instead of `mistral-nemo-large:latest`, so a deployment without an explicit override no longer exhausts shared GPU memory
|
||||||
|
- `BaseAgent` is generic over its context type and `run_stream` is typed as
|
||||||
|
`AsyncIterator[str | StreamEvent]`, matching what callers already receive.
|
||||||
|
|
||||||
|
## [1.0.1] - 2026-07-19
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Default `TATLOCK_API_URL` and `SEARXNG_URL` now use docker container names (`http://tatlock:8000`, `http://searxng:8080`) instead of host IP:port, for container-to-container traffic on the docker-dataplane network
|
||||||
|
- CI workflow now pushes Docker images via the `git.schweitz.net` registry route
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-01-15
|
||||||
|
|
||||||
|
### 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
|
||||||
|
- Retry logic for transient failures with exponential backoff
|
||||||
|
- `src/shared/retry.py` - `@with_retry` decorator and `retry_async()` function
|
||||||
|
- Retries on: timeout, connection errors, HTTP 429/5xx
|
||||||
|
- Configurable: `RETRY_MAX_ATTEMPTS`, `RETRY_BASE_DELAY`, `RETRY_MAX_DELAY`
|
||||||
|
- Web search tool now automatically retries on network failures
|
||||||
|
- 29 retry tests (205 total tests passing)
|
||||||
|
|
||||||
|
## [0.4.1] - 2026-01-11
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Replace `litellm` with `tiktoken` for token counting (dependency conflict with pydantic-ai)
|
||||||
|
- Update documentation (README.md, architecture.md) with conversation layer info
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-01-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Conversation persistence layer with SQLAlchemy async
|
||||||
|
- Database models: `Conversation`, `Message` with UUID primary keys
|
||||||
|
- SQLite (dev) and PostgreSQL (prod) support via async engines
|
||||||
|
- Lazy database initialization pattern
|
||||||
|
- Context management infrastructure
|
||||||
|
- Token counting utilities using `tiktoken`
|
||||||
|
- Context summarization at 80% token threshold
|
||||||
|
- XML-tagged context prompt building for agent injection
|
||||||
|
- REST API for multi-turn conversations
|
||||||
|
- `POST /conversations/` - Create new conversation
|
||||||
|
- `GET /conversations/` - List conversations
|
||||||
|
- `GET /conversations/{id}` - Get conversation with history
|
||||||
|
- `POST /conversations/{id}/messages` - Add message (triggers agent)
|
||||||
|
- `DELETE /conversations/{id}` - Delete conversation
|
||||||
|
- New dependencies: `sqlalchemy[asyncio]~=2.0.36`, `aiosqlite~=0.21.0`, `tiktoken>=0.12.0`
|
||||||
|
- Config settings: `database_url`, `summarization_threshold`, `keep_recent_messages`
|
||||||
|
- 19 conversation tests, 6 token counting tests (176 total tests passing)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated COVERAGE.md to ~80% complete
|
||||||
|
- Quieter pytest output (`-q --tb=short` instead of `-v`)
|
||||||
|
|
||||||
|
## [0.3.4] - 2026-01-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Task Agent - Full orchestrator for autonomous multi-step task execution
|
||||||
|
- Has ALL tools: read, write, edit, bash (full), web_search
|
||||||
|
- New `spawn_agent` tool to launch sub-agents (Explore, Plan) for focused work
|
||||||
|
- Recursion prevention: cannot spawn nested Task agents
|
||||||
|
- 22 unit tests for registration, tools, spawn_agent, and API
|
||||||
|
- Complete agent hierarchy: Explore (read-only) → Plan (read-only) → Task (orchestrator)
|
||||||
|
|
||||||
|
## [0.3.3] - 2026-01-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Plan Agent - READ-ONLY software architect that designs implementation strategies
|
||||||
|
- Uses only read-only tools: `read_file`, `glob_files`, `grep_content`, `bash_readonly`
|
||||||
|
- Creates step-by-step implementation plans with critical files list
|
||||||
|
- 15 unit tests for registration, tools, and API
|
||||||
|
- Web search summarizer added to roadmap (future feature)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated COVERAGE.md to ~70% complete
|
||||||
|
|
||||||
|
## [0.3.2] - 2026-01-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Mandatory release procedure documentation in AGENTS.md
|
||||||
|
|
||||||
|
## [0.3.1] - 2026-01-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Integration test infrastructure with pytest markers (integration, e2e, slow)
|
||||||
|
- 10 LLM integration tests (requires Ollama)
|
||||||
|
- 12 E2E API tests (requires running server)
|
||||||
|
- Command line options: `--run-integration`, `--run-e2e`, `--ollama-url`, `--api-url`
|
||||||
|
- Sample project fixtures for testing
|
||||||
|
- 14 security tests (path traversal, command injection, input validation)
|
||||||
|
- Helper functions: `assert_contains_any`, `assert_contains_all`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated COVERAGE.md to ~65% complete
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-01-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Explore agent with PydanticAI tool calling and Mistral Nemo
|
||||||
|
- Coding tools: `edit_file`, `write_file`, `bash` (full)
|
||||||
|
- Web search tool using SearXNG integration
|
||||||
|
- Streaming responses via SSE
|
||||||
|
- Sanitized Ollama provider (fixes `content: null` issue)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Reorganized into monorepo structure (webber-api/, webber-cli/, webber-sandbox/)
|
||||||
|
- Added ruff linter and fixed mypy errors
|
||||||
|
|
||||||
|
## [0.2.3] - 2026-01-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Docker healthcheck for container health monitoring
|
||||||
|
|
||||||
|
## [0.2.2] - 2026-01-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Config parsing for empty environment variables (allowed_paths, cors_*)
|
||||||
|
- Use `env_parse_none_str=""` to treat empty strings as None
|
||||||
|
|
||||||
|
## [0.2.1] - 2026-01-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- CI/CD pipeline credentials configured
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-01-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Reference prompts from claude-code-system-prompts for all agent types
|
||||||
|
- Detailed documentation for Explore, Plan, and Task agents
|
||||||
|
- Detailed documentation for File, Shell, and Search tools
|
||||||
|
- Utility prompts (TodoWrite, AskUserQuestion, conversation summarization, etc.)
|
||||||
|
- Security review prompt for code analysis
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Expanded agents/README.md with capabilities and use cases
|
||||||
|
- Expanded tools/README.md with parameter details and behaviors
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-01-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial FastAPI boilerplate setup
|
||||||
|
- Domain-based project structure (src/domains/, src/shared/)
|
||||||
|
- BaseController pattern with lazy router instantiation
|
||||||
|
- Pydantic Settings configuration with env file support
|
||||||
|
- Logger decorator with temporal benchmarking and trace IDs
|
||||||
|
- UserProvider singleton for request-scoped context
|
||||||
|
- Custom exception hierarchy
|
||||||
|
- Health endpoints (/, /health)
|
||||||
|
- Placeholder domains for agents (explore, plan, task)
|
||||||
|
- Placeholder domains for tools (file, shell, search)
|
||||||
|
- Placeholder domain for auth (tatlock integration)
|
||||||
|
- CI/CD workflow for Gitea with Docker build and Watchtower deployment
|
||||||
|
- Dockerfile for containerized deployment
|
||||||
|
- CVE-checked dependencies (2026-01-09)
|
||||||
+67
-19
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
> Tracking progress towards Claude Code-like functionality
|
> Tracking progress towards Claude Code-like functionality
|
||||||
|
|
||||||
## Current Status: ~65% Complete
|
## Current Status: ~85% Complete
|
||||||
|
|
||||||
Last updated: 2026-01-11
|
Last updated: 2026-01-14
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -44,6 +44,20 @@ Last updated: 2026-01-11
|
|||||||
|
|
||||||
**Gap:** Mistral Nemo sometimes hallucinates instead of using tool results.
|
**Gap:** Mistral Nemo sometimes hallucinates instead of using tool results.
|
||||||
|
|
||||||
|
### Phase 2b: Plan Agent ✅ Complete
|
||||||
|
|
||||||
|
| Component | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| `PlanAgentImpl` | ✅ | READ-ONLY software architect agent |
|
||||||
|
| System prompts | ✅ | Architecture-focused with tool examples |
|
||||||
|
| Tool registration | ✅ | Only read-only tools (4 tools) |
|
||||||
|
| Streaming support | ✅ | `run_stream()` method with SSE |
|
||||||
|
| Unit tests | ✅ | 15 tests for registration, tools, API |
|
||||||
|
|
||||||
|
**Available tools:** `read_file`, `glob_files`, `grep_content`, `bash_readonly` (read-only only)
|
||||||
|
|
||||||
|
**Purpose:** Design implementation strategies before coding - explores codebase and creates step-by-step plans.
|
||||||
|
|
||||||
### Phase 3: CLI Foundation ✅ Complete
|
### Phase 3: CLI Foundation ✅ Complete
|
||||||
|
|
||||||
| Component | Status | Notes |
|
| Component | Status | Notes |
|
||||||
@@ -54,16 +68,19 @@ Last updated: 2026-01-11
|
|||||||
| Markdown rendering | ✅ | Rich markdown output |
|
| Markdown rendering | ✅ | Rich markdown output |
|
||||||
| Streaming display | ✅ | Real-time token output with `--stream` flag |
|
| Streaming display | ✅ | Real-time token output with `--stream` flag |
|
||||||
|
|
||||||
### Phase 4: Agentic Loop ⚠️ Partial
|
### Phase 4: Agentic Loop ✅ Complete
|
||||||
|
|
||||||
| Component | Status | Notes |
|
| Component | Status | Notes |
|
||||||
|-----------|--------|-------|
|
|-----------|--------|-------|
|
||||||
| `webber-cli chat` command | ✅ | Interactive mode with streaming |
|
| `webber-cli chat` command | ✅ | Interactive mode with streaming |
|
||||||
| `webber-cli explore` command | ✅ | One-shot query with streaming |
|
| `webber-cli explore` command | ✅ | One-shot query with streaming |
|
||||||
| `SessionState` dataclass | ✅ | Basic context tracking |
|
| `SessionState` dataclass | ✅ | Basic context tracking |
|
||||||
| `AgenticLoop` class | ⚠️ | Basic implementation, not fully utilized |
|
| `AgenticLoop` class | ✅ | Basic implementation |
|
||||||
| Conversation history | ❌ | Not persisted between turns in CLI |
|
| Conversation persistence | ✅ | SQLAlchemy async with SQLite/PostgreSQL |
|
||||||
| Context management | ❌ | No token counting or summarization |
|
| Context summarization | ✅ | Token counting (litellm) + auto-summarization |
|
||||||
|
| Conversation API | ✅ | `/conversations/` REST endpoints |
|
||||||
|
|
||||||
|
**Database:** SQLite (dev) or PostgreSQL (prod), async via SQLAlchemy 2.0
|
||||||
|
|
||||||
### Phase 5: REST API ✅ Complete
|
### Phase 5: REST API ✅ Complete
|
||||||
|
|
||||||
@@ -94,21 +111,24 @@ Last updated: 2026-01-11
|
|||||||
|
|
||||||
| Feature | Category | Description | Complexity |
|
| Feature | Category | Description | Complexity |
|
||||||
|---------|----------|-------------|------------|
|
|---------|----------|-------------|------------|
|
||||||
| **Plan Agent** | Agents | Design implementation approaches | High |
|
| ~~**Plan Agent**~~ | Agents | ✅ Design implementation approaches | High |
|
||||||
| **Task Agent** | Agents | Autonomous multi-step execution | High |
|
| ~~**Task Agent**~~ | Agents | ✅ Autonomous multi-step execution | High |
|
||||||
| **Context summarization** | Infrastructure | Compress history at token limit | High |
|
| ~~**Context summarization**~~ | Infrastructure | ✅ Token counting + auto-summarization | High |
|
||||||
| **Conversation persistence** | CLI | Multi-turn memory in chat mode | Medium |
|
| ~~**Conversation persistence**~~ | Infrastructure | ✅ SQLAlchemy async database layer | Medium |
|
||||||
|
|
||||||
### Medium Priority
|
### Medium Priority
|
||||||
|
|
||||||
| Feature | Category | Description | Complexity |
|
| Feature | Category | Description | Complexity |
|
||||||
|---------|----------|-------------|------------|
|
|---------|----------|-------------|------------|
|
||||||
|
| **Web search summarizer** | Tools | Agent to extract core content from web pages (remove nav, footers, etc.) and preserve relevant links for nested fetching | Medium |
|
||||||
| **Tool result caching** | Infrastructure | Cache file reads for performance | Low |
|
| **Tool result caching** | Infrastructure | Cache file reads for performance | Low |
|
||||||
| **Session persistence** | CLI | Save/resume conversations | Medium |
|
| ~~**Session persistence**~~ | CLI | ✅ Save/resume conversations via `sessions` and `chat --resume` | Medium |
|
||||||
| **Todo tracking** | CLI | Built-in task list (`/todo`) | Medium |
|
| **Todo tracking** | CLI | Built-in task list (`/todo`) | Medium |
|
||||||
| **Git integration** | CLI | Auto-commit, branch management | Medium |
|
| **Git integration** | CLI | Auto-commit, branch management | Medium |
|
||||||
| **Agent handoff** | Orchestration | Explore → Plan → Task workflow | High |
|
| ~~**Agent handoff**~~ | Orchestration | ✅ Task agent is main agent, spawns Explore/Plan as needed (Claude Code pattern) | High |
|
||||||
| **Retry logic** | Infrastructure | Auto-retry on tool failures | Low |
|
| ~~**Retry logic**~~ | Infrastructure | ✅ Auto-retry with exponential backoff | Low |
|
||||||
|
| ~~**Permission modes**~~ | CLI | ✅ default/plan/auto_accept modes controlling tool access | Medium |
|
||||||
|
| ~~**CLI shell features**~~ | CLI | ✅ prompt_toolkit: history, tab completion, auto-suggest | Low |
|
||||||
|
|
||||||
### Low Priority
|
### Low Priority
|
||||||
|
|
||||||
@@ -116,7 +136,7 @@ Last updated: 2026-01-11
|
|||||||
|---------|----------|-------------|------------|
|
|---------|----------|-------------|------------|
|
||||||
| **Notebook editing** | Tools | Jupyter cell manipulation | Medium |
|
| **Notebook editing** | Tools | Jupyter cell manipulation | Medium |
|
||||||
| **MCP support** | Infrastructure | Model Context Protocol | High |
|
| **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 |
|
| **IDE integration** | CLI | VS Code extension | High |
|
||||||
| **Parallel agents** | Orchestration | Concurrent agent execution | High |
|
| **Parallel agents** | Orchestration | Concurrent agent execution | High |
|
||||||
| **Agent memory** | Orchestration | Shared context between agents | Medium |
|
| **Agent memory** | Orchestration | Shared context between agents | Medium |
|
||||||
@@ -129,10 +149,17 @@ Last updated: 2026-01-11
|
|||||||
|------|---------|--------|--------|
|
|------|---------|--------|--------|
|
||||||
| Tool unit tests | 109 | 109 | ✅ |
|
| Tool unit tests | 109 | 109 | ✅ |
|
||||||
| API tests | 11 | 11 | ✅ |
|
| API tests | 11 | 11 | ✅ |
|
||||||
|
| Plan agent tests | 15 | 15 | ✅ |
|
||||||
|
| Task agent tests | 15 | 15 | ✅ |
|
||||||
|
| Conversation tests | 22 | 22 | ✅ |
|
||||||
|
| Token tests | 6 | 6 | ✅ |
|
||||||
|
| Retry tests | 29 | 29 | ✅ |
|
||||||
| Security tests | 14 | 14 | ✅ |
|
| Security tests | 14 | 14 | ✅ |
|
||||||
| Integration tests | 10 | 10 | ✅ Agent + real LLM |
|
| Integration tests | 10 | 10 | ✅ Agent + real LLM |
|
||||||
| E2E tests | 12 | 12 | ✅ Full API workflow |
|
| E2E tests | 12 | 12 | ✅ Full API workflow |
|
||||||
|
|
||||||
|
**Total: 208 tests passing**
|
||||||
|
|
||||||
**Test breakdown:**
|
**Test breakdown:**
|
||||||
- Read/Glob/Grep tools: 17 tests
|
- Read/Glob/Grep tools: 17 tests
|
||||||
- Edit/Write tools: 22 tests
|
- Edit/Write tools: 22 tests
|
||||||
@@ -140,6 +167,11 @@ Last updated: 2026-01-11
|
|||||||
- Web search: 10 tests
|
- Web search: 10 tests
|
||||||
- Gitignore filtering: 10 tests
|
- Gitignore filtering: 10 tests
|
||||||
- API endpoints: 11 tests
|
- API endpoints: 11 tests
|
||||||
|
- Plan agent: 15 tests
|
||||||
|
- Task agent: 15 tests
|
||||||
|
- Conversations: 22 tests
|
||||||
|
- Tokens: 6 tests
|
||||||
|
- Retry: 29 tests
|
||||||
- Security: 14 tests
|
- Security: 14 tests
|
||||||
- Health checks: 2 tests
|
- Health checks: 2 tests
|
||||||
- Integration (LLM): 10 tests
|
- Integration (LLM): 10 tests
|
||||||
@@ -166,9 +198,9 @@ pytest tests/ --run-integration --run-e2e
|
|||||||
|
|
||||||
1. **Model hallucination** - Mistral Nemo sometimes makes up file contents instead of using actual tool results.
|
1. **Model hallucination** - Mistral Nemo sometimes makes up file contents instead of using actual tool results.
|
||||||
|
|
||||||
2. **No conversation memory** - CLI chat mode doesn't persist context between sessions.
|
2. **Temperature setting** - Changed from 0.0 to 0.3 for Mistral Nemo compatibility, may affect determinism.
|
||||||
|
|
||||||
3. **Temperature setting** - Changed from 0.0 to 0.3 for Mistral Nemo compatibility, may affect determinism.
|
3. **SQLAlchemy deprecation** - `datetime.utcnow()` deprecation warning from SQLAlchemy.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -194,9 +226,9 @@ cd webber-api && ./wakeup.sh
|
|||||||
|
|
||||||
# CLI commands (from webber-cli/)
|
# CLI commands (from webber-cli/)
|
||||||
.venv/bin/webber-cli status # Check API connection
|
.venv/bin/webber-cli status # Check API connection
|
||||||
.venv/bin/webber-cli explore "find tests" # One-shot exploration
|
.venv/bin/webber-cli chat # Interactive mode (Task agent, full tools)
|
||||||
.venv/bin/webber-cli explore "query" --no-stream # Batch mode
|
.venv/bin/webber-cli chat --mode plan # Read-only mode (safe exploration)
|
||||||
.venv/bin/webber-cli chat # Interactive mode
|
.venv/bin/webber-cli chat --mode auto_accept # No approval prompts (use with caution)
|
||||||
|
|
||||||
# API endpoints
|
# API endpoints
|
||||||
curl http://localhost:8095/health
|
curl http://localhost:8095/health
|
||||||
@@ -205,10 +237,26 @@ curl -X POST http://localhost:8095/agents/run \
|
|||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"agent_type":"explore","prompt":"list python files","working_dir":"."}'
|
-d '{"agent_type":"explore","prompt":"list python files","working_dir":"."}'
|
||||||
|
|
||||||
|
# Plan agent (read-only, creates implementation plans)
|
||||||
|
curl -X POST http://localhost:8095/agents/run \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"agent_type":"plan","prompt":"plan how to add user auth","working_dir":"."}'
|
||||||
|
|
||||||
# Streaming endpoint
|
# Streaming endpoint
|
||||||
curl -N http://localhost:8095/agents/stream \
|
curl -N http://localhost:8095/agents/stream \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"agent_type":"explore","prompt":"find config files","working_dir":"."}'
|
-d '{"agent_type":"explore","prompt":"find config files","working_dir":"."}'
|
||||||
|
|
||||||
|
# Conversation API (stateful multi-turn)
|
||||||
|
curl -X POST http://localhost:8095/conversations/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-API-Key: dev-key" \
|
||||||
|
-d '{"agent_type":"explore","working_dir":"."}'
|
||||||
|
|
||||||
|
curl -X POST http://localhost:8095/conversations/{id}/messages \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-API-Key: dev-key" \
|
||||||
|
-d '{"content":"find all Python files"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Webber is a FastAPI-based agent orchestration service that provides:
|
|||||||
**Port:** 8086
|
**Port:** 8086
|
||||||
**Runtime:** Python 3.12, FastAPI, Uvicorn
|
**Runtime:** Python 3.12, FastAPI, Uvicorn
|
||||||
**Agent Framework:** PydanticAI
|
**Agent Framework:** PydanticAI
|
||||||
**Default LLM:** Ollama with mistral-nemo-large:latest
|
**Default LLM:** Ollama with gemma4:e2b
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -24,18 +24,30 @@ webber/
|
|||||||
├── src/
|
├── src/
|
||||||
│ ├── main.py # App entry point (NO routes)
|
│ ├── main.py # App entry point (NO routes)
|
||||||
│ │
|
│ │
|
||||||
|
│ ├── db/ # Database layer
|
||||||
|
│ │ ├── __init__.py # Exports: Database, get_database, get_session
|
||||||
|
│ │ ├── database.py # SQLAlchemy async engine, session factory
|
||||||
|
│ │ └── models.py # Base declarative model
|
||||||
|
│ │
|
||||||
│ ├── shared/ # Cross-cutting concerns
|
│ ├── shared/ # Cross-cutting concerns
|
||||||
│ │ ├── base.py # BaseController, BaseSchema
|
│ │ ├── base.py # BaseController, BaseSchema
|
||||||
│ │ ├── config.py # Pydantic Settings
|
│ │ ├── config.py # Pydantic Settings
|
||||||
│ │ ├── logging.py # @logged decorator, trace_span
|
│ │ ├── logging.py # @logged decorator, trace_span
|
||||||
│ │ ├── exceptions.py # Custom exception hierarchy
|
│ │ ├── exceptions.py # Custom exception hierarchy
|
||||||
│ │ ├── auth.py # API key validation
|
│ │ ├── auth.py # API key validation
|
||||||
│ │ └── context.py # UserProvider singleton
|
│ │ ├── context.py # UserProvider singleton
|
||||||
|
│ │ └── tokens.py # Token counting utilities (litellm)
|
||||||
│ │
|
│ │
|
||||||
│ └── domains/ # Feature domains
|
│ └── domains/ # Feature domains
|
||||||
│ ├── router.py # Root router (composes all)
|
│ ├── router.py # Root router (composes all)
|
||||||
│ ├── health/ # Health endpoints
|
│ ├── health/ # Health endpoints
|
||||||
│ ├── auth/ # Authentication
|
│ ├── auth/ # Authentication
|
||||||
|
│ ├── conversations/ # Multi-turn conversation memory
|
||||||
|
│ │ ├── models.py # Conversation, Message SQLAlchemy models
|
||||||
|
│ │ ├── schemas.py # Pydantic request/response models
|
||||||
|
│ │ ├── service.py # ConversationService business logic
|
||||||
|
│ │ ├── router.py # REST API endpoints
|
||||||
|
│ │ └── summarize.py # Context summarization logic
|
||||||
│ ├── agents/ # Agent orchestration
|
│ ├── agents/ # Agent orchestration
|
||||||
│ │ ├── explore/ # Codebase navigation
|
│ │ ├── explore/ # Codebase navigation
|
||||||
│ │ ├── plan/ # Implementation design
|
│ │ ├── plan/ # Implementation design
|
||||||
@@ -193,14 +205,23 @@ All settings via environment variables or `.env`:
|
|||||||
| HOST | 0.0.0.0 | Server host |
|
| HOST | 0.0.0.0 | Server host |
|
||||||
| PORT | 8086 | Server port |
|
| PORT | 8086 | Server port |
|
||||||
| OLLAMA_URL | http://192.168.86.149:11434 | Ollama API URL |
|
| OLLAMA_URL | http://192.168.86.149:11434 | Ollama API URL |
|
||||||
| OLLAMA_AGENT_MODEL | mistral-nemo-large:latest | Agent reasoning model |
|
| OLLAMA_AGENT_MODEL | gemma4:e2b | Agent reasoning model |
|
||||||
| OLLAMA_EMBED_MODEL | nomic-embed-text:latest | Embedding model |
|
| OLLAMA_EMBED_MODEL | nomic-embed-text:latest | Embedding model |
|
||||||
| TATLOCK_API_URL | http://192.168.86.149:8000 | Tatlock auth service |
|
| TATLOCK_API_URL | http://tatlock:8000 | Tatlock auth service |
|
||||||
|
| SEARXNG_URL | http://searxng:8080 | SearXNG web search instance |
|
||||||
|
| SEARXNG_TIMEOUT | 10 | SearXNG request timeout (seconds) |
|
||||||
| TOOL_TIMEOUT_SECONDS | 120 | Tool execution timeout |
|
| TOOL_TIMEOUT_SECONDS | 120 | Tool execution timeout |
|
||||||
| SANDBOX_ENABLED | true | Enable sandboxed execution |
|
| SANDBOX_ENABLED | true | Enable sandboxed execution |
|
||||||
| ALLOWED_PATHS | [] | Paths accessible to tools |
|
| ALLOWED_PATHS | [] | Paths accessible to tools |
|
||||||
| SESSION_TTL_HOURS | 24 | Session expiry |
|
| SESSION_TTL_HOURS | 24 | Session expiry |
|
||||||
| MAX_CONTEXT_TOKENS | 128000 | Max context window |
|
| MAX_CONTEXT_TOKENS | 128000 | Max context window |
|
||||||
|
| DATABASE_URL | sqlite+aiosqlite:///./webber.db | Database connection URL |
|
||||||
|
| SUMMARIZATION_THRESHOLD | 0.8 | Summarize at N% of max tokens |
|
||||||
|
| SUMMARIZATION_TARGET_TOKENS | 500 | Target summary size |
|
||||||
|
| KEEP_RECENT_MESSAGES | 6 | Messages to keep unsummarized |
|
||||||
|
| RETRY_MAX_ATTEMPTS | 3 | Max retry attempts for transient failures |
|
||||||
|
| RETRY_BASE_DELAY | 1.0 | Base delay between retries (seconds) |
|
||||||
|
| RETRY_MAX_DELAY | 30.0 | Maximum delay between retries (seconds) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -250,6 +271,53 @@ Tools are sandboxed operations agents can invoke:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Database Layer
|
||||||
|
|
||||||
|
SQLAlchemy 2.0 async with lazy initialization pattern.
|
||||||
|
|
||||||
|
### Supported Databases
|
||||||
|
- **Development**: SQLite via `aiosqlite`
|
||||||
|
- **Production**: PostgreSQL via `asyncpg`
|
||||||
|
|
||||||
|
### Pattern
|
||||||
|
```python
|
||||||
|
from src.db import get_session
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
async def my_endpoint(session: AsyncSession = Depends(get_session)):
|
||||||
|
# Session auto-commits on success, rollbacks on exception
|
||||||
|
result = await session.execute(query)
|
||||||
|
```
|
||||||
|
|
||||||
|
Tables are created lazily on first `get_session()` call.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conversation API
|
||||||
|
|
||||||
|
Multi-turn conversation memory with automatic context summarization.
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/conversations/` | POST | Create new conversation |
|
||||||
|
| `/conversations/` | GET | List user's conversations |
|
||||||
|
| `/conversations/{id}` | GET | Get conversation with history |
|
||||||
|
| `/conversations/{id}/messages` | POST | Add message, triggers agent |
|
||||||
|
| `/conversations/{id}` | DELETE | Delete conversation |
|
||||||
|
|
||||||
|
### Models
|
||||||
|
- **Conversation**: User session with agent type, working directory
|
||||||
|
- **Message**: Individual messages with role, content, token count
|
||||||
|
|
||||||
|
### Context Summarization
|
||||||
|
When total tokens exceed 80% of `MAX_CONTEXT_TOKENS`:
|
||||||
|
1. Keep last 6 messages intact
|
||||||
|
2. Summarize older messages into a single summary message
|
||||||
|
3. Mark old messages as summarized (soft delete)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Authentication Flow
|
## Authentication Flow
|
||||||
|
|
||||||
1. Client sends `X-API-Key` header
|
1. Client sends `X-API-Key` header
|
||||||
@@ -291,7 +359,7 @@ Gitea Actions workflow:
|
|||||||
|
|
||||||
Deployed in Portainer `agents` stack alongside Tatlock:
|
Deployed in Portainer `agents` stack alongside Tatlock:
|
||||||
- Network: `docker-dataplane`
|
- Network: `docker-dataplane`
|
||||||
- Registry: `git.schweitz.internal/jpmschweitzer/webber`
|
- Registry: `git.schweitz.net/jpmschweitzer/webber`
|
||||||
- Auto-update: Watchtower with label `com.centurylinklabs.watchtower.enable=true`
|
- Auto-update: Watchtower with label `com.centurylinklabs.watchtower.enable=true`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "webber-api"
|
name = "webber-api"
|
||||||
version = "0.3.1"
|
version = "1.1.0"
|
||||||
description = "Webber API - Multi-Agent AI Development Server"
|
description = "Webber API - Multi-Agent AI Development Server"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "jpmschweitzer"}
|
{name = "jpmschweitzer"}
|
||||||
@@ -27,7 +27,7 @@ include = ["src*"]
|
|||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
python_files = ["test_*.py"]
|
python_files = ["test_*.py"]
|
||||||
python_functions = ["test_*"]
|
python_functions = ["test_*"]
|
||||||
addopts = "-v --strict-markers"
|
addopts = "-q --strict-markers --tb=short"
|
||||||
markers = [
|
markers = [
|
||||||
"integration: marks tests as integration tests (require Ollama to be running)",
|
"integration: marks tests as integration tests (require Ollama to be running)",
|
||||||
"e2e: marks tests as end-to-end tests (require API server to be running)",
|
"e2e: marks tests as end-to-end tests (require API server to be running)",
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ pip-audit~=2.9.0
|
|||||||
|
|
||||||
# Type checking
|
# Type checking
|
||||||
mypy~=1.19.1
|
mypy~=1.19.1
|
||||||
|
# Stubs for aiofiles, which ships none. Without them mypy reports
|
||||||
|
# import-untyped on every module that reads or writes a file.
|
||||||
|
types-aiofiles~=25.1
|
||||||
|
|
||||||
# Linting and formatting
|
# Linting and formatting
|
||||||
ruff~=0.9.4
|
ruff~=0.9.4
|
||||||
|
|||||||
@@ -25,3 +25,10 @@ rich~=13.9.0
|
|||||||
python-multipart~=0.0.21
|
python-multipart~=0.0.21
|
||||||
python-dotenv~=1.2.1
|
python-dotenv~=1.2.1
|
||||||
pathspec~=0.12.1 # Gitignore pattern matching
|
pathspec~=0.12.1 # Gitignore pattern matching
|
||||||
|
|
||||||
|
# Database
|
||||||
|
sqlalchemy[asyncio]~=2.0.36
|
||||||
|
aiosqlite~=0.21.0 # SQLite async driver (dev)
|
||||||
|
|
||||||
|
# Token counting
|
||||||
|
tiktoken>=0.12.0 # OpenAI tokenizer (used for estimation)
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ from pathlib import Path
|
|||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
|
from src.cli.session.loop import AgenticLoop
|
||||||
from src.cli.theme import get_theme
|
from src.cli.theme import get_theme
|
||||||
from src.cli.ui.console import get_console
|
from src.cli.ui.console import get_console
|
||||||
from src.cli.session.loop import AgenticLoop
|
|
||||||
from src.shared.logging import setup_logging
|
from src.shared.logging import setup_logging
|
||||||
|
|
||||||
console = get_console()
|
console = get_console()
|
||||||
|
|||||||
@@ -53,11 +53,17 @@ def main(
|
|||||||
|
|
||||||
|
|
||||||
# Import and register commands
|
# Import and register commands
|
||||||
from src.cli.commands import chat, explore, version # noqa: E402, F401
|
from src.cli.commands import chat, explore, version # noqa: E402
|
||||||
|
|
||||||
# Register subcommands
|
# Register subcommands
|
||||||
app.command(name="chat")(chat.chat_command)
|
app.command(name="chat")(chat.chat_command)
|
||||||
app.command(name="explore")(explore.explore_command)
|
app.command(name="explore")(explore.explore_command)
|
||||||
|
# version was imported and never registered, so `webber version` did not exist.
|
||||||
|
# The --version flag above is the terse form; show_version prints the panel with
|
||||||
|
# the resolved Ollama URL, model and debug state, which is the one worth having
|
||||||
|
# when something is misconfigured. The F401 suppression on the import was what
|
||||||
|
# kept the omission quiet.
|
||||||
|
app.command(name="version")(version.show_version)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ Session management for CLI.
|
|||||||
from src.cli.session.context import SessionState
|
from src.cli.session.context import SessionState
|
||||||
from src.cli.session.loop import AgenticLoop
|
from src.cli.session.loop import AgenticLoop
|
||||||
|
|
||||||
__all__ = ["SessionState", "AgenticLoop"]
|
__all__ = ["AgenticLoop", "SessionState"]
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
Agentic conversation loop for interactive CLI.
|
Agentic conversation loop for interactive CLI.
|
||||||
"""
|
"""
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
from src.cli.session.context import SessionState
|
from src.cli.session.context import SessionState
|
||||||
from src.cli.ui.display import format_response
|
from src.cli.ui.display import format_response
|
||||||
from src.domains.agents.base import BaseAgent
|
from src.domains.agents.base import BaseAgent
|
||||||
from src.shared.logging import logged, trace_span, get_logger
|
from src.shared.logging import get_logger, logged, trace_span
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ CLI theme configuration.
|
|||||||
Centralized color and style definitions for the Webber CLI.
|
Centralized color and style definitions for the Webber CLI.
|
||||||
All color choices should be defined here for easy customization.
|
All color choices should be defined here for easy customization.
|
||||||
"""
|
"""
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -36,7 +36,7 @@ class ThemeColors:
|
|||||||
class ThemeConfig:
|
class ThemeConfig:
|
||||||
"""Complete theme configuration."""
|
"""Complete theme configuration."""
|
||||||
|
|
||||||
colors: ThemeColors = ThemeColors()
|
colors: ThemeColors = field(default_factory=ThemeColors)
|
||||||
|
|
||||||
# Spinner style for loading indicators
|
# Spinner style for loading indicators
|
||||||
spinner: str = "dots"
|
spinner: str = "dots"
|
||||||
|
|||||||
@@ -2,6 +2,6 @@
|
|||||||
CLI UI components.
|
CLI UI components.
|
||||||
"""
|
"""
|
||||||
from src.cli.ui.console import get_console
|
from src.cli.ui.console import get_console
|
||||||
from src.cli.ui.display import format_response, format_code
|
from src.cli.ui.display import format_code, format_response
|
||||||
|
|
||||||
__all__ = ["get_console", "format_response", "format_code"]
|
__all__ = ["format_code", "format_response", "get_console"]
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from rich.syntax import Syntax
|
|||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from src.cli.theme import get_theme
|
from src.cli.theme import get_theme
|
||||||
from src.cli.ui.console import get_console
|
|
||||||
|
|
||||||
|
|
||||||
def format_response(text: str) -> Markdown | Text:
|
def format_response(text: str) -> Markdown | Text:
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""
|
||||||
|
Database package for Webber.
|
||||||
|
|
||||||
|
Provides async SQLAlchemy database access following core-api patterns.
|
||||||
|
"""
|
||||||
|
from src.db.database import Database, get_database, get_session
|
||||||
|
from src.db.models import Base
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Base",
|
||||||
|
"Database",
|
||||||
|
"get_database",
|
||||||
|
"get_session",
|
||||||
|
]
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""
|
||||||
|
Async SQLAlchemy database management.
|
||||||
|
|
||||||
|
Pattern from core-api: singleton Database class with async session factory.
|
||||||
|
"""
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncEngine,
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
from src.shared.config import get_settings
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
"""
|
||||||
|
Async database connection manager.
|
||||||
|
|
||||||
|
Manages SQLAlchemy async engine and session factory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, url: str):
|
||||||
|
"""
|
||||||
|
Initialize database with connection URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: SQLAlchemy async connection URL
|
||||||
|
e.g., "sqlite+aiosqlite:///./webber.db"
|
||||||
|
or "postgresql+asyncpg://user:pass@host/db"
|
||||||
|
"""
|
||||||
|
self._url = url
|
||||||
|
self._engine: AsyncEngine | None = None
|
||||||
|
self._session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def engine(self) -> AsyncEngine:
|
||||||
|
"""Get or create the async engine."""
|
||||||
|
if self._engine is None:
|
||||||
|
self._engine = create_async_engine(
|
||||||
|
self._url,
|
||||||
|
echo=get_settings().debug,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
)
|
||||||
|
return self._engine
|
||||||
|
|
||||||
|
@property
|
||||||
|
def session_factory(self) -> async_sessionmaker[AsyncSession]:
|
||||||
|
"""Get or create the session factory."""
|
||||||
|
if self._session_factory is None:
|
||||||
|
self._session_factory = async_sessionmaker(
|
||||||
|
bind=self.engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
autoflush=False,
|
||||||
|
)
|
||||||
|
return self._session_factory
|
||||||
|
|
||||||
|
async def create_tables(self) -> None:
|
||||||
|
"""Create all tables (for development)."""
|
||||||
|
from src.db.models import Base
|
||||||
|
|
||||||
|
async with self.engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
logger.info("Database tables created")
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the database connection."""
|
||||||
|
if self._engine:
|
||||||
|
await self._engine.dispose()
|
||||||
|
self._engine = None
|
||||||
|
self._session_factory = None
|
||||||
|
logger.info("Database connection closed")
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
_database: Database | None = None
|
||||||
|
_tables_created: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_database() -> Database:
|
||||||
|
"""Get the database singleton."""
|
||||||
|
global _database
|
||||||
|
if _database is None:
|
||||||
|
settings = get_settings()
|
||||||
|
_database = Database(settings.database_url)
|
||||||
|
return _database
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_tables() -> None:
|
||||||
|
"""Ensure database tables exist (lazy initialization)."""
|
||||||
|
global _tables_created
|
||||||
|
if not _tables_created:
|
||||||
|
database = get_database()
|
||||||
|
await database.create_tables()
|
||||||
|
_tables_created = True
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""
|
||||||
|
Dependency for getting async database sessions.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@router.get("/")
|
||||||
|
async def endpoint(session: AsyncSession = Depends(get_session)):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
await _ensure_tables()
|
||||||
|
database = get_database()
|
||||||
|
async with database.session_factory() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""
|
||||||
|
SQLAlchemy Base model for all database models.
|
||||||
|
"""
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""Base class for all SQLAlchemy models."""
|
||||||
|
pass
|
||||||
@@ -4,34 +4,34 @@ Agent implementations.
|
|||||||
All agents inherit from BaseAgent and are registered in the global registry.
|
All agents inherit from BaseAgent and are registered in the global registry.
|
||||||
"""
|
"""
|
||||||
from src.domains.agents.base import (
|
from src.domains.agents.base import (
|
||||||
BaseAgent,
|
|
||||||
AgentContext,
|
AgentContext,
|
||||||
AgentProtocol,
|
AgentProtocol,
|
||||||
register_agent,
|
BaseAgent,
|
||||||
get_agent,
|
get_agent,
|
||||||
list_agents,
|
|
||||||
get_registry,
|
get_registry,
|
||||||
|
list_agents,
|
||||||
|
register_agent,
|
||||||
)
|
)
|
||||||
from src.domains.agents.explore import (
|
from src.domains.agents.explore import (
|
||||||
ExploreAgentImpl,
|
ExploreAgentImpl,
|
||||||
ExploreContext,
|
ExploreContext,
|
||||||
explore_agent,
|
|
||||||
explore,
|
explore,
|
||||||
|
explore_agent,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Base classes
|
|
||||||
"BaseAgent",
|
|
||||||
"AgentContext",
|
"AgentContext",
|
||||||
"AgentProtocol",
|
"AgentProtocol",
|
||||||
# Registry functions
|
# Base classes
|
||||||
"register_agent",
|
"BaseAgent",
|
||||||
"get_agent",
|
|
||||||
"list_agents",
|
|
||||||
"get_registry",
|
|
||||||
# Explore agent
|
# Explore agent
|
||||||
"ExploreAgentImpl",
|
"ExploreAgentImpl",
|
||||||
"ExploreContext",
|
"ExploreContext",
|
||||||
"explore_agent",
|
|
||||||
"explore",
|
"explore",
|
||||||
|
"explore_agent",
|
||||||
|
"get_agent",
|
||||||
|
"get_registry",
|
||||||
|
"list_agents",
|
||||||
|
# Registry functions
|
||||||
|
"register_agent",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""
|
||||||
|
Tool approval evaluation logic.
|
||||||
|
|
||||||
|
Provides granular control over tool execution:
|
||||||
|
- Rule-based matching on tool name and arguments
|
||||||
|
- Priority-ordered rule evaluation
|
||||||
|
- Default fallback behavior
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.domains.agents.schemas import (
|
||||||
|
ApprovalAction,
|
||||||
|
ApprovalRule,
|
||||||
|
ApprovalRuleSet,
|
||||||
|
PermissionMode,
|
||||||
|
)
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_tool_args(tool_args: dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
Serialize tool arguments to a string for pattern matching.
|
||||||
|
|
||||||
|
Converts tool args dict to a consistent string format that can be
|
||||||
|
matched against regex patterns.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
{"command": "curl localhost:8095"} -> "command=curl localhost:8095"
|
||||||
|
{"file_path": "/src/main.py"} -> "file_path=/src/main.py"
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
for key, value in sorted(tool_args.items()):
|
||||||
|
parts.append(f"{key}={value}")
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_rule(rule: ApprovalRule, tool_name: str, tool_args: dict[str, Any]) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a rule matches the given tool call.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rule: The approval rule to evaluate
|
||||||
|
tool_name: Name of the tool being called
|
||||||
|
tool_args: Arguments passed to the tool
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the rule matches, False otherwise
|
||||||
|
"""
|
||||||
|
# Tool name must match exactly
|
||||||
|
if rule.tool != tool_name and rule.tool != "*":
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Serialize args for pattern matching
|
||||||
|
args_str = _serialize_tool_args(tool_args)
|
||||||
|
|
||||||
|
# Try to match pattern against serialized args
|
||||||
|
try:
|
||||||
|
if re.search(rule.pattern, args_str, re.IGNORECASE):
|
||||||
|
return True
|
||||||
|
except re.error as e:
|
||||||
|
logger.warning(f"Invalid regex pattern in rule: {rule.pattern} - {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_approval(
|
||||||
|
ruleset: ApprovalRuleSet,
|
||||||
|
tool_name: str,
|
||||||
|
tool_args: dict[str, Any],
|
||||||
|
mode: PermissionMode = PermissionMode.default,
|
||||||
|
) -> ApprovalAction:
|
||||||
|
"""
|
||||||
|
Evaluate whether a tool call should be allowed, denied, or prompt for approval.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ruleset: Set of approval rules to evaluate
|
||||||
|
tool_name: Name of the tool being called
|
||||||
|
tool_args: Arguments passed to the tool
|
||||||
|
mode: Current permission mode
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ApprovalAction indicating what to do (allow, deny, ask)
|
||||||
|
"""
|
||||||
|
# Plan mode: only read-only tools are even registered, so if we get here
|
||||||
|
# it's a read-only tool and should be allowed
|
||||||
|
if mode == PermissionMode.plan:
|
||||||
|
return ApprovalAction.allow
|
||||||
|
|
||||||
|
# Auto-accept mode: allow everything without prompting
|
||||||
|
if mode == PermissionMode.auto_accept:
|
||||||
|
return ApprovalAction.allow
|
||||||
|
|
||||||
|
# Default mode: evaluate rules
|
||||||
|
# Sort rules by priority (highest first)
|
||||||
|
sorted_rules = sorted(ruleset.rules, key=lambda r: r.priority, reverse=True)
|
||||||
|
|
||||||
|
for rule in sorted_rules:
|
||||||
|
if evaluate_rule(rule, tool_name, tool_args):
|
||||||
|
logger.debug(
|
||||||
|
f"Rule matched: {rule.description or rule.pattern} -> {rule.action}"
|
||||||
|
)
|
||||||
|
return rule.action
|
||||||
|
|
||||||
|
# No rules matched, use default action
|
||||||
|
return ruleset.default_action
|
||||||
|
|
||||||
|
|
||||||
|
# === Default rule sets ===
|
||||||
|
|
||||||
|
# Read-only tools that never need approval
|
||||||
|
READONLY_TOOLS = {"read_file", "glob_files", "grep_content", "bash_readonly"}
|
||||||
|
|
||||||
|
# Default rules for common patterns
|
||||||
|
DEFAULT_RULES = ApprovalRuleSet(
|
||||||
|
rules=[
|
||||||
|
# Always allow read-only tools
|
||||||
|
ApprovalRule(
|
||||||
|
tool="read_file",
|
||||||
|
pattern=".*",
|
||||||
|
action=ApprovalAction.allow,
|
||||||
|
description="Allow all file reads",
|
||||||
|
priority=100,
|
||||||
|
),
|
||||||
|
ApprovalRule(
|
||||||
|
tool="glob_files",
|
||||||
|
pattern=".*",
|
||||||
|
action=ApprovalAction.allow,
|
||||||
|
description="Allow all glob searches",
|
||||||
|
priority=100,
|
||||||
|
),
|
||||||
|
ApprovalRule(
|
||||||
|
tool="grep_content",
|
||||||
|
pattern=".*",
|
||||||
|
action=ApprovalAction.allow,
|
||||||
|
description="Allow all grep searches",
|
||||||
|
priority=100,
|
||||||
|
),
|
||||||
|
ApprovalRule(
|
||||||
|
tool="bash_readonly",
|
||||||
|
pattern=".*",
|
||||||
|
action=ApprovalAction.allow,
|
||||||
|
description="Allow all read-only bash commands",
|
||||||
|
priority=100,
|
||||||
|
),
|
||||||
|
# Dangerous patterns - always deny
|
||||||
|
ApprovalRule(
|
||||||
|
tool="bash",
|
||||||
|
pattern="rm\\s+-rf\\s+/",
|
||||||
|
action=ApprovalAction.deny,
|
||||||
|
description="Deny recursive delete from root",
|
||||||
|
priority=90,
|
||||||
|
),
|
||||||
|
ApprovalRule(
|
||||||
|
tool="bash",
|
||||||
|
pattern="sudo\\s+",
|
||||||
|
action=ApprovalAction.deny,
|
||||||
|
description="Deny sudo commands",
|
||||||
|
priority=90,
|
||||||
|
),
|
||||||
|
# Common safe patterns - allow without prompting
|
||||||
|
ApprovalRule(
|
||||||
|
tool="bash",
|
||||||
|
pattern="command=git\\s+(status|log|diff|show|branch)",
|
||||||
|
action=ApprovalAction.allow,
|
||||||
|
description="Allow read-only git commands",
|
||||||
|
priority=50,
|
||||||
|
),
|
||||||
|
ApprovalRule(
|
||||||
|
tool="bash",
|
||||||
|
pattern="command=pytest\\s+",
|
||||||
|
action=ApprovalAction.allow,
|
||||||
|
description="Allow pytest execution",
|
||||||
|
priority=50,
|
||||||
|
),
|
||||||
|
ApprovalRule(
|
||||||
|
tool="bash",
|
||||||
|
pattern="command=python\\s+-m\\s+pytest",
|
||||||
|
action=ApprovalAction.allow,
|
||||||
|
description="Allow pytest via python -m",
|
||||||
|
priority=50,
|
||||||
|
),
|
||||||
|
ApprovalRule(
|
||||||
|
tool="bash",
|
||||||
|
pattern="command=curl.*localhost",
|
||||||
|
action=ApprovalAction.allow,
|
||||||
|
description="Allow curl to localhost",
|
||||||
|
priority=50,
|
||||||
|
),
|
||||||
|
ApprovalRule(
|
||||||
|
tool="bash",
|
||||||
|
pattern="command=curl.*127\\.0\\.0\\.1",
|
||||||
|
action=ApprovalAction.allow,
|
||||||
|
description="Allow curl to 127.0.0.1",
|
||||||
|
priority=50,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
default_action=ApprovalAction.ask,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_ruleset() -> ApprovalRuleSet:
|
||||||
|
"""Get the default approval ruleset."""
|
||||||
|
return DEFAULT_RULES
|
||||||
|
|
||||||
|
|
||||||
|
def is_readonly_tool(tool_name: str) -> bool:
|
||||||
|
"""Check if a tool is read-only (never needs approval)."""
|
||||||
|
return tool_name in READONLY_TOOLS
|
||||||
@@ -6,10 +6,11 @@ All agents are built on PydanticAI and registered in a central registry.
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Protocol, runtime_checkable
|
from typing import Any, Generic, Protocol, TypeVar, runtime_checkable
|
||||||
|
|
||||||
from pydantic_ai import Agent
|
from pydantic_ai import Agent
|
||||||
|
|
||||||
|
from src.domains.agents.schemas import StreamEvent
|
||||||
from src.shared.logging import get_logger
|
from src.shared.logging import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -27,6 +28,15 @@ class AgentContext:
|
|||||||
timeout_seconds: int = 120
|
timeout_seconds: int = 120
|
||||||
|
|
||||||
|
|
||||||
|
# Every agent narrows the context its tools receive — ExploreContext,
|
||||||
|
# PlanContext, TaskContext. Without this parameter BaseAgent could only say
|
||||||
|
# `Agent`, which is `Agent[Any, Any]`, and pydantic_ai then types every
|
||||||
|
# `.run()` result as Any. That is where 35 of this package's mypy errors came
|
||||||
|
# from: functions declared `-> str` returning Any, each looking like a local
|
||||||
|
# annotation slip rather than one missing type parameter in the base class.
|
||||||
|
CtxT = TypeVar("CtxT", bound=AgentContext)
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class AgentProtocol(Protocol):
|
class AgentProtocol(Protocol):
|
||||||
"""Protocol that all agents must implement."""
|
"""Protocol that all agents must implement."""
|
||||||
@@ -60,7 +70,7 @@ class AgentProtocol(Protocol):
|
|||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
class BaseAgent(ABC):
|
class BaseAgent(ABC, Generic[CtxT]):
|
||||||
"""
|
"""
|
||||||
Abstract base class for agent implementations.
|
Abstract base class for agent implementations.
|
||||||
|
|
||||||
@@ -71,7 +81,8 @@ class BaseAgent(ABC):
|
|||||||
name = "explore"
|
name = "explore"
|
||||||
description = "Fast codebase exploration"
|
description = "Fast codebase exploration"
|
||||||
|
|
||||||
def _create_agent(self) -> Agent:
|
class ExploreAgent(BaseAgent[ExploreContext]):
|
||||||
|
def _create_agent(self) -> Agent[ExploreContext, str]:
|
||||||
# Create and configure PydanticAI agent
|
# Create and configure PydanticAI agent
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -92,15 +103,21 @@ class BaseAgent(ABC):
|
|||||||
"""Human-readable description."""
|
"""Human-readable description."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Declared on the base rather than only in each subclass's __init__. The
|
||||||
|
# base reached it through hasattr, so mypy could not determine its type at
|
||||||
|
# all; the guard existed because nothing guaranteed the attribute existed.
|
||||||
|
# Declaring it here makes the None check sufficient.
|
||||||
|
_agent: "Agent[CtxT, str] | None" = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def agent(self) -> Agent:
|
def agent(self) -> "Agent[CtxT, str]":
|
||||||
"""Lazy-loaded PydanticAI agent."""
|
"""Lazy-loaded PydanticAI agent."""
|
||||||
if not hasattr(self, '_agent') or self._agent is None:
|
if self._agent is None:
|
||||||
self._agent = self._create_agent()
|
self._agent = self._create_agent()
|
||||||
return self._agent
|
return self._agent
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def _create_agent(self) -> Agent:
|
def _create_agent(self) -> "Agent[CtxT, str]":
|
||||||
"""
|
"""
|
||||||
Create and configure the PydanticAI agent.
|
Create and configure the PydanticAI agent.
|
||||||
|
|
||||||
@@ -115,7 +132,7 @@ class BaseAgent(ABC):
|
|||||||
|
|
||||||
async def run_stream(
|
async def run_stream(
|
||||||
self, prompt: str, **kwargs: Any
|
self, prompt: str, **kwargs: Any
|
||||||
) -> AsyncIterator[str]:
|
) -> AsyncIterator[str | StreamEvent]:
|
||||||
"""
|
"""
|
||||||
Execute the agent with streaming output.
|
Execute the agent with streaming output.
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ Explore Agent - Fast codebase exploration.
|
|||||||
from src.domains.agents.explore.agent import (
|
from src.domains.agents.explore.agent import (
|
||||||
ExploreAgentImpl,
|
ExploreAgentImpl,
|
||||||
ExploreContext,
|
ExploreContext,
|
||||||
explore_agent,
|
|
||||||
explore,
|
explore,
|
||||||
|
explore_agent,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ExploreAgentImpl",
|
"ExploreAgentImpl",
|
||||||
"ExploreContext",
|
"ExploreContext",
|
||||||
"explore_agent",
|
|
||||||
"explore",
|
"explore",
|
||||||
|
"explore_agent",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ from typing import Any
|
|||||||
from pydantic_ai import Agent
|
from pydantic_ai import Agent
|
||||||
from pydantic_ai.models.openai import OpenAIModel
|
from pydantic_ai.models.openai import OpenAIModel
|
||||||
|
|
||||||
from src.domains.agents.base import BaseAgent, AgentContext, register_agent
|
from src.domains.agents.base import AgentContext, BaseAgent, register_agent
|
||||||
from src.domains.agents.explore.prompts import EXPLORE_SYSTEM_PROMPT
|
from src.domains.agents.explore.prompts import EXPLORE_SYSTEM_PROMPT
|
||||||
from src.ollama.provider import get_ollama_provider
|
from src.ollama.provider import get_ollama_provider
|
||||||
from src.shared.config import get_settings
|
from src.shared.config import get_settings
|
||||||
from src.shared.logging import logged, get_logger, trace_span
|
from src.shared.logging import get_logger, logged, trace_span
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ class ExploreContext(AgentContext):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ExploreAgentImpl(BaseAgent):
|
class ExploreAgentImpl(BaseAgent[ExploreContext]):
|
||||||
"""
|
"""
|
||||||
Fast codebase exploration agent.
|
Fast codebase exploration agent.
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ class ExploreAgentImpl(BaseAgent):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize the explore agent."""
|
"""Initialize the explore agent."""
|
||||||
self._agent: Agent[ExploreContext, str] | None = None
|
self._agent = None
|
||||||
self._settings = get_settings()
|
self._settings = get_settings()
|
||||||
|
|
||||||
def _create_agent(self) -> Agent[ExploreContext, str]:
|
def _create_agent(self) -> Agent[ExploreContext, str]:
|
||||||
@@ -79,6 +79,14 @@ class ExploreAgentImpl(BaseAgent):
|
|||||||
from src.domains.agents.explore.tools import register_explore_tools
|
from src.domains.agents.explore.tools import register_explore_tools
|
||||||
register_explore_tools(agent)
|
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()
|
@logged()
|
||||||
async def run(
|
async def run(
|
||||||
self,
|
self,
|
||||||
@@ -98,16 +106,20 @@ class ExploreAgentImpl(BaseAgent):
|
|||||||
Returns:
|
Returns:
|
||||||
Agent response with findings
|
Agent response with findings
|
||||||
"""
|
"""
|
||||||
|
effective_working_dir = working_dir or os.getcwd()
|
||||||
|
|
||||||
ctx = ExploreContext(
|
ctx = ExploreContext(
|
||||||
working_dir=working_dir or os.getcwd(),
|
working_dir=effective_working_dir,
|
||||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||||
timeout_seconds=self._settings.tool_timeout_seconds,
|
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"):
|
async with trace_span("explore_agent_run"):
|
||||||
try:
|
try:
|
||||||
# Use run() not run_stream() - Ollama has bugs with streaming + tools
|
# 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
|
return result.output
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Explore agent error: {e}")
|
logger.exception(f"Explore agent error: {e}")
|
||||||
@@ -126,15 +138,19 @@ class ExploreAgentImpl(BaseAgent):
|
|||||||
|
|
||||||
Yields text chunks as they become available.
|
Yields text chunks as they become available.
|
||||||
"""
|
"""
|
||||||
|
effective_working_dir = working_dir or os.getcwd()
|
||||||
|
|
||||||
ctx = ExploreContext(
|
ctx = ExploreContext(
|
||||||
working_dir=working_dir or os.getcwd(),
|
working_dir=effective_working_dir,
|
||||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||||
timeout_seconds=self._settings.tool_timeout_seconds,
|
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"):
|
async with trace_span("explore_agent_stream"):
|
||||||
try:
|
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():
|
async for chunk in result.stream_text():
|
||||||
yield chunk
|
yield chunk
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ Registers our tool implementations with the PydanticAI agent.
|
|||||||
"""
|
"""
|
||||||
from pydantic_ai import Agent, RunContext
|
from pydantic_ai import Agent, RunContext
|
||||||
|
|
||||||
from src.domains.agents.base import AgentContext
|
from src.domains.agents.explore.agent import ExploreContext
|
||||||
from src.domains.tools.file.read import ReadFileTool
|
|
||||||
from src.domains.tools.file.glob import GlobFilesTool
|
|
||||||
from src.domains.tools.file.edit import EditFileTool
|
from src.domains.tools.file.edit import EditFileTool
|
||||||
|
from src.domains.tools.file.glob import GlobFilesTool
|
||||||
|
from src.domains.tools.file.read import ReadFileTool
|
||||||
from src.domains.tools.file.write import WriteFileTool
|
from src.domains.tools.file.write import WriteFileTool
|
||||||
from src.domains.tools.search.grep import GrepContentTool
|
from src.domains.tools.search.grep import GrepContentTool
|
||||||
from src.domains.tools.search.web import WebSearchTool
|
from src.domains.tools.search.web import WebSearchTool
|
||||||
@@ -16,7 +16,7 @@ from src.domains.tools.shell.bash import BashReadOnlyTool
|
|||||||
from src.domains.tools.shell.bash_full import BashTool
|
from src.domains.tools.shell.bash_full import BashTool
|
||||||
|
|
||||||
|
|
||||||
def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
|
def register_explore_tools(agent: Agent[ExploreContext, str]) -> None:
|
||||||
"""
|
"""
|
||||||
Register all exploration tools with the agent.
|
Register all exploration tools with the agent.
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
|
|||||||
|
|
||||||
@agent.tool
|
@agent.tool
|
||||||
async def read_file(
|
async def read_file(
|
||||||
ctx: RunContext[AgentContext],
|
ctx: RunContext[ExploreContext],
|
||||||
file_path: str,
|
file_path: str,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
limit: int = 2000
|
limit: int = 2000
|
||||||
@@ -52,7 +52,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
|
|||||||
|
|
||||||
@agent.tool
|
@agent.tool
|
||||||
async def glob_files(
|
async def glob_files(
|
||||||
ctx: RunContext[AgentContext],
|
ctx: RunContext[ExploreContext],
|
||||||
pattern: str,
|
pattern: str,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
limit: int = 100
|
limit: int = 100
|
||||||
@@ -85,7 +85,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
|
|||||||
|
|
||||||
@agent.tool
|
@agent.tool
|
||||||
async def grep_content(
|
async def grep_content(
|
||||||
ctx: RunContext[AgentContext],
|
ctx: RunContext[ExploreContext],
|
||||||
pattern: str,
|
pattern: str,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
file_glob: str | None = None,
|
file_glob: str | None = None,
|
||||||
@@ -125,7 +125,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
|
|||||||
|
|
||||||
@agent.tool
|
@agent.tool
|
||||||
async def bash_readonly(
|
async def bash_readonly(
|
||||||
ctx: RunContext[AgentContext],
|
ctx: RunContext[ExploreContext],
|
||||||
command: str,
|
command: str,
|
||||||
cwd: str | None = None,
|
cwd: str | None = None,
|
||||||
timeout: int = 30
|
timeout: int = 30
|
||||||
@@ -170,7 +170,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
|
|||||||
|
|
||||||
@agent.tool
|
@agent.tool
|
||||||
async def edit_file(
|
async def edit_file(
|
||||||
ctx: RunContext[AgentContext],
|
ctx: RunContext[ExploreContext],
|
||||||
file_path: str,
|
file_path: str,
|
||||||
old_string: str,
|
old_string: str,
|
||||||
new_string: str,
|
new_string: str,
|
||||||
@@ -204,7 +204,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
|
|||||||
|
|
||||||
@agent.tool
|
@agent.tool
|
||||||
async def write_file(
|
async def write_file(
|
||||||
ctx: RunContext[AgentContext],
|
ctx: RunContext[ExploreContext],
|
||||||
file_path: str,
|
file_path: str,
|
||||||
content: str
|
content: str
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -231,7 +231,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
|
|||||||
|
|
||||||
@agent.tool
|
@agent.tool
|
||||||
async def bash(
|
async def bash(
|
||||||
ctx: RunContext[AgentContext],
|
ctx: RunContext[ExploreContext],
|
||||||
command: str,
|
command: str,
|
||||||
cwd: str | None = None,
|
cwd: str | None = None,
|
||||||
timeout: int = 60
|
timeout: int = 60
|
||||||
@@ -277,7 +277,7 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
|
|||||||
|
|
||||||
@agent.tool
|
@agent.tool
|
||||||
async def web_search(
|
async def web_search(
|
||||||
ctx: RunContext[AgentContext],
|
ctx: RunContext[ExploreContext],
|
||||||
query: str,
|
query: str,
|
||||||
num_results: int = 5,
|
num_results: int = 5,
|
||||||
categories: str | None = None
|
categories: str | None = None
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""
|
||||||
|
Plan Agent - Software architect for implementation planning.
|
||||||
|
|
||||||
|
The Plan agent explores codebases and designs step-by-step implementation
|
||||||
|
strategies. It uses only read-only tools and cannot modify any files.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from src.domains.agents.plan import plan_agent, plan
|
||||||
|
|
||||||
|
# Direct agent access
|
||||||
|
result = await plan_agent.run("Plan how to add user authentication")
|
||||||
|
|
||||||
|
# Convenience function
|
||||||
|
result = await plan("Plan how to add user authentication")
|
||||||
|
"""
|
||||||
|
from src.domains.agents.plan.agent import (
|
||||||
|
PlanAgentImpl,
|
||||||
|
PlanContext,
|
||||||
|
plan,
|
||||||
|
plan_agent,
|
||||||
|
plan_stream,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PlanAgentImpl",
|
||||||
|
"PlanContext",
|
||||||
|
"plan",
|
||||||
|
"plan_agent",
|
||||||
|
"plan_stream",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""
|
||||||
|
Plan Agent implementation using PydanticAI.
|
||||||
|
|
||||||
|
Software architect agent that explores codebases and designs implementation plans.
|
||||||
|
Uses only read-only tools - cannot modify any files.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic_ai import Agent
|
||||||
|
from pydantic_ai.models.openai import OpenAIModel
|
||||||
|
|
||||||
|
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 get_logger, logged, trace_span
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlanContext(AgentContext):
|
||||||
|
"""
|
||||||
|
Context for plan agent tools.
|
||||||
|
|
||||||
|
Passed to all tool functions via RunContext.
|
||||||
|
Uses the same fields as base AgentContext.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PlanAgentImpl(BaseAgent[PlanContext]):
|
||||||
|
"""
|
||||||
|
Software architect agent for implementation planning.
|
||||||
|
|
||||||
|
Explores codebases to understand patterns and conventions,
|
||||||
|
then designs step-by-step implementation plans.
|
||||||
|
|
||||||
|
READ-ONLY: Cannot modify files - uses only exploration tools.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "plan"
|
||||||
|
description = "Software architect for designing implementation plans - explores codebase and creates step-by-step strategies"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the plan agent."""
|
||||||
|
self._agent = None
|
||||||
|
self._settings = get_settings()
|
||||||
|
|
||||||
|
def _create_agent(self) -> Agent[PlanContext, str]:
|
||||||
|
"""Create the PydanticAI agent with Ollama backend."""
|
||||||
|
# Use sanitized Ollama provider to fix content: null issues
|
||||||
|
model = OpenAIModel(
|
||||||
|
model_name=self._settings.ollama_agent_model,
|
||||||
|
provider=get_ollama_provider(),
|
||||||
|
)
|
||||||
|
|
||||||
|
agent: Agent[PlanContext, str] = Agent(
|
||||||
|
model=model,
|
||||||
|
system_prompt=PLAN_SYSTEM_PROMPT,
|
||||||
|
deps_type=PlanContext,
|
||||||
|
output_type=str,
|
||||||
|
# Mistral Nemo settings:
|
||||||
|
# - temperature 0.3 (Nemo needs slightly higher than 0.0)
|
||||||
|
# - tool_choice "required" forces tool use
|
||||||
|
model_settings={
|
||||||
|
"temperature": 0.3,
|
||||||
|
"extra_body": {"tool_choice": "required"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register read-only tools
|
||||||
|
self._register_tools(agent)
|
||||||
|
|
||||||
|
return agent
|
||||||
|
|
||||||
|
def _register_tools(self, agent: Agent[PlanContext, str]) -> None:
|
||||||
|
"""Register read-only exploration tools with the agent."""
|
||||||
|
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,
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
allowed_paths: list[str] | None = None,
|
||||||
|
**kwargs: Any
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Run the plan agent to design an implementation strategy.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: Description of what to implement
|
||||||
|
working_dir: Working directory for exploration
|
||||||
|
allowed_paths: Restrict tool access to these paths
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Implementation plan with steps and critical files
|
||||||
|
"""
|
||||||
|
effective_working_dir = working_dir or os.getcwd()
|
||||||
|
|
||||||
|
ctx = PlanContext(
|
||||||
|
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(full_prompt, deps=ctx)
|
||||||
|
return result.output
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Plan agent error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def run_stream(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
allowed_paths: list[str] | None = None,
|
||||||
|
**kwargs: Any
|
||||||
|
) -> AsyncIterator[str]:
|
||||||
|
"""
|
||||||
|
Run the plan agent with streaming output.
|
||||||
|
|
||||||
|
Yields text chunks as they become available.
|
||||||
|
"""
|
||||||
|
effective_working_dir = working_dir or os.getcwd()
|
||||||
|
|
||||||
|
ctx = PlanContext(
|
||||||
|
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(full_prompt, deps=ctx) as result:
|
||||||
|
async for chunk in result.stream_text():
|
||||||
|
yield chunk
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Plan agent stream error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# Create and register the singleton instance
|
||||||
|
plan_agent = PlanAgentImpl()
|
||||||
|
register_agent(plan_agent)
|
||||||
|
|
||||||
|
|
||||||
|
async def plan(
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
**kwargs: Any
|
||||||
|
) -> str:
|
||||||
|
"""Run planning query."""
|
||||||
|
return await plan_agent.run(prompt, working_dir=working_dir, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def plan_stream(
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
**kwargs: Any
|
||||||
|
) -> AsyncIterator[str]:
|
||||||
|
"""Run planning query with streaming."""
|
||||||
|
async for chunk in plan_agent.run_stream(prompt, working_dir=working_dir, **kwargs):
|
||||||
|
yield chunk
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
System prompts for the Plan agent.
|
||||||
|
|
||||||
|
The Plan agent is a READ-ONLY software architect that explores codebases
|
||||||
|
and designs implementation plans without modifying any files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PLAN_SYSTEM_PROMPT = """You are a software architect and planning specialist.
|
||||||
|
|
||||||
|
Your role is to explore codebases and design implementation plans.
|
||||||
|
|
||||||
|
CRITICAL: You are READ-ONLY. You CANNOT modify any files.
|
||||||
|
|
||||||
|
AVAILABLE TOOLS:
|
||||||
|
- glob_files: Find files by pattern
|
||||||
|
- read_file: Read file contents
|
||||||
|
- grep_content: Search code with regex
|
||||||
|
- bash_readonly: Run read-only commands (ls, git status, git log, etc.)
|
||||||
|
|
||||||
|
WORKFLOW:
|
||||||
|
1. Understand the requirements
|
||||||
|
2. Explore the codebase to find relevant patterns and conventions
|
||||||
|
3. Design an implementation approach
|
||||||
|
4. Create a step-by-step plan with specific files and changes
|
||||||
|
|
||||||
|
TOOL CALL EXAMPLES (follow exactly):
|
||||||
|
|
||||||
|
To find Python files:
|
||||||
|
Call glob_files with pattern="**/*.py"
|
||||||
|
|
||||||
|
To find a specific file:
|
||||||
|
Call glob_files with pattern="**/config.py"
|
||||||
|
|
||||||
|
To read a file:
|
||||||
|
Call read_file with file_path="/absolute/path/to/file.py"
|
||||||
|
|
||||||
|
To search for code patterns:
|
||||||
|
Call grep_content with pattern="class.*Controller"
|
||||||
|
|
||||||
|
To check git history:
|
||||||
|
Call bash_readonly with command="git log --oneline -10"
|
||||||
|
|
||||||
|
OUTPUT FORMAT:
|
||||||
|
End your response with:
|
||||||
|
|
||||||
|
### Implementation Steps
|
||||||
|
1. [First step with specific file and changes]
|
||||||
|
2. [Second step...]
|
||||||
|
3. [Continue...]
|
||||||
|
|
||||||
|
### Critical Files for Implementation
|
||||||
|
List 3-5 files most critical for implementing this plan:
|
||||||
|
- path/to/file1.py - [Brief reason: e.g., "Core logic to modify"]
|
||||||
|
- path/to/file2.py - [Brief reason: e.g., "Pattern to follow"]
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- ALWAYS use tools first, then analyze results
|
||||||
|
- Follow existing patterns in the codebase
|
||||||
|
- Consider trade-offs and alternatives
|
||||||
|
- Identify dependencies and sequencing
|
||||||
|
- Never guess - verify with tools
|
||||||
|
- Provide specific file paths and code locations
|
||||||
|
"""
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""
|
||||||
|
Tool registrations for the Plan agent.
|
||||||
|
|
||||||
|
The Plan agent only has access to READ-ONLY tools.
|
||||||
|
It cannot modify files - only explore and analyze.
|
||||||
|
"""
|
||||||
|
from pydantic_ai import Agent, RunContext
|
||||||
|
|
||||||
|
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[PlanContext, str]) -> None:
|
||||||
|
"""
|
||||||
|
Register read-only exploration tools with the Plan agent.
|
||||||
|
|
||||||
|
The Plan agent is restricted to read-only tools:
|
||||||
|
- read_file: Read file contents
|
||||||
|
- glob_files: Find files by pattern
|
||||||
|
- grep_content: Search file contents
|
||||||
|
- bash_readonly: Read-only shell commands
|
||||||
|
|
||||||
|
Write tools (edit_file, write_file, bash) are NOT available.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def read_file(
|
||||||
|
ctx: RunContext[PlanContext],
|
||||||
|
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. Use this to understand existing code.
|
||||||
|
"""
|
||||||
|
tool = ReadFileTool(allowed_paths=ctx.deps.allowed_paths)
|
||||||
|
result = await tool.execute(
|
||||||
|
file_path=file_path,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def glob_files(
|
||||||
|
ctx: RunContext[PlanContext],
|
||||||
|
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
|
||||||
|
|
||||||
|
IMPORTANT: Use this to discover files before reading them.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def grep_content(
|
||||||
|
ctx: RunContext[PlanContext],
|
||||||
|
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"
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- pattern="def.*__init__" finds init methods
|
||||||
|
- pattern="class\\s+\\w+" finds class definitions
|
||||||
|
- pattern="TODO|FIXME" finds todo comments
|
||||||
|
|
||||||
|
IMPORTANT: Use this to find code patterns and implementations.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def bash_readonly(
|
||||||
|
ctx: RunContext[PlanContext],
|
||||||
|
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)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Command output or error message.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "ls -la" lists files with details
|
||||||
|
- "git status" shows git status
|
||||||
|
- "git log --oneline -10" shows recent commits
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
@@ -1,21 +1,40 @@
|
|||||||
"""
|
"""
|
||||||
REST API routes for agents.
|
REST API routes for agents.
|
||||||
|
|
||||||
|
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
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from src.domains.agents.base import get_agent, list_agents
|
|
||||||
|
|
||||||
# Import agents to ensure they're registered
|
# Import agents to ensure they're registered
|
||||||
import src.domains.agents.explore # noqa: F401
|
import src.domains.agents.explore
|
||||||
|
import src.domains.agents.plan
|
||||||
|
import src.domains.agents.task # noqa: F401
|
||||||
|
from src.domains.agents.base import get_agent, list_agents
|
||||||
from src.domains.agents.schemas import (
|
from src.domains.agents.schemas import (
|
||||||
AgentRunRequest,
|
|
||||||
AgentRunResponse,
|
|
||||||
AgentInfo,
|
AgentInfo,
|
||||||
AgentListResponse,
|
AgentListResponse,
|
||||||
|
AgentRunRequest,
|
||||||
|
AgentRunResponse,
|
||||||
|
PermissionMode,
|
||||||
|
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__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -38,6 +57,7 @@ async def run_agent(request: AgentRunRequest) -> AgentRunResponse:
|
|||||||
Run an agent with the given prompt.
|
Run an agent with the given prompt.
|
||||||
|
|
||||||
The agent will use tools to explore the codebase and answer questions.
|
The agent will use tools to explore the codebase and answer questions.
|
||||||
|
Permission mode controls which tools are available.
|
||||||
"""
|
"""
|
||||||
# Get the requested agent
|
# Get the requested agent
|
||||||
agent = get_agent(request.agent_type)
|
agent = get_agent(request.agent_type)
|
||||||
@@ -47,16 +67,21 @@ async def run_agent(request: AgentRunRequest) -> AgentRunResponse:
|
|||||||
detail=f"Unknown agent type: {request.agent_type}"
|
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:
|
try:
|
||||||
# Run the agent
|
# Run the agent with mode
|
||||||
response = await agent.run(
|
response = await agent.run(
|
||||||
request.prompt,
|
request.prompt,
|
||||||
working_dir=request.working_dir,
|
working_dir=request.working_dir,
|
||||||
|
mode=mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
return AgentRunResponse(
|
return AgentRunResponse(
|
||||||
response=response,
|
response=response,
|
||||||
agent_type=request.agent_type,
|
agent_type=request.agent_type,
|
||||||
|
mode=request.mode, # Keep original for response
|
||||||
success=True,
|
success=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,6 +90,7 @@ async def run_agent(request: AgentRunRequest) -> AgentRunResponse:
|
|||||||
return AgentRunResponse(
|
return AgentRunResponse(
|
||||||
response="",
|
response="",
|
||||||
agent_type=request.agent_type,
|
agent_type=request.agent_type,
|
||||||
|
mode=request.mode,
|
||||||
success=False,
|
success=False,
|
||||||
error=str(e),
|
error=str(e),
|
||||||
)
|
)
|
||||||
@@ -76,11 +102,16 @@ async def stream_agent(request: AgentRunRequest) -> StreamingResponse:
|
|||||||
"""
|
"""
|
||||||
Run an agent with streaming response.
|
Run an agent with streaming response.
|
||||||
|
|
||||||
Returns Server-Sent Events (SSE) with text chunks.
|
Returns Server-Sent Events (SSE) with structured events.
|
||||||
Event types:
|
Permission mode controls which tools are available.
|
||||||
- "chunk": Text chunk from the agent
|
|
||||||
- "done": Stream complete
|
Event types (from StreamEvent):
|
||||||
- "error": Error occurred
|
- 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)
|
agent = get_agent(request.agent_type)
|
||||||
if not agent:
|
if not agent:
|
||||||
@@ -89,22 +120,28 @@ async def stream_agent(request: AgentRunRequest) -> StreamingResponse:
|
|||||||
detail=f"Unknown agent type: {request.agent_type}"
|
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():
|
async def generate():
|
||||||
try:
|
try:
|
||||||
async for chunk in agent.run_stream(
|
async for event in agent.run_stream(
|
||||||
request.prompt,
|
request.prompt,
|
||||||
working_dir=request.working_dir,
|
working_dir=request.working_dir,
|
||||||
|
mode=mode,
|
||||||
):
|
):
|
||||||
# SSE format: data: {json}\n\n
|
# Handle both StreamEvent objects and legacy string chunks
|
||||||
event = {"event": "chunk", "data": chunk}
|
if isinstance(event, StreamEvent):
|
||||||
yield f"data: {json.dumps(event)}\n\n"
|
# New structured event format
|
||||||
|
event_data = event.model_dump(exclude_none=True)
|
||||||
# Signal completion
|
yield f"data: {json.dumps(event_data)}\n\n"
|
||||||
yield f"data: {json.dumps({'event': 'done'})}\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:
|
except Exception as e:
|
||||||
logger.exception(f"Stream error: {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"
|
yield f"data: {json.dumps(error_event)}\n\n"
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
|
|||||||
@@ -1,22 +1,127 @@
|
|||||||
"""
|
"""
|
||||||
Request and response schemas for agent API.
|
Request and response schemas for agent API.
|
||||||
"""
|
"""
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
from src.shared.base import BaseSchema
|
from src.shared.base import BaseSchema
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionMode(str, Enum):
|
||||||
|
"""
|
||||||
|
Permission modes that control agent tool access.
|
||||||
|
|
||||||
|
Aligns with Claude Code's permission model:
|
||||||
|
- default: Full tools, approval required for writes (future)
|
||||||
|
- plan: Read-only tools only, no approval needed
|
||||||
|
- auto_accept: Full tools, no approval prompts
|
||||||
|
"""
|
||||||
|
default = "default"
|
||||||
|
plan = "plan"
|
||||||
|
auto_accept = "auto_accept"
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalStatus(str, Enum):
|
||||||
|
"""Status of a tool approval request."""
|
||||||
|
pending = "pending"
|
||||||
|
approved = "approved"
|
||||||
|
denied = "denied"
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalAction(str, Enum):
|
||||||
|
"""Action to take when a rule matches."""
|
||||||
|
allow = "allow" # Auto-approve without prompting
|
||||||
|
deny = "deny" # Auto-deny without prompting
|
||||||
|
ask = "ask" # Prompt user for approval
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalRule(BaseSchema):
|
||||||
|
"""
|
||||||
|
Granular approval rule for tool execution.
|
||||||
|
|
||||||
|
Allows fine-grained control over which tool calls are allowed:
|
||||||
|
- Pattern matching on tool arguments
|
||||||
|
- Different actions per rule (allow, deny, ask)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Allow curl to localhost
|
||||||
|
ApprovalRule(tool="bash", pattern="curl.*localhost.*", action="allow")
|
||||||
|
|
||||||
|
# Deny any rm command
|
||||||
|
ApprovalRule(tool="bash", pattern="rm\\s+.*", action="deny")
|
||||||
|
|
||||||
|
# Ask for git push
|
||||||
|
ApprovalRule(tool="bash", pattern="git\\s+push.*", action="ask")
|
||||||
|
|
||||||
|
# Allow all file reads in src/
|
||||||
|
ApprovalRule(tool="read_file", pattern=".*/src/.*", action="allow")
|
||||||
|
"""
|
||||||
|
tool: str # Tool name to match (e.g., "bash", "edit_file")
|
||||||
|
pattern: str # Regex pattern to match against tool args
|
||||||
|
action: ApprovalAction # What to do when matched
|
||||||
|
description: str | None = None # Human-readable description of rule
|
||||||
|
priority: int = 0 # Higher priority rules evaluated first
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalRuleSet(BaseSchema):
|
||||||
|
"""
|
||||||
|
Collection of approval rules with evaluation logic.
|
||||||
|
|
||||||
|
Rules are evaluated in priority order (highest first).
|
||||||
|
First matching rule determines the action.
|
||||||
|
If no rules match, falls back to default action.
|
||||||
|
"""
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
class ToolApprovalRequest(BaseSchema):
|
||||||
|
"""
|
||||||
|
Request for tool execution approval.
|
||||||
|
|
||||||
|
Sent from API to CLI when a tool needs user approval.
|
||||||
|
Prep for future bidirectional approval flow.
|
||||||
|
"""
|
||||||
|
request_id: str
|
||||||
|
tool_name: str
|
||||||
|
tool_args: dict
|
||||||
|
description: str
|
||||||
|
risk_level: str = "write" # "read", "write", "dangerous"
|
||||||
|
|
||||||
|
|
||||||
|
class ToolApprovalResponse(BaseSchema):
|
||||||
|
"""
|
||||||
|
Response to a tool approval request.
|
||||||
|
|
||||||
|
Sent from CLI to API with user's decision.
|
||||||
|
"""
|
||||||
|
request_id: str
|
||||||
|
status: ApprovalStatus
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AgentRunRequest(BaseSchema):
|
class AgentRunRequest(BaseSchema):
|
||||||
"""Request to run an agent."""
|
"""Request to run an agent."""
|
||||||
prompt: str
|
prompt: str
|
||||||
working_dir: str = "."
|
working_dir: str = "."
|
||||||
agent_type: str = "explore"
|
agent_type: str = "task" # Default to task agent (main agent)
|
||||||
|
mode: PermissionMode = PermissionMode.default
|
||||||
|
|
||||||
|
|
||||||
class AgentRunResponse(BaseSchema):
|
class AgentRunResponse(BaseSchema):
|
||||||
"""Response from agent execution."""
|
"""Response from agent execution."""
|
||||||
response: str
|
response: str
|
||||||
agent_type: str
|
agent_type: str
|
||||||
|
mode: PermissionMode = PermissionMode.default
|
||||||
success: bool = True
|
success: bool = True
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
# Prep for approval flow - if set, CLI should handle approval
|
||||||
|
pending_approval: ToolApprovalRequest | None = None
|
||||||
|
|
||||||
|
|
||||||
class AgentInfo(BaseSchema):
|
class AgentInfo(BaseSchema):
|
||||||
@@ -28,3 +133,36 @@ class AgentInfo(BaseSchema):
|
|||||||
class AgentListResponse(BaseSchema):
|
class AgentListResponse(BaseSchema):
|
||||||
"""List of available agents."""
|
"""List of available agents."""
|
||||||
agents: list[AgentInfo]
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""
|
||||||
|
Task Agent - Full orchestrator for autonomous task execution.
|
||||||
|
|
||||||
|
The Task agent can:
|
||||||
|
- Execute multi-step tasks autonomously
|
||||||
|
- Use all tools (read + write + bash)
|
||||||
|
- Spawn sub-agents (Explore, Plan) for focused work
|
||||||
|
- Return consolidated task summaries
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from src.domains.agents.task import task_agent, task
|
||||||
|
|
||||||
|
# Direct agent access
|
||||||
|
result = await task_agent.run("Create a new user model with tests")
|
||||||
|
|
||||||
|
# Convenience function
|
||||||
|
result = await task("Create a new user model with tests")
|
||||||
|
"""
|
||||||
|
from src.domains.agents.task.agent import (
|
||||||
|
TaskAgentImpl,
|
||||||
|
TaskContext,
|
||||||
|
task,
|
||||||
|
task_agent,
|
||||||
|
task_stream,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"TaskAgentImpl",
|
||||||
|
"TaskContext",
|
||||||
|
"task",
|
||||||
|
"task_agent",
|
||||||
|
"task_stream",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
"""
|
||||||
|
Task Agent implementation using PydanticAI.
|
||||||
|
|
||||||
|
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
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic_ai import Agent
|
||||||
|
from pydantic_ai.models.openai import OpenAIModel
|
||||||
|
|
||||||
|
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 get_logger, logged, trace_span
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TaskContext(AgentContext):
|
||||||
|
"""
|
||||||
|
Context for task agent tools.
|
||||||
|
|
||||||
|
Passed to all tool functions via RunContext.
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Tool access depends on permission mode:
|
||||||
|
- plan: Read-only tools only (safe exploration)
|
||||||
|
- default: All tools (approval required for writes - future)
|
||||||
|
- auto_accept: All tools (no approval prompts)
|
||||||
|
|
||||||
|
Can spawn Explore and Plan agents to offload focused tasks,
|
||||||
|
keeping context efficient across complex multi-step work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "task"
|
||||||
|
description = "Autonomous multi-step task execution with sub-agent orchestration"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the task agent."""
|
||||||
|
# Cache agents by mode to avoid recreating
|
||||||
|
self._agents: dict[PermissionMode, Agent[TaskContext, str]] = {}
|
||||||
|
self._settings = get_settings()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def agent(self) -> Agent[TaskContext, str]:
|
||||||
|
"""Default agent (full mode) for compatibility."""
|
||||||
|
return self._get_agent_for_mode(PermissionMode.default)
|
||||||
|
|
||||||
|
def _get_agent_for_mode(self, mode: PermissionMode) -> Agent[TaskContext, str]:
|
||||||
|
"""Get or create agent configured for the specified mode."""
|
||||||
|
if mode not in self._agents:
|
||||||
|
self._agents[mode] = self._create_agent(mode)
|
||||||
|
return self._agents[mode]
|
||||||
|
|
||||||
|
def _create_agent(self, mode: PermissionMode = PermissionMode.default) -> Agent[TaskContext, str]:
|
||||||
|
"""Create the PydanticAI agent with Ollama backend."""
|
||||||
|
# Use sanitized Ollama provider to fix content: null issues
|
||||||
|
model = OpenAIModel(
|
||||||
|
model_name=self._settings.ollama_agent_model,
|
||||||
|
provider=get_ollama_provider(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Select system prompt based on mode
|
||||||
|
system_prompt = TASK_PLAN_MODE_PROMPT if mode == PermissionMode.plan else TASK_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
agent: Agent[TaskContext, str] = Agent(
|
||||||
|
model=model,
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
deps_type=TaskContext,
|
||||||
|
output_type=str,
|
||||||
|
# Mistral Nemo settings:
|
||||||
|
# - temperature 0.3 (Nemo needs slightly higher than 0.0)
|
||||||
|
# - tool_choice "required" forces tool use
|
||||||
|
model_settings={
|
||||||
|
"temperature": 0.3,
|
||||||
|
"extra_body": {"tool_choice": "required"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register tools based on mode
|
||||||
|
self._register_tools(agent, mode)
|
||||||
|
|
||||||
|
return agent
|
||||||
|
|
||||||
|
def _register_tools(self, agent: Agent[TaskContext, str], mode: PermissionMode) -> None:
|
||||||
|
"""Register tools with the agent based on permission mode."""
|
||||||
|
from src.domains.agents.task.tools_streaming import (
|
||||||
|
register_readonly_tools_streaming,
|
||||||
|
register_task_tools_streaming,
|
||||||
|
)
|
||||||
|
|
||||||
|
if mode == PermissionMode.plan:
|
||||||
|
# Plan mode: read-only tools only
|
||||||
|
register_readonly_tools_streaming(agent)
|
||||||
|
else:
|
||||||
|
# Default and auto_accept: all tools
|
||||||
|
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(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
allowed_paths: list[str] | None = None,
|
||||||
|
mode: PermissionMode = PermissionMode.default,
|
||||||
|
**kwargs: Any
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Run the task agent to execute a multi-step task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: Description of the task to execute
|
||||||
|
working_dir: Working directory for the agent
|
||||||
|
allowed_paths: Restrict tool access to these paths
|
||||||
|
mode: Permission mode controlling tool access
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Consolidated task summary with results
|
||||||
|
"""
|
||||||
|
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"):
|
||||||
|
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,
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
allowed_paths: list[str] | None = None,
|
||||||
|
mode: PermissionMode = PermissionMode.default,
|
||||||
|
**kwargs: Any
|
||||||
|
) -> AsyncIterator[StreamEvent]:
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
working_dir: Working directory
|
||||||
|
allowed_paths: Restrict tool access
|
||||||
|
mode: Permission mode controlling tool access
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
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"):
|
||||||
|
# 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
|
||||||
|
task_agent = TaskAgentImpl()
|
||||||
|
register_agent(task_agent)
|
||||||
|
|
||||||
|
|
||||||
|
async def task(
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
**kwargs: Any
|
||||||
|
) -> str:
|
||||||
|
"""Run task execution."""
|
||||||
|
return await task_agent.run(prompt, working_dir=working_dir, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def task_stream(
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
**kwargs: Any
|
||||||
|
) -> AsyncIterator[StreamEvent]:
|
||||||
|
"""Run task execution with event streaming."""
|
||||||
|
async for event in task_agent.run_stream(prompt, working_dir=working_dir, **kwargs):
|
||||||
|
yield event
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""
|
||||||
|
System prompts for the Task agent.
|
||||||
|
|
||||||
|
The Task agent is a full orchestrator that can:
|
||||||
|
- Execute multi-step tasks autonomously
|
||||||
|
- Use all tools (read + write) based on permission mode
|
||||||
|
- Spawn sub-agents (Explore, Plan) for focused work
|
||||||
|
"""
|
||||||
|
|
||||||
|
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):
|
||||||
|
|
||||||
|
File Operations:
|
||||||
|
- read_file: Read file contents with line numbers
|
||||||
|
- glob_files: Find files by pattern
|
||||||
|
- grep_content: Search file contents with regex
|
||||||
|
|
||||||
|
Shell:
|
||||||
|
- bash_readonly: Read-only commands (ls, git status, git log, git diff, etc.)
|
||||||
|
|
||||||
|
Orchestration:
|
||||||
|
- spawn_agent: Launch sub-agents for focused tasks (explore, plan only)
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
To find all Python files:
|
||||||
|
Call glob_files with pattern="**/*.py"
|
||||||
|
|
||||||
|
To search for a function:
|
||||||
|
Call grep_content with pattern="def my_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 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 from tool results
|
||||||
|
|
||||||
|
OUTPUT FORMAT:
|
||||||
|
Structure your response with:
|
||||||
|
|
||||||
|
### Analysis
|
||||||
|
- What was found (from tool results)
|
||||||
|
- Key patterns identified
|
||||||
|
- Relevant files (actual paths from tools)
|
||||||
|
|
||||||
|
### Recommendations
|
||||||
|
- Suggested approach
|
||||||
|
- Potential concerns
|
||||||
|
- Next steps (to be executed in full mode)
|
||||||
|
"""
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
AVAILABLE TOOLS:
|
||||||
|
|
||||||
|
File Operations:
|
||||||
|
- read_file: Read file contents with line numbers
|
||||||
|
- glob_files: Find files by pattern
|
||||||
|
- grep_content: Search file contents with regex
|
||||||
|
- edit_file: Make targeted edits via find-and-replace
|
||||||
|
- write_file: Create or overwrite files
|
||||||
|
|
||||||
|
Shell:
|
||||||
|
- bash_readonly: Read-only commands (ls, git status, git log, etc.)
|
||||||
|
- bash: Full bash execution (git commit, pytest, mkdir, etc.)
|
||||||
|
|
||||||
|
External:
|
||||||
|
- web_search: Search the web for current information
|
||||||
|
|
||||||
|
Orchestration:
|
||||||
|
- spawn_agent: Launch sub-agents for focused tasks
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
To spawn a Plan agent for design:
|
||||||
|
Call spawn_agent with agent_type="plan" and prompt="design user auth feature"
|
||||||
|
|
||||||
|
To edit a file:
|
||||||
|
Call edit_file with file_path="/path/to/file.py" and old_string="old" and new_string="new"
|
||||||
|
|
||||||
|
To run tests:
|
||||||
|
Call bash with command="pytest tests/ -v"
|
||||||
|
|
||||||
|
SPAWN_AGENT USAGE:
|
||||||
|
- Use spawn_agent to offload focused tasks to specialized agents
|
||||||
|
- Explore agent: Fast codebase searches and analysis
|
||||||
|
- Plan agent: Design implementation strategies
|
||||||
|
- Keep each agent's context focused and efficient
|
||||||
|
|
||||||
|
GIT DISCIPLINE:
|
||||||
|
- Create feature branches for changes
|
||||||
|
- Use conventional commit format (feat:, fix:, docs:, etc.)
|
||||||
|
- Never commit directly to main
|
||||||
|
- Run tests before committing
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- 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
|
||||||
|
|
||||||
|
OUTPUT FORMAT:
|
||||||
|
End your response with a summary:
|
||||||
|
|
||||||
|
### Task Summary
|
||||||
|
- **Accomplished:** What was done
|
||||||
|
- **Files modified:** List of changed files
|
||||||
|
- **Commands run:** Key commands executed
|
||||||
|
- **Issues:** Any problems encountered
|
||||||
|
"""
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
"""
|
||||||
|
Tool registrations for the Task agent.
|
||||||
|
|
||||||
|
The Task agent has access to tools based on permission mode:
|
||||||
|
- Plan mode: Read-only tools only
|
||||||
|
- Default/auto_accept: All tools including write operations
|
||||||
|
"""
|
||||||
|
from pydantic_ai import Agent, RunContext
|
||||||
|
|
||||||
|
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 _register_read_file(agent: Agent[TaskContext, str]) -> None:
|
||||||
|
"""Register read_file tool."""
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
tool = ReadFileTool(allowed_paths=ctx.deps.allowed_paths)
|
||||||
|
result = await tool.execute(
|
||||||
|
file_path=file_path,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
|
||||||
|
def _register_glob_files(agent: Agent[TaskContext, str]) -> None:
|
||||||
|
"""Register glob_files tool."""
|
||||||
|
@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
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
|
||||||
|
def _register_grep_content(agent: Agent[TaskContext, str]) -> None:
|
||||||
|
"""Register grep_content tool."""
|
||||||
|
@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"
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
|
||||||
|
def _register_bash_readonly(agent: Agent[TaskContext, str]) -> None:
|
||||||
|
"""Register bash_readonly tool."""
|
||||||
|
@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)
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
|
||||||
|
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[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
|
||||||
|
|
||||||
|
# Validate agent type
|
||||||
|
allowed_types = ["explore", "plan"]
|
||||||
|
if agent_type not in allowed_types:
|
||||||
|
if agent_type == "task":
|
||||||
|
return "Error: Cannot spawn nested Task agents (recursion risk)"
|
||||||
|
return f"Error: Unknown agent type '{agent_type}'. Allowed: {allowed_types}"
|
||||||
|
|
||||||
|
sub_agent = get_agent(agent_type)
|
||||||
|
if not sub_agent:
|
||||||
|
return f"Error: Agent '{agent_type}' not found in registry"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await sub_agent.run(
|
||||||
|
prompt=prompt,
|
||||||
|
working_dir=working_dir or ctx.deps.working_dir,
|
||||||
|
allowed_paths=ctx.deps.allowed_paths,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
return f"Sub-agent error: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def register_readonly_tools(agent: Agent[TaskContext, str]) -> None:
|
||||||
|
"""
|
||||||
|
Register read-only tools with the agent.
|
||||||
|
|
||||||
|
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(agent: Agent[TaskContext, str]) -> None:
|
||||||
|
"""
|
||||||
|
Register all tools with the Task agent.
|
||||||
|
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
@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
|
||||||
|
"""
|
||||||
|
tool = WriteFileTool(allowed_paths=ctx.deps.allowed_paths)
|
||||||
|
result = await tool.execute(
|
||||||
|
file_path=file_path,
|
||||||
|
content=content
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
@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
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
# === 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
|
||||||
|
"""
|
||||||
|
tool = WebSearchTool()
|
||||||
|
result = await tool.execute(
|
||||||
|
query=query,
|
||||||
|
num_results=num_results,
|
||||||
|
categories=categories
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
# === Orchestration tools ===
|
||||||
|
_register_spawn_agent(agent, readonly_only=False)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
Conversations domain - Multi-turn conversation management.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- Conversation persistence with message history
|
||||||
|
- Context summarization when approaching token limits
|
||||||
|
- Agent integration with conversation context injection
|
||||||
|
"""
|
||||||
|
from src.domains.conversations.models import Conversation, Message
|
||||||
|
from src.domains.conversations.service import ConversationService
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Conversation",
|
||||||
|
"ConversationService",
|
||||||
|
"Message",
|
||||||
|
]
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""
|
||||||
|
Database models for conversations.
|
||||||
|
|
||||||
|
Following core-api patterns: SQLAlchemy 2.0 with async support.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.models import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Conversation(Base):
|
||||||
|
"""
|
||||||
|
A conversation session with an agent.
|
||||||
|
|
||||||
|
Tracks message history, token usage, and metadata.
|
||||||
|
"""
|
||||||
|
__tablename__ = "conversations"
|
||||||
|
|
||||||
|
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
|
||||||
|
user_id: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
|
agent_type: Mapped[str] = mapped_column(String(50), default="explore", insert_default="explore")
|
||||||
|
title: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
|
||||||
|
working_dir: Mapped[str] = mapped_column(String(1024), default=".", insert_default=".")
|
||||||
|
total_tokens: Mapped[int] = mapped_column(default=0, insert_default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
|
||||||
|
updated_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
default=datetime.utcnow,
|
||||||
|
onupdate=datetime.utcnow,
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
messages: Mapped[list["Message"]] = relationship(
|
||||||
|
back_populates="conversation",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="Message.created_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Conversation {self.id} agent={self.agent_type}>"
|
||||||
|
|
||||||
|
|
||||||
|
class Message(Base):
|
||||||
|
"""
|
||||||
|
A single message in a conversation.
|
||||||
|
|
||||||
|
Tracks role, content, token count, and summarization state.
|
||||||
|
"""
|
||||||
|
__tablename__ = "messages"
|
||||||
|
|
||||||
|
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
|
||||||
|
conversation_id: Mapped[UUID] = mapped_column(
|
||||||
|
ForeignKey("conversations.id", ondelete="CASCADE"),
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
role: Mapped[str] = mapped_column(String(20)) # user, assistant, system, summary
|
||||||
|
content: Mapped[str] = mapped_column(Text)
|
||||||
|
token_count: Mapped[int] = mapped_column(default=0, insert_default=0)
|
||||||
|
is_summary: Mapped[bool] = mapped_column(default=False, insert_default=False)
|
||||||
|
summarizes_up_to: Mapped[UUID | None] = mapped_column(nullable=True, default=None)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
conversation: Mapped["Conversation"] = relationship(back_populates="messages")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
preview = self.content[:30] + "..." if len(self.content) > 30 else self.content
|
||||||
|
return f"<Message {self.role}: {preview}>"
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"""
|
||||||
|
REST API routes for conversations.
|
||||||
|
"""
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.db import get_session
|
||||||
|
from src.domains.conversations.schemas import (
|
||||||
|
AddMessageRequest,
|
||||||
|
AddMessageResponse,
|
||||||
|
ConversationDetailResponse,
|
||||||
|
ConversationListResponse,
|
||||||
|
ConversationResponse,
|
||||||
|
CreateConversationRequest,
|
||||||
|
MessageResponse,
|
||||||
|
SaveMessagesRequest,
|
||||||
|
SaveMessagesResponse,
|
||||||
|
)
|
||||||
|
from src.domains.conversations.service import ConversationService
|
||||||
|
from src.shared.auth import require_auth
|
||||||
|
from src.shared.logging import get_logger, logged
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/conversations", tags=["Conversations"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=ConversationResponse, status_code=201)
|
||||||
|
@logged()
|
||||||
|
async def create_conversation(
|
||||||
|
request: CreateConversationRequest,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
user=Depends(require_auth),
|
||||||
|
) -> ConversationResponse:
|
||||||
|
"""
|
||||||
|
Create a new conversation.
|
||||||
|
|
||||||
|
Starts an empty conversation with the specified agent type.
|
||||||
|
"""
|
||||||
|
service = ConversationService(session)
|
||||||
|
conversation = await service.create(
|
||||||
|
user_id=user.id,
|
||||||
|
agent_type=request.agent_type,
|
||||||
|
working_dir=request.working_dir,
|
||||||
|
title=request.title,
|
||||||
|
)
|
||||||
|
return ConversationResponse.model_validate(conversation)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=ConversationListResponse)
|
||||||
|
@logged()
|
||||||
|
async def list_conversations(
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
user=Depends(require_auth),
|
||||||
|
) -> ConversationListResponse:
|
||||||
|
"""
|
||||||
|
List user's conversations.
|
||||||
|
|
||||||
|
Returns conversations sorted by most recently updated.
|
||||||
|
"""
|
||||||
|
service = ConversationService(session)
|
||||||
|
conversations, total = await service.list_by_user(
|
||||||
|
user_id=user.id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
return ConversationListResponse(
|
||||||
|
conversations=[ConversationResponse.model_validate(c) for c in conversations],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{conversation_id}", response_model=ConversationDetailResponse)
|
||||||
|
@logged()
|
||||||
|
async def get_conversation(
|
||||||
|
conversation_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
user=Depends(require_auth),
|
||||||
|
) -> ConversationDetailResponse:
|
||||||
|
"""
|
||||||
|
Get conversation with all messages.
|
||||||
|
|
||||||
|
Returns conversation metadata and full message history.
|
||||||
|
"""
|
||||||
|
service = ConversationService(session)
|
||||||
|
conversation = await service.get_with_messages(conversation_id)
|
||||||
|
|
||||||
|
if not conversation:
|
||||||
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||||
|
|
||||||
|
if conversation.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
|
return ConversationDetailResponse.model_validate(conversation)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{conversation_id}", status_code=204)
|
||||||
|
@logged()
|
||||||
|
async def delete_conversation(
|
||||||
|
conversation_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
user=Depends(require_auth),
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Delete a conversation and all its messages.
|
||||||
|
"""
|
||||||
|
service = ConversationService(session)
|
||||||
|
conversation = await service.get(conversation_id)
|
||||||
|
|
||||||
|
if not conversation:
|
||||||
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||||
|
|
||||||
|
if conversation.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
|
await service.delete(conversation_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{conversation_id}/messages", response_model=AddMessageResponse)
|
||||||
|
@logged()
|
||||||
|
async def add_message(
|
||||||
|
conversation_id: UUID,
|
||||||
|
request: AddMessageRequest,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
user=Depends(require_auth),
|
||||||
|
) -> AddMessageResponse:
|
||||||
|
"""
|
||||||
|
Add a message to a conversation and get agent response.
|
||||||
|
|
||||||
|
This is the main endpoint for continuing conversations.
|
||||||
|
It:
|
||||||
|
1. Adds the user message
|
||||||
|
2. Checks if summarization is needed
|
||||||
|
3. Builds context from conversation history
|
||||||
|
4. Gets agent response
|
||||||
|
5. Adds agent response to conversation
|
||||||
|
6. Returns both messages
|
||||||
|
"""
|
||||||
|
service = ConversationService(session)
|
||||||
|
|
||||||
|
# Verify conversation exists and user owns it
|
||||||
|
conversation = await service.get(conversation_id)
|
||||||
|
if not conversation:
|
||||||
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||||
|
|
||||||
|
if conversation.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
|
# Add user message
|
||||||
|
user_message = await service.add_message(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
role="user",
|
||||||
|
content=request.content,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if summarization needed before getting response
|
||||||
|
summarized = await service.summarize_if_needed(conversation_id)
|
||||||
|
|
||||||
|
# Get agent response with context
|
||||||
|
try:
|
||||||
|
response_text = await service.get_agent_response(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
user_message=request.content,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Agent response failed: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Agent error: {e!s}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add assistant message
|
||||||
|
assistant_message = await service.add_message(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
role="assistant",
|
||||||
|
content=response_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get updated conversation for total tokens
|
||||||
|
conversation = await service.get(conversation_id)
|
||||||
|
|
||||||
|
return AddMessageResponse(
|
||||||
|
user_message=MessageResponse.model_validate(user_message),
|
||||||
|
assistant_message=MessageResponse.model_validate(assistant_message),
|
||||||
|
total_tokens=conversation.total_tokens if conversation else 0,
|
||||||
|
summarized=summarized,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{conversation_id}/save", response_model=SaveMessagesResponse)
|
||||||
|
@logged()
|
||||||
|
async def save_messages(
|
||||||
|
conversation_id: UUID,
|
||||||
|
request: SaveMessagesRequest,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
user=Depends(require_auth),
|
||||||
|
) -> SaveMessagesResponse:
|
||||||
|
"""
|
||||||
|
Save a user/assistant message pair without triggering agent execution.
|
||||||
|
|
||||||
|
Used by CLI when streaming responses separately via /agents/stream.
|
||||||
|
This allows persisting the exchange after streaming completes.
|
||||||
|
"""
|
||||||
|
service = ConversationService(session)
|
||||||
|
|
||||||
|
# Verify conversation exists and user owns it
|
||||||
|
conversation = await service.get(conversation_id)
|
||||||
|
if not conversation:
|
||||||
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||||
|
|
||||||
|
if conversation.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
|
# Save user message
|
||||||
|
user_message = await service.add_message(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
role="user",
|
||||||
|
content=request.user_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save assistant message
|
||||||
|
assistant_message = await service.add_message(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
role="assistant",
|
||||||
|
content=request.assistant_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get updated conversation for total tokens
|
||||||
|
conversation = await service.get(conversation_id)
|
||||||
|
|
||||||
|
return SaveMessagesResponse(
|
||||||
|
user_message=MessageResponse.model_validate(user_message),
|
||||||
|
assistant_message=MessageResponse.model_validate(assistant_message),
|
||||||
|
total_tokens=conversation.total_tokens if conversation else 0,
|
||||||
|
)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""
|
||||||
|
Pydantic schemas for conversation API.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
# === Request Schemas ===
|
||||||
|
|
||||||
|
class CreateConversationRequest(BaseModel):
|
||||||
|
"""Request to create a new conversation."""
|
||||||
|
agent_type: str = Field(default="explore", description="Agent type to use")
|
||||||
|
working_dir: str = Field(default=".", description="Working directory for agent")
|
||||||
|
title: str | None = Field(default=None, description="Optional conversation title")
|
||||||
|
|
||||||
|
|
||||||
|
class AddMessageRequest(BaseModel):
|
||||||
|
"""Request to add a message to a conversation."""
|
||||||
|
content: str = Field(..., min_length=1, description="Message content")
|
||||||
|
|
||||||
|
|
||||||
|
class SaveMessagesRequest(BaseModel):
|
||||||
|
"""Request to save a message pair without triggering agent execution.
|
||||||
|
|
||||||
|
Used by CLI when streaming responses separately via /agents/stream.
|
||||||
|
"""
|
||||||
|
user_content: str = Field(..., min_length=1, description="User message content")
|
||||||
|
assistant_content: str = Field(..., min_length=1, description="Assistant response content")
|
||||||
|
|
||||||
|
|
||||||
|
# === Response Schemas ===
|
||||||
|
|
||||||
|
class MessageResponse(BaseModel):
|
||||||
|
"""Response for a single message."""
|
||||||
|
id: UUID
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
token_count: int
|
||||||
|
is_summary: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationResponse(BaseModel):
|
||||||
|
"""Response for conversation metadata."""
|
||||||
|
id: UUID
|
||||||
|
agent_type: str
|
||||||
|
title: str | None
|
||||||
|
working_dir: str
|
||||||
|
total_tokens: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime | None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationDetailResponse(BaseModel):
|
||||||
|
"""Response for conversation with messages."""
|
||||||
|
id: UUID
|
||||||
|
agent_type: str
|
||||||
|
title: str | None
|
||||||
|
working_dir: str
|
||||||
|
total_tokens: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime | None
|
||||||
|
messages: list[MessageResponse]
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationListResponse(BaseModel):
|
||||||
|
"""Response for listing conversations."""
|
||||||
|
conversations: list[ConversationResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class AddMessageResponse(BaseModel):
|
||||||
|
"""Response after adding a message (includes agent response)."""
|
||||||
|
user_message: MessageResponse
|
||||||
|
assistant_message: MessageResponse
|
||||||
|
total_tokens: int
|
||||||
|
summarized: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Whether context was summarized due to token limit"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SaveMessagesResponse(BaseModel):
|
||||||
|
"""Response after saving messages (no agent execution)."""
|
||||||
|
user_message: MessageResponse
|
||||||
|
assistant_message: MessageResponse
|
||||||
|
total_tokens: int
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
"""
|
||||||
|
Conversation service - Business logic for conversation management.
|
||||||
|
|
||||||
|
Handles CRUD operations, context building, and summarization triggers.
|
||||||
|
"""
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from src.domains.agents.base import get_agent
|
||||||
|
from src.domains.conversations.models import Conversation, Message
|
||||||
|
from src.domains.conversations.summarize import generate_summary
|
||||||
|
from src.shared.config import get_settings
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
from src.shared.tokens import count_tokens
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationService:
|
||||||
|
"""
|
||||||
|
Service for managing conversations and messages.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- CRUD operations for conversations and messages
|
||||||
|
- Context building for agent prompts
|
||||||
|
- Automatic summarization when approaching token limits
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession):
|
||||||
|
"""
|
||||||
|
Initialize with database session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Async SQLAlchemy session
|
||||||
|
"""
|
||||||
|
self.session = session
|
||||||
|
self.settings = get_settings()
|
||||||
|
|
||||||
|
# === Conversation CRUD ===
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
agent_type: str = "explore",
|
||||||
|
working_dir: str = ".",
|
||||||
|
title: str | None = None,
|
||||||
|
) -> Conversation:
|
||||||
|
"""
|
||||||
|
Create a new conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's user ID
|
||||||
|
agent_type: Type of agent for this conversation
|
||||||
|
working_dir: Working directory for agent
|
||||||
|
title: Optional title (auto-generated from first message if None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created Conversation object
|
||||||
|
"""
|
||||||
|
conversation = Conversation(
|
||||||
|
user_id=user_id,
|
||||||
|
agent_type=agent_type,
|
||||||
|
working_dir=working_dir,
|
||||||
|
title=title,
|
||||||
|
)
|
||||||
|
self.session.add(conversation)
|
||||||
|
await self.session.flush()
|
||||||
|
logger.info(f"Created conversation {conversation.id} for user {user_id}")
|
||||||
|
return conversation
|
||||||
|
|
||||||
|
async def get(self, conversation_id: UUID) -> Conversation | None:
|
||||||
|
"""Get conversation by ID without messages."""
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(Conversation).where(Conversation.id == conversation_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_with_messages(self, conversation_id: UUID) -> Conversation | None:
|
||||||
|
"""Get conversation by ID with messages loaded."""
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(Conversation)
|
||||||
|
.options(selectinload(Conversation.messages))
|
||||||
|
.where(Conversation.id == conversation_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_by_user(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[Conversation], int]:
|
||||||
|
"""
|
||||||
|
List conversations for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID to filter by
|
||||||
|
limit: Maximum results to return
|
||||||
|
offset: Offset for pagination
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (conversations, total_count)
|
||||||
|
"""
|
||||||
|
# Get total count
|
||||||
|
count_result = await self.session.execute(
|
||||||
|
select(func.count(Conversation.id))
|
||||||
|
.where(Conversation.user_id == user_id)
|
||||||
|
)
|
||||||
|
total = count_result.scalar() or 0
|
||||||
|
|
||||||
|
# Get conversations
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(Conversation)
|
||||||
|
.where(Conversation.user_id == user_id)
|
||||||
|
.order_by(Conversation.updated_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
conversations = list(result.scalars().all())
|
||||||
|
|
||||||
|
return conversations, total
|
||||||
|
|
||||||
|
async def delete(self, conversation_id: UUID) -> bool:
|
||||||
|
"""Delete a conversation and all its messages."""
|
||||||
|
conversation = await self.get(conversation_id)
|
||||||
|
if conversation:
|
||||||
|
await self.session.delete(conversation)
|
||||||
|
logger.info(f"Deleted conversation {conversation_id}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# === Message Operations ===
|
||||||
|
|
||||||
|
async def add_message(
|
||||||
|
self,
|
||||||
|
conversation_id: UUID,
|
||||||
|
role: str,
|
||||||
|
content: str,
|
||||||
|
) -> Message:
|
||||||
|
"""
|
||||||
|
Add a message to a conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: Conversation to add to
|
||||||
|
role: Message role (user, assistant, system, summary)
|
||||||
|
content: Message content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created Message object
|
||||||
|
"""
|
||||||
|
# Count tokens
|
||||||
|
token_count = count_tokens(content)
|
||||||
|
|
||||||
|
message = Message(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
role=role,
|
||||||
|
content=content,
|
||||||
|
token_count=token_count,
|
||||||
|
)
|
||||||
|
self.session.add(message)
|
||||||
|
|
||||||
|
# Update conversation total tokens
|
||||||
|
conversation = await self.get(conversation_id)
|
||||||
|
if conversation:
|
||||||
|
conversation.total_tokens += token_count
|
||||||
|
|
||||||
|
# Auto-generate title from first user message
|
||||||
|
if conversation.title is None and role == "user":
|
||||||
|
conversation.title = content[:100] + ("..." if len(content) > 100 else "")
|
||||||
|
|
||||||
|
await self.session.flush()
|
||||||
|
return message
|
||||||
|
|
||||||
|
# === Context Building ===
|
||||||
|
|
||||||
|
def build_context_prompt(
|
||||||
|
self,
|
||||||
|
messages: list[Message],
|
||||||
|
current_message: str,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Build a prompt with conversation context.
|
||||||
|
|
||||||
|
Includes summary (if exists) and recent messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: All conversation messages
|
||||||
|
current_message: The current user message
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted prompt with context
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Find most recent summary
|
||||||
|
summaries = [m for m in messages if m.is_summary]
|
||||||
|
if summaries:
|
||||||
|
latest_summary = summaries[-1]
|
||||||
|
parts.append(
|
||||||
|
f"<conversation_summary>\n{latest_summary.content}\n</conversation_summary>"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get recent non-summary messages
|
||||||
|
recent = [m for m in messages if not m.is_summary]
|
||||||
|
keep_count = self.settings.keep_recent_messages
|
||||||
|
recent = recent[-keep_count:] if len(recent) > keep_count else recent
|
||||||
|
|
||||||
|
if recent:
|
||||||
|
parts.append("<recent_conversation>")
|
||||||
|
for msg in recent:
|
||||||
|
role_label = msg.role.upper()
|
||||||
|
parts.append(f"{role_label}: {msg.content}")
|
||||||
|
parts.append("</recent_conversation>")
|
||||||
|
|
||||||
|
# Add current message
|
||||||
|
parts.append(f"<current_request>\n{current_message}\n</current_request>")
|
||||||
|
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
# === Agent Integration ===
|
||||||
|
|
||||||
|
async def get_agent_response(
|
||||||
|
self,
|
||||||
|
conversation_id: UUID,
|
||||||
|
user_message: str,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Get agent response with conversation context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: Conversation ID
|
||||||
|
user_message: Current user message
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Agent's response text
|
||||||
|
"""
|
||||||
|
conversation = await self.get_with_messages(conversation_id)
|
||||||
|
if not conversation:
|
||||||
|
raise ValueError(f"Conversation {conversation_id} not found")
|
||||||
|
|
||||||
|
agent = get_agent(conversation.agent_type)
|
||||||
|
if not agent:
|
||||||
|
raise ValueError(f"Unknown agent type: {conversation.agent_type}")
|
||||||
|
|
||||||
|
# Build context prompt
|
||||||
|
context_prompt = self.build_context_prompt(
|
||||||
|
conversation.messages,
|
||||||
|
user_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run agent
|
||||||
|
response = await agent.run(
|
||||||
|
context_prompt,
|
||||||
|
working_dir=conversation.working_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
# === Summarization ===
|
||||||
|
|
||||||
|
async def should_summarize(self, conversation_id: UUID) -> bool:
|
||||||
|
"""
|
||||||
|
Check if conversation needs summarization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: Conversation to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if summarization should be triggered
|
||||||
|
"""
|
||||||
|
conversation = await self.get(conversation_id)
|
||||||
|
if not conversation:
|
||||||
|
return False
|
||||||
|
|
||||||
|
threshold = self.settings.max_context_tokens * self.settings.summarization_threshold
|
||||||
|
return conversation.total_tokens > threshold
|
||||||
|
|
||||||
|
async def summarize_if_needed(self, conversation_id: UUID) -> bool:
|
||||||
|
"""
|
||||||
|
Summarize old messages if approaching token limit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: Conversation to check and potentially summarize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if summarization was performed
|
||||||
|
"""
|
||||||
|
if not await self.should_summarize(conversation_id):
|
||||||
|
return False
|
||||||
|
|
||||||
|
conversation = await self.get_with_messages(conversation_id)
|
||||||
|
if not conversation:
|
||||||
|
return False
|
||||||
|
|
||||||
|
messages = conversation.messages
|
||||||
|
keep_count = self.settings.keep_recent_messages
|
||||||
|
|
||||||
|
# Don't summarize if not enough messages
|
||||||
|
if len(messages) <= keep_count + 1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get messages to summarize (exclude recent and existing summaries)
|
||||||
|
non_summary_msgs = [m for m in messages if not m.is_summary]
|
||||||
|
to_summarize = non_summary_msgs[:-keep_count]
|
||||||
|
|
||||||
|
if not to_summarize:
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Summarizing {len(to_summarize)} messages in conversation {conversation_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate summary
|
||||||
|
summary_text = await generate_summary(
|
||||||
|
to_summarize,
|
||||||
|
working_dir=conversation.working_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get ID of last summarized message
|
||||||
|
last_summarized_id = to_summarize[-1].id
|
||||||
|
|
||||||
|
# Calculate tokens being removed
|
||||||
|
removed_tokens = sum(m.token_count for m in to_summarize)
|
||||||
|
summary_tokens = count_tokens(summary_text)
|
||||||
|
|
||||||
|
# Add summary message
|
||||||
|
summary_message = Message(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
role="summary",
|
||||||
|
content=summary_text,
|
||||||
|
token_count=summary_tokens,
|
||||||
|
is_summary=True,
|
||||||
|
summarizes_up_to=last_summarized_id,
|
||||||
|
)
|
||||||
|
self.session.add(summary_message)
|
||||||
|
|
||||||
|
# Mark old messages as summarized (soft delete by excluding from context)
|
||||||
|
for msg in to_summarize:
|
||||||
|
msg.is_summary = True # Reuse flag to mark as "summarized away"
|
||||||
|
|
||||||
|
# Update conversation token count
|
||||||
|
conversation.total_tokens = conversation.total_tokens - removed_tokens + summary_tokens
|
||||||
|
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Summarization complete: removed {removed_tokens} tokens, "
|
||||||
|
f"added {summary_tokens} token summary"
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""
|
||||||
|
Context summarization for conversations.
|
||||||
|
|
||||||
|
Compresses old messages when approaching token limits.
|
||||||
|
"""
|
||||||
|
from src.domains.conversations.models import Message
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
SUMMARIZE_PROMPT = """Summarize this conversation history concisely for context preservation.
|
||||||
|
|
||||||
|
Focus on:
|
||||||
|
- Key decisions made and their rationale
|
||||||
|
- Important files, functions, or code discussed
|
||||||
|
- Current task state and progress
|
||||||
|
- Any unresolved questions or blockers
|
||||||
|
- Technical details that would be needed to continue the work
|
||||||
|
|
||||||
|
Keep the summary under 500 words. Be factual and technical, not conversational.
|
||||||
|
Preserve specific file paths, function names, and code references.
|
||||||
|
|
||||||
|
CONVERSATION HISTORY:
|
||||||
|
{history}
|
||||||
|
|
||||||
|
CONCISE SUMMARY:"""
|
||||||
|
|
||||||
|
|
||||||
|
def format_messages_for_summary(messages: list[Message]) -> str:
|
||||||
|
"""
|
||||||
|
Format messages into a string for summarization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: List of Message objects to format
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted conversation string
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
for msg in messages:
|
||||||
|
if msg.is_summary:
|
||||||
|
parts.append(f"[Previous Summary]: {msg.content}")
|
||||||
|
else:
|
||||||
|
role = msg.role.upper()
|
||||||
|
parts.append(f"{role}: {msg.content}")
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_summary(
|
||||||
|
messages: list[Message],
|
||||||
|
working_dir: str = "."
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate a summary of conversation messages using the Explore agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: Messages to summarize
|
||||||
|
working_dir: Working directory for agent context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Summary text
|
||||||
|
"""
|
||||||
|
from src.domains.agents.explore import explore
|
||||||
|
|
||||||
|
history = format_messages_for_summary(messages)
|
||||||
|
prompt = SUMMARIZE_PROMPT.format(history=history)
|
||||||
|
|
||||||
|
logger.info(f"Generating summary for {len(messages)} messages")
|
||||||
|
|
||||||
|
try:
|
||||||
|
summary = await explore(prompt, working_dir=working_dir)
|
||||||
|
return summary.strip()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Summary generation failed: {e}")
|
||||||
|
# Fallback: create a simple truncated summary
|
||||||
|
return _fallback_summary(messages)
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_summary(messages: list[Message]) -> str:
|
||||||
|
"""
|
||||||
|
Create a simple fallback summary if agent summarization fails.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: Messages to summarize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Basic summary string
|
||||||
|
"""
|
||||||
|
# Take first and last few messages
|
||||||
|
if len(messages) <= 4:
|
||||||
|
return format_messages_for_summary(messages)
|
||||||
|
|
||||||
|
first_two = messages[:2]
|
||||||
|
last_two = messages[-2:]
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
"Conversation started with:",
|
||||||
|
format_messages_for_summary(first_two),
|
||||||
|
f"\n[... {len(messages) - 4} messages omitted ...]\n",
|
||||||
|
"Most recent exchange:",
|
||||||
|
format_messages_for_summary(last_two),
|
||||||
|
]
|
||||||
|
return "\n".join(parts)
|
||||||
@@ -6,8 +6,9 @@ main.py only includes this root_router.
|
|||||||
"""
|
"""
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from src.domains.health.router import router as health_router
|
|
||||||
from src.domains.agents.router import router as agents_router
|
from src.domains.agents.router import router as agents_router
|
||||||
|
from src.domains.conversations.router import router as conversations_router
|
||||||
|
from src.domains.health.router import router as health_router
|
||||||
|
|
||||||
# from src.domains.auth.router import router as auth_router
|
# from src.domains.auth.router import router as auth_router
|
||||||
# from src.domains.tools.router import router as tools_router
|
# from src.domains.tools.router import router as tools_router
|
||||||
@@ -20,6 +21,9 @@ root_router.include_router(health_router)
|
|||||||
# Agents domain (prefix defined in router)
|
# Agents domain (prefix defined in router)
|
||||||
root_router.include_router(agents_router)
|
root_router.include_router(agents_router)
|
||||||
|
|
||||||
|
# Conversations domain (prefix defined in router)
|
||||||
|
root_router.include_router(conversations_router)
|
||||||
|
|
||||||
# Auth domain
|
# Auth domain
|
||||||
# root_router.include_router(auth_router, prefix="/auth", tags=["Auth"])
|
# root_router.include_router(auth_router, prefix="/auth", tags=["Auth"])
|
||||||
|
|
||||||
|
|||||||
@@ -4,19 +4,19 @@ Tool implementations for agent use.
|
|||||||
All tools inherit from BaseTool and return ToolResult.
|
All tools inherit from BaseTool and return ToolResult.
|
||||||
"""
|
"""
|
||||||
from src.domains.tools.base import BaseTool, ToolResult
|
from src.domains.tools.base import BaseTool, ToolResult
|
||||||
from src.domains.tools.file import ReadFileTool, GlobFilesTool, EditFileTool, WriteFileTool
|
from src.domains.tools.file import EditFileTool, GlobFilesTool, ReadFileTool, WriteFileTool
|
||||||
from src.domains.tools.search import GrepContentTool, WebSearchTool
|
from src.domains.tools.search import GrepContentTool, WebSearchTool
|
||||||
from src.domains.tools.shell import BashReadOnlyTool, BashTool
|
from src.domains.tools.shell import BashReadOnlyTool, BashTool
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BaseTool",
|
"BaseTool",
|
||||||
"ToolResult",
|
|
||||||
"ReadFileTool",
|
|
||||||
"GlobFilesTool",
|
|
||||||
"EditFileTool",
|
|
||||||
"WriteFileTool",
|
|
||||||
"GrepContentTool",
|
|
||||||
"WebSearchTool",
|
|
||||||
"BashReadOnlyTool",
|
"BashReadOnlyTool",
|
||||||
"BashTool",
|
"BashTool",
|
||||||
|
"EditFileTool",
|
||||||
|
"GlobFilesTool",
|
||||||
|
"GrepContentTool",
|
||||||
|
"ReadFileTool",
|
||||||
|
"ToolResult",
|
||||||
|
"WebSearchTool",
|
||||||
|
"WriteFileTool",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -32,10 +32,7 @@ class ToolResult:
|
|||||||
if not self.success:
|
if not self.success:
|
||||||
return f"ERROR: {self.error}"
|
return f"ERROR: {self.error}"
|
||||||
|
|
||||||
if isinstance(self.data, str):
|
content = self.data if isinstance(self.data, str) else str(self.data)
|
||||||
content = self.data
|
|
||||||
else:
|
|
||||||
content = str(self.data)
|
|
||||||
|
|
||||||
if len(content) > max_length:
|
if len(content) > max_length:
|
||||||
self.truncated = True
|
self.truncated = True
|
||||||
@@ -90,6 +87,21 @@ class BaseTool(ABC):
|
|||||||
"""
|
"""
|
||||||
Execute the tool with given arguments.
|
Execute the tool with given arguments.
|
||||||
|
|
||||||
|
Note on the `# type: ignore[override]` each implementation carries.
|
||||||
|
Every tool narrows this to its own named parameters — read_file takes
|
||||||
|
file_path/offset/limit, bash takes command/timeout — which mypy reports
|
||||||
|
as an LSP violation, and strictly it is: a caller holding a BaseTool
|
||||||
|
could call .execute(anything=1) and no implementation would accept it.
|
||||||
|
|
||||||
|
Nothing does. Checked: no reference anywhere in this package is typed as
|
||||||
|
BaseTool, and every call site constructs the concrete tool and passes its
|
||||||
|
specific arguments. What this abstract method buys is the runtime
|
||||||
|
guarantee that a tool without an execute cannot be instantiated, and that
|
||||||
|
is worth keeping.
|
||||||
|
|
||||||
|
The suppressions are per-site rather than a disable_error_code for the
|
||||||
|
whole package, so a future override that IS unsound still gets caught.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ToolResult with success status and data or error
|
ToolResult with success status and data or error
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
File operation tools.
|
File operation tools.
|
||||||
"""
|
"""
|
||||||
from src.domains.tools.file.read import ReadFileTool
|
|
||||||
from src.domains.tools.file.glob import GlobFilesTool
|
|
||||||
from src.domains.tools.file.edit import EditFileTool
|
from src.domains.tools.file.edit import EditFileTool
|
||||||
|
from src.domains.tools.file.glob import GlobFilesTool
|
||||||
|
from src.domains.tools.file.read import ReadFileTool
|
||||||
from src.domains.tools.file.write import WriteFileTool
|
from src.domains.tools.file.write import WriteFileTool
|
||||||
|
|
||||||
__all__ = ["ReadFileTool", "GlobFilesTool", "EditFileTool", "WriteFileTool"]
|
__all__ = ["EditFileTool", "GlobFilesTool", "ReadFileTool", "WriteFileTool"]
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
File editing tool with find-and-replace functionality.
|
File editing tool with find-and-replace functionality.
|
||||||
"""
|
"""
|
||||||
import difflib
|
import difflib
|
||||||
import aiofiles
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiofiles
|
||||||
|
|
||||||
from src.domains.tools.base import BaseTool, ToolResult
|
from src.domains.tools.base import BaseTool, ToolResult
|
||||||
from src.shared.logging import logged, get_logger
|
from src.shared.logging import get_logger, logged
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -95,7 +96,7 @@ Examples:
|
|||||||
return "".join(diff)
|
return "".join(diff)
|
||||||
|
|
||||||
@logged()
|
@logged()
|
||||||
async def execute(
|
async def execute( # type: ignore[override] # see BaseTool.execute
|
||||||
self,
|
self,
|
||||||
file_path: str,
|
file_path: str,
|
||||||
old_string: str,
|
old_string: str,
|
||||||
@@ -147,15 +148,15 @@ Examples:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Read file content
|
# Read file content
|
||||||
async with aiofiles.open(path, 'r', encoding='utf-8', errors='replace') as f:
|
async with aiofiles.open(path, encoding='utf-8', errors='replace') as f:
|
||||||
content = await f.read()
|
content = await f.read()
|
||||||
|
|
||||||
# Check if old_string exists
|
# Check if old_string exists
|
||||||
count = content.count(old_string)
|
count = content.count(old_string)
|
||||||
if count == 0:
|
if count == 0:
|
||||||
return self._error(
|
return self._error(
|
||||||
f"old_string not found in file. "
|
"old_string not found in file. "
|
||||||
f"Make sure to match exact whitespace and indentation."
|
"Make sure to match exact whitespace and indentation."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check uniqueness if replace_all is False
|
# Check uniqueness if replace_all is False
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from src.domains.tools.base import BaseTool, ToolResult
|
from src.domains.tools.base import BaseTool, ToolResult
|
||||||
from src.domains.tools.gitignore import filter_gitignored
|
from src.domains.tools.gitignore import filter_gitignored
|
||||||
from src.shared.logging import logged, get_logger
|
from src.shared.logging import get_logger, logged
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ IMPORTANT:
|
|||||||
self.honor_gitignore = honor_gitignore
|
self.honor_gitignore = honor_gitignore
|
||||||
|
|
||||||
@logged()
|
@logged()
|
||||||
async def execute(
|
async def execute( # type: ignore[override] # see BaseTool.execute
|
||||||
self,
|
self,
|
||||||
pattern: str,
|
pattern: str,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
File reading tool with line number formatting and sandboxing.
|
File reading tool with line number formatting and sandboxing.
|
||||||
"""
|
"""
|
||||||
import aiofiles
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiofiles
|
||||||
|
|
||||||
from src.domains.tools.base import BaseTool, ToolResult
|
from src.domains.tools.base import BaseTool, ToolResult
|
||||||
from src.shared.logging import logged, get_logger
|
from src.shared.logging import get_logger, logged
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ IMPORTANT:
|
|||||||
self.max_line_length = max_line_length
|
self.max_line_length = max_line_length
|
||||||
|
|
||||||
@logged()
|
@logged()
|
||||||
async def execute(
|
async def execute( # type: ignore[override] # see BaseTool.execute
|
||||||
self,
|
self,
|
||||||
file_path: str,
|
file_path: str,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
@@ -87,7 +88,7 @@ IMPORTANT:
|
|||||||
return self._error(f"Not a file: {file_path}")
|
return self._error(f"Not a file: {file_path}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with aiofiles.open(path, 'r', encoding='utf-8', errors='replace') as f:
|
async with aiofiles.open(path, encoding='utf-8', errors='replace') as f:
|
||||||
content = await f.read()
|
content = await f.read()
|
||||||
|
|
||||||
lines = content.splitlines()
|
lines = content.splitlines()
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
File writing tool for creating and overwriting files.
|
File writing tool for creating and overwriting files.
|
||||||
"""
|
"""
|
||||||
import aiofiles
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiofiles
|
||||||
|
|
||||||
from src.domains.tools.base import BaseTool, ToolResult
|
from src.domains.tools.base import BaseTool, ToolResult
|
||||||
from src.shared.logging import logged, get_logger
|
from src.shared.logging import get_logger, logged
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ Examples:
|
|||||||
self.max_content_size = max_content_size
|
self.max_content_size = max_content_size
|
||||||
|
|
||||||
@logged()
|
@logged()
|
||||||
async def execute(
|
async def execute( # type: ignore[override] # see BaseTool.execute
|
||||||
self,
|
self,
|
||||||
file_path: str,
|
file_path: str,
|
||||||
content: str
|
content: str
|
||||||
|
|||||||
@@ -90,10 +90,7 @@ class GitignoreFilter:
|
|||||||
|
|
||||||
# Make path relative to root for matching
|
# Make path relative to root for matching
|
||||||
try:
|
try:
|
||||||
if path.is_absolute():
|
rel_path = path.resolve().relative_to(self.root_dir) if path.is_absolute() else path
|
||||||
rel_path = path.resolve().relative_to(self.root_dir)
|
|
||||||
else:
|
|
||||||
rel_path = path
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# Path is not under root_dir, don't filter
|
# Path is not under root_dir, don't filter
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import Literal
|
|||||||
|
|
||||||
from src.domains.tools.base import BaseTool, ToolResult
|
from src.domains.tools.base import BaseTool, ToolResult
|
||||||
from src.domains.tools.gitignore import filter_gitignored
|
from src.domains.tools.gitignore import filter_gitignored
|
||||||
from src.shared.logging import logged, get_logger
|
from src.shared.logging import get_logger, logged
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ IMPORTANT:
|
|||||||
self.honor_gitignore = honor_gitignore
|
self.honor_gitignore = honor_gitignore
|
||||||
|
|
||||||
@logged()
|
@logged()
|
||||||
async def execute(
|
async def execute( # type: ignore[override] # see BaseTool.execute
|
||||||
self,
|
self,
|
||||||
pattern: str,
|
pattern: str,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ Web search tool using SearXNG.
|
|||||||
"""
|
"""
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from src.domains.tools.base import BaseTool, ToolResult
|
from src.domains.tools.base import BaseTool, ToolResult
|
||||||
from src.shared.config import get_settings
|
from src.shared.config import get_settings
|
||||||
from src.shared.logging import logged, get_logger
|
from src.shared.logging import get_logger, logged
|
||||||
|
from src.shared.retry import retry_async
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -73,9 +75,30 @@ IMPORTANT:
|
|||||||
self.searxng_url = (searxng_url or settings.searxng_url).rstrip("/")
|
self.searxng_url = (searxng_url or settings.searxng_url).rstrip("/")
|
||||||
self.timeout = timeout or settings.searxng_timeout
|
self.timeout = timeout or settings.searxng_timeout
|
||||||
self.max_results = max_results
|
self.max_results = max_results
|
||||||
|
# Retry settings
|
||||||
|
self.retry_max_attempts = settings.retry_max_attempts
|
||||||
|
self.retry_base_delay = settings.retry_base_delay
|
||||||
|
self.retry_max_delay = settings.retry_max_delay
|
||||||
|
|
||||||
|
async def _fetch_search_results(self, params: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Fetch search results from SearXNG.
|
||||||
|
|
||||||
|
This method is wrapped with retry logic for transient failures.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{self.searxng_url}/search",
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
# httpx types .json() as Any. Naming the shape here keeps the Any from
|
||||||
|
# travelling into every caller of this method.
|
||||||
|
payload: dict[Any, Any] = response.json()
|
||||||
|
return payload
|
||||||
|
|
||||||
@logged()
|
@logged()
|
||||||
async def execute(
|
async def execute( # type: ignore[override] # see BaseTool.execute
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
num_results: int = 5,
|
num_results: int = 5,
|
||||||
@@ -111,16 +134,15 @@ IMPORTANT:
|
|||||||
params["categories"] = categories
|
params["categories"] = categories
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
data = await retry_async(
|
||||||
response = await client.get(
|
self._fetch_search_results,
|
||||||
f"{self.searxng_url}/search",
|
params,
|
||||||
params=params,
|
max_attempts=self.retry_max_attempts,
|
||||||
|
base_delay=self.retry_base_delay,
|
||||||
|
max_delay=self.retry_max_delay,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
except httpx.TimeoutException:
|
except httpx.TimeoutException:
|
||||||
return self._error(f"Search timed out after {self.timeout}s")
|
return self._error(f"Search timed out after {self.timeout}s (all retries exhausted)")
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
return self._error(f"Search failed: HTTP {e.response.status_code}")
|
return self._error(f"Search failed: HTTP {e.response.status_code}")
|
||||||
except httpx.RequestError as e:
|
except httpx.RequestError as e:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import shlex
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from src.domains.tools.base import BaseTool, ToolResult
|
from src.domains.tools.base import BaseTool, ToolResult
|
||||||
from src.shared.logging import logged, get_logger
|
from src.shared.logging import get_logger, logged
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ Examples:
|
|||||||
self.max_output_size = max_output_size
|
self.max_output_size = max_output_size
|
||||||
|
|
||||||
@logged()
|
@logged()
|
||||||
async def execute(
|
async def execute( # type: ignore[override] # see BaseTool.execute
|
||||||
self,
|
self,
|
||||||
command: str,
|
command: str,
|
||||||
cwd: str | None = None,
|
cwd: str | None = None,
|
||||||
@@ -190,7 +190,7 @@ Examples:
|
|||||||
exit_code=proc.returncode
|
exit_code=proc.returncode
|
||||||
)
|
)
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
return self._error(f"Command timed out after {timeout} seconds")
|
return self._error(f"Command timed out after {timeout} seconds")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error executing command: {command}")
|
logger.exception(f"Error executing command: {command}")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import shlex
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from src.domains.tools.base import BaseTool, ToolResult
|
from src.domains.tools.base import BaseTool, ToolResult
|
||||||
from src.shared.logging import logged, get_logger
|
from src.shared.logging import get_logger, logged
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@ Examples:
|
|||||||
self.max_output_size = max_output_size
|
self.max_output_size = max_output_size
|
||||||
|
|
||||||
@logged()
|
@logged()
|
||||||
async def execute(
|
async def execute( # type: ignore[override] # see BaseTool.execute
|
||||||
self,
|
self,
|
||||||
command: str,
|
command: str,
|
||||||
cwd: str | None = None,
|
cwd: str | None = None,
|
||||||
@@ -228,7 +228,7 @@ Examples:
|
|||||||
exit_code=proc.returncode
|
exit_code=proc.returncode
|
||||||
)
|
)
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
return self._error(f"Command timed out after {timeout} seconds")
|
return self._error(f"Command timed out after {timeout} seconds")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error executing command: {command}")
|
logger.exception(f"Error executing command: {command}")
|
||||||
@@ -340,7 +340,7 @@ Examples:
|
|||||||
# Handle git with flags before subcommand (e.g., git -C path status)
|
# Handle git with flags before subcommand (e.g., git -C path status)
|
||||||
if git_subcommand.startswith("-"):
|
if git_subcommand.startswith("-"):
|
||||||
# Find the actual subcommand
|
# Find the actual subcommand
|
||||||
for i, token in enumerate(tokens[2:], 2):
|
for _i, token in enumerate(tokens[2:], 2):
|
||||||
if not token.startswith("-"):
|
if not token.startswith("-"):
|
||||||
git_subcommand = token
|
git_subcommand = token
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -31,13 +31,18 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info(f"Port: {settings.port}")
|
logger.info(f"Port: {settings.port}")
|
||||||
logger.info(f"Ollama: {settings.ollama_url}")
|
logger.info(f"Ollama: {settings.ollama_url}")
|
||||||
logger.info(f"Agent model: {settings.ollama_agent_model}")
|
logger.info(f"Agent model: {settings.ollama_agent_model}")
|
||||||
|
logger.info(f"Database: {settings.database_url}")
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
# TODO: Initialize resources (LLM clients, etc.)
|
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
|
from src.db import get_database
|
||||||
|
try:
|
||||||
|
database = get_database()
|
||||||
|
await database.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("Shutting down")
|
logger.info("Shutting down")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -54,17 +54,54 @@ class _SanitizedAsyncOpenAI(AsyncOpenAI):
|
|||||||
super().__init__(api_key="ollama", **kwargs)
|
super().__init__(api_key="ollama", **kwargs)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def chat(self) -> "_SanitizedChat":
|
def chat(self) -> "_SanitizedChat": # type: ignore[override]
|
||||||
"""Return sanitized chat interface."""
|
"""Return sanitized chat interface.
|
||||||
|
|
||||||
|
Deliberately incompatible with AsyncOpenAI.chat, which is a Chat
|
||||||
|
resource. Replacing it is the entire mechanism of this class: Ollama
|
||||||
|
rejects assistant messages carrying content: null alongside tool_calls,
|
||||||
|
so every completion has to pass through the sanitiser. Typing it as the
|
||||||
|
parent's Chat would describe an object this class does not return.
|
||||||
|
|
||||||
|
The suppression is on this member alone; the rest of the client keeps
|
||||||
|
its inherited types.
|
||||||
|
"""
|
||||||
return _SanitizedChat(self)
|
return _SanitizedChat(self)
|
||||||
|
|
||||||
|
|
||||||
|
def _parent_chat(client: AsyncOpenAI) -> Any:
|
||||||
|
"""Get AsyncOpenAI's own `chat`, bypassing the subclass override.
|
||||||
|
|
||||||
|
This read the descriptor's `.fget` until 2026-08-11, which is the property
|
||||||
|
API. openai made `chat` a functools.cached_property, whose getter is `.func`,
|
||||||
|
so the call raised AttributeError the moment anything touched `.chat` — that
|
||||||
|
is, on the first completion any agent tried to make. Verified broken in the
|
||||||
|
running container on openai 2.46.0 as well as locally on 2.15.0.
|
||||||
|
|
||||||
|
It went unnoticed because the endpoints that reach it had served no requests
|
||||||
|
in 30 days, and because the line carried a bare `# type: ignore` that
|
||||||
|
suppressed exactly the complaint that would have flagged it.
|
||||||
|
|
||||||
|
Reading whichever getter the descriptor actually exposes keeps this working
|
||||||
|
across that change and the reverse of it, and raises something legible if
|
||||||
|
openai adopts a third shape.
|
||||||
|
"""
|
||||||
|
descriptor = AsyncOpenAI.__dict__["chat"]
|
||||||
|
getter = getattr(descriptor, "func", None) or getattr(descriptor, "fget", None)
|
||||||
|
if getter is None: # pragma: no cover - defensive
|
||||||
|
raise TypeError(
|
||||||
|
f"AsyncOpenAI.chat is a {type(descriptor).__name__} with neither "
|
||||||
|
"'func' nor 'fget'; the sanitising wrapper needs updating"
|
||||||
|
)
|
||||||
|
return getter(client)
|
||||||
|
|
||||||
|
|
||||||
class _SanitizedChat:
|
class _SanitizedChat:
|
||||||
"""Chat interface wrapper with sanitized completions."""
|
"""Chat interface wrapper with sanitized completions."""
|
||||||
|
|
||||||
def __init__(self, client: _SanitizedAsyncOpenAI):
|
def __init__(self, client: _SanitizedAsyncOpenAI):
|
||||||
self._client = client
|
self._client = client
|
||||||
self._original_chat = AsyncOpenAI.chat.fget(client) # type: ignore
|
self._original_chat = _parent_chat(client)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def completions(self) -> "_SanitizedCompletions":
|
def completions(self) -> "_SanitizedCompletions":
|
||||||
@@ -110,8 +147,10 @@ def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|||||||
msg_copy = dict(msg)
|
msg_copy = dict(msg)
|
||||||
|
|
||||||
# Fix null content in assistant messages with tool calls
|
# Fix null content in assistant messages with tool calls
|
||||||
if msg_copy.get("role") == "assistant":
|
if (
|
||||||
if msg_copy.get("content") is None and msg_copy.get("tool_calls"):
|
msg_copy.get("role") == "assistant"
|
||||||
|
and msg_copy.get("content") is None and msg_copy.get("tool_calls")
|
||||||
|
):
|
||||||
msg_copy["content"] = ""
|
msg_copy["content"] = ""
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Sanitized null content, tool_calls={len(msg_copy['tool_calls'])}"
|
f"Sanitized null content, tool_calls={len(msg_copy['tool_calls'])}"
|
||||||
|
|||||||
@@ -64,15 +64,15 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# LLM - Ollama (always hot in VRAM on tower-of-joy)
|
# LLM - Ollama (always hot in VRAM on tower-of-joy)
|
||||||
ollama_url: str = "http://192.168.86.149:11434"
|
ollama_url: str = "http://192.168.86.149:11434"
|
||||||
ollama_agent_model: str = "mistral-nemo-large:latest"
|
ollama_agent_model: str = "gemma4:e2b"
|
||||||
ollama_embed_model: str = "nomic-embed-text:latest"
|
ollama_embed_model: str = "nomic-embed-text:latest"
|
||||||
|
|
||||||
# Auth - Tatlock integration
|
# Auth - Tatlock integration
|
||||||
tatlock_api_url: str | None = "http://192.168.86.149:8000"
|
tatlock_api_url: str | None = "http://tatlock:8000"
|
||||||
internal_api_key: str | None = None
|
internal_api_key: str | None = None
|
||||||
|
|
||||||
# Web search - SearXNG (use SEARXNG_URL env var to override)
|
# Web search - SearXNG (use SEARXNG_URL env var to override)
|
||||||
searxng_url: str = "http://192.168.86.149:8087"
|
searxng_url: str = "http://searxng:8080"
|
||||||
searxng_timeout: int = 10
|
searxng_timeout: int = 10
|
||||||
|
|
||||||
# Tool execution
|
# Tool execution
|
||||||
@@ -80,9 +80,20 @@ class Settings(BaseSettings):
|
|||||||
sandbox_enabled: bool = True
|
sandbox_enabled: bool = True
|
||||||
allowed_paths: list[str] | None = None
|
allowed_paths: list[str] | None = None
|
||||||
|
|
||||||
# Sessions
|
# Database
|
||||||
|
database_url: str = "sqlite+aiosqlite:///./webber.db"
|
||||||
|
|
||||||
|
# Sessions & Context
|
||||||
session_ttl_hours: int = 24
|
session_ttl_hours: int = 24
|
||||||
max_context_tokens: int = 128000
|
max_context_tokens: int = 128000
|
||||||
|
summarization_threshold: float = 0.8 # Summarize at 80% of max tokens
|
||||||
|
summarization_target_tokens: int = 500 # Target summary size
|
||||||
|
keep_recent_messages: int = 6 # Messages to keep unsummarized (3 turns)
|
||||||
|
|
||||||
|
# Retry logic
|
||||||
|
retry_max_attempts: int = 3 # Max retry attempts for transient failures
|
||||||
|
retry_base_delay: float = 1.0 # Base delay in seconds
|
||||||
|
retry_max_delay: float = 30.0 # Maximum delay in seconds
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from collections.abc import Callable
|
|||||||
from contextvars import ContextVar, Token
|
from contextvars import ContextVar, Token
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any, ParamSpec, TypeVar, cast
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
# === Trace Context ===
|
# === Trace Context ===
|
||||||
@@ -80,12 +81,21 @@ def get_logger(name: str) -> logging.Logger:
|
|||||||
|
|
||||||
# === Decorator ===
|
# === Decorator ===
|
||||||
|
|
||||||
|
# @logged wraps ~24 functions across this package. Untyped, its decorator
|
||||||
|
# erased every one of their signatures, so mypy saw `Any` coming back from
|
||||||
|
# annotated functions like `ToolResult.execute() -> ToolResult`. That surfaced
|
||||||
|
# as 33 no-any-return errors scattered across the tools and agents — each
|
||||||
|
# reading like a local annotation slip, all of them this one decorator.
|
||||||
|
P = ParamSpec("P")
|
||||||
|
R = TypeVar("R")
|
||||||
|
|
||||||
|
|
||||||
def logged(
|
def logged(
|
||||||
logger: logging.Logger | None = None,
|
logger: logging.Logger | None = None,
|
||||||
slow_threshold_ms: float = 100.0,
|
slow_threshold_ms: float = 100.0,
|
||||||
warn_threshold_ms: float = 500.0,
|
warn_threshold_ms: float = 500.0,
|
||||||
include_args: bool = False,
|
include_args: bool = False,
|
||||||
):
|
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
||||||
"""
|
"""
|
||||||
Decorator for automatic function logging with temporal benchmarking.
|
Decorator for automatic function logging with temporal benchmarking.
|
||||||
|
|
||||||
@@ -102,7 +112,7 @@ def logged(
|
|||||||
@logged(slow_threshold_ms=50, warn_threshold_ms=200)
|
@logged(slow_threshold_ms=50, warn_threshold_ms=200)
|
||||||
def critical_path(): ...
|
def critical_path(): ...
|
||||||
"""
|
"""
|
||||||
def decorator(func: Callable):
|
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
||||||
nonlocal logger
|
nonlocal logger
|
||||||
if logger is None:
|
if logger is None:
|
||||||
logger = logging.getLogger(func.__module__)
|
logger = logging.getLogger(func.__module__)
|
||||||
@@ -140,7 +150,7 @@ def logged(
|
|||||||
logger.debug(f"[{tid}] {func_name} completed in {duration:.2f}ms")
|
logger.debug(f"[{tid}] {func_name} completed in {duration:.2f}ms")
|
||||||
|
|
||||||
@functools.wraps(func)
|
@functools.wraps(func)
|
||||||
async def async_wrapper(*args, **kwargs):
|
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
|
||||||
span = _create_span()
|
span = _create_span()
|
||||||
token = _current_span.set(span)
|
token = _current_span.set(span)
|
||||||
|
|
||||||
@@ -150,7 +160,7 @@ def logged(
|
|||||||
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}")
|
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await func(*args, **kwargs)
|
result = await cast(Any, func(*args, **kwargs))
|
||||||
_log_completion(span)
|
_log_completion(span)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -160,7 +170,7 @@ def logged(
|
|||||||
_current_span.reset(token)
|
_current_span.reset(token)
|
||||||
|
|
||||||
@functools.wraps(func)
|
@functools.wraps(func)
|
||||||
def sync_wrapper(*args, **kwargs):
|
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||||
span = _create_span()
|
span = _create_span()
|
||||||
token = _current_span.set(span)
|
token = _current_span.set(span)
|
||||||
|
|
||||||
@@ -179,7 +189,11 @@ def logged(
|
|||||||
finally:
|
finally:
|
||||||
_current_span.reset(token)
|
_current_span.reset(token)
|
||||||
|
|
||||||
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
|
# The branch is chosen at decoration time; mypy cannot narrow R to a
|
||||||
|
# coroutine on the strength of iscoroutinefunction, so the union is
|
||||||
|
# asserted here once instead of at every call site.
|
||||||
|
chosen = async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
|
||||||
|
return cast(Callable[P, R], chosen)
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""
|
||||||
|
Retry utilities for handling transient failures.
|
||||||
|
|
||||||
|
Provides decorators and helpers for automatic retry with exponential backoff.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
# Exceptions that should trigger a retry
|
||||||
|
RETRYABLE_EXCEPTIONS = (
|
||||||
|
httpx.TimeoutException,
|
||||||
|
httpx.ConnectError,
|
||||||
|
httpx.ReadError,
|
||||||
|
httpx.WriteError,
|
||||||
|
httpx.ConnectTimeout,
|
||||||
|
httpx.ReadTimeout,
|
||||||
|
httpx.WriteTimeout,
|
||||||
|
httpx.PoolTimeout,
|
||||||
|
ConnectionError,
|
||||||
|
TimeoutError,
|
||||||
|
OSError, # Covers many network-related errors
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_retryable_http_status(status_code: int) -> bool:
|
||||||
|
"""
|
||||||
|
Check if an HTTP status code should trigger a retry.
|
||||||
|
|
||||||
|
Retryable:
|
||||||
|
- 429 Too Many Requests (rate limited)
|
||||||
|
- 500 Internal Server Error
|
||||||
|
- 502 Bad Gateway
|
||||||
|
- 503 Service Unavailable
|
||||||
|
- 504 Gateway Timeout
|
||||||
|
"""
|
||||||
|
return status_code in (429, 500, 502, 503, 504)
|
||||||
|
|
||||||
|
|
||||||
|
def is_retryable_exception(exc: Exception) -> bool:
|
||||||
|
"""Check if an exception should trigger a retry."""
|
||||||
|
if isinstance(exc, RETRYABLE_EXCEPTIONS):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check for retryable HTTP status codes
|
||||||
|
if isinstance(exc, httpx.HTTPStatusError):
|
||||||
|
return is_retryable_http_status(exc.response.status_code)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_backoff(
|
||||||
|
attempt: int,
|
||||||
|
base_delay: float = 1.0,
|
||||||
|
max_delay: float = 60.0,
|
||||||
|
jitter: bool = True,
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Calculate exponential backoff delay with optional jitter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attempt: Current attempt number (0-indexed)
|
||||||
|
base_delay: Base delay in seconds
|
||||||
|
max_delay: Maximum delay in seconds
|
||||||
|
jitter: Add random jitter to prevent thundering herd
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Delay in seconds
|
||||||
|
"""
|
||||||
|
# Exponential backoff: base_delay * 2^attempt
|
||||||
|
delay: float = min(base_delay * (2 ** attempt), max_delay)
|
||||||
|
|
||||||
|
if jitter:
|
||||||
|
# Add up to 25% random jitter
|
||||||
|
delay = delay * (0.75 + random.random() * 0.5)
|
||||||
|
|
||||||
|
return delay
|
||||||
|
|
||||||
|
|
||||||
|
def with_retry(
|
||||||
|
max_attempts: int = 3,
|
||||||
|
base_delay: float = 1.0,
|
||||||
|
max_delay: float = 60.0,
|
||||||
|
retryable_exceptions: tuple[type[Exception], ...] | None = None,
|
||||||
|
) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
|
||||||
|
"""
|
||||||
|
Decorator for async functions that should retry on transient failures.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_attempts: Maximum number of attempts (including initial)
|
||||||
|
base_delay: Base delay between retries in seconds
|
||||||
|
max_delay: Maximum delay between retries in seconds
|
||||||
|
retryable_exceptions: Additional exceptions to retry on
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decorated function with retry logic
|
||||||
|
|
||||||
|
Example:
|
||||||
|
@with_retry(max_attempts=3, base_delay=1.0)
|
||||||
|
async def fetch_data():
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(url)
|
||||||
|
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)
|
||||||
|
async def wrapper(*args: Any, **kwargs: Any) -> T:
|
||||||
|
last_exception: Exception | None = None
|
||||||
|
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
try:
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
except retry_on as e:
|
||||||
|
last_exception = e
|
||||||
|
should_retry = True
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
last_exception = e
|
||||||
|
should_retry = is_retryable_http_status(e.response.status_code)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# Non-retryable exception, re-raise immediately
|
||||||
|
raise
|
||||||
|
|
||||||
|
if should_retry and attempt < max_attempts - 1:
|
||||||
|
delay = calculate_backoff(attempt, base_delay, max_delay)
|
||||||
|
logger.warning(
|
||||||
|
f"Retry {attempt + 1}/{max_attempts - 1} for {func.__name__} "
|
||||||
|
f"after {delay:.2f}s due to: {last_exception}"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
elif not should_retry:
|
||||||
|
# Non-retryable HTTP error
|
||||||
|
raise last_exception
|
||||||
|
|
||||||
|
# All retries exhausted
|
||||||
|
logger.error(
|
||||||
|
f"All {max_attempts} attempts failed for {func.__name__}: {last_exception}"
|
||||||
|
)
|
||||||
|
raise last_exception # type: ignore
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
async def retry_async(
|
||||||
|
func: Callable[..., Awaitable[T]],
|
||||||
|
*args: Any,
|
||||||
|
max_attempts: int = 3,
|
||||||
|
base_delay: float = 1.0,
|
||||||
|
max_delay: float = 60.0,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> T:
|
||||||
|
"""
|
||||||
|
Retry an async function with exponential backoff.
|
||||||
|
|
||||||
|
Alternative to decorator when you need per-call control.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func: Async function to call
|
||||||
|
*args: Positional arguments for func
|
||||||
|
max_attempts: Maximum number of attempts
|
||||||
|
base_delay: Base delay between retries
|
||||||
|
max_delay: Maximum delay between retries
|
||||||
|
**kwargs: Keyword arguments for func
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result of func
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Last exception if all retries fail
|
||||||
|
|
||||||
|
Example:
|
||||||
|
result = await retry_async(
|
||||||
|
fetch_data,
|
||||||
|
url,
|
||||||
|
max_attempts=5,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
last_exception: Exception | None = None
|
||||||
|
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
try:
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
last_exception = e
|
||||||
|
|
||||||
|
if not is_retryable_exception(e):
|
||||||
|
raise
|
||||||
|
|
||||||
|
if attempt < max_attempts - 1:
|
||||||
|
delay = calculate_backoff(attempt, base_delay, max_delay)
|
||||||
|
logger.warning(
|
||||||
|
f"Retry {attempt + 1}/{max_attempts - 1} "
|
||||||
|
f"after {delay:.2f}s due to: {e}"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
raise last_exception # type: ignore
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
Token counting utilities for context management.
|
||||||
|
|
||||||
|
Uses tiktoken for token counting. While tiktoken is OpenAI's tokenizer,
|
||||||
|
cl100k_base encoding provides reasonable estimates for most LLMs.
|
||||||
|
"""
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _get_encoding():
|
||||||
|
"""Get tiktoken encoding (cached)."""
|
||||||
|
import tiktoken
|
||||||
|
# cl100k_base is used by GPT-4 and provides reasonable estimates for most models
|
||||||
|
return tiktoken.get_encoding("cl100k_base")
|
||||||
|
|
||||||
|
|
||||||
|
def count_tokens(text: str) -> int:
|
||||||
|
"""
|
||||||
|
Count tokens in a text string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to count tokens for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Token count
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
encoding = _get_encoding()
|
||||||
|
return len(encoding.encode(text))
|
||||||
|
except Exception as e:
|
||||||
|
# Fallback to rough estimate if tiktoken fails
|
||||||
|
logger.warning(f"Token counting failed, using estimate: {e}")
|
||||||
|
return len(text) // 4
|
||||||
|
|
||||||
|
|
||||||
|
def count_message_tokens(messages: list[dict[str, str]]) -> int:
|
||||||
|
"""
|
||||||
|
Count tokens for a list of chat messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: List of message dicts with 'role' and 'content' keys
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Total token count including message overhead
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
encoding = _get_encoding()
|
||||||
|
total = 0
|
||||||
|
for msg in messages:
|
||||||
|
# Each message has ~4 tokens overhead for role/formatting
|
||||||
|
total += 4
|
||||||
|
total += len(encoding.encode(msg.get("content", "")))
|
||||||
|
total += len(encoding.encode(msg.get("role", "")))
|
||||||
|
# Add 2 tokens for assistant response priming
|
||||||
|
total += 2
|
||||||
|
return total
|
||||||
|
except Exception as e:
|
||||||
|
# Fallback to rough estimate
|
||||||
|
logger.warning(f"Token counting failed, using estimate: {e}")
|
||||||
|
total = 0
|
||||||
|
for msg in messages:
|
||||||
|
total += len(msg.get("content", "")) // 4
|
||||||
|
total += 4 # Overhead per message
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_tokens(text: str) -> int:
|
||||||
|
"""
|
||||||
|
Quick token estimate without external library.
|
||||||
|
|
||||||
|
Uses ~4 characters per token heuristic.
|
||||||
|
Less accurate but faster for rough estimates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to estimate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Estimated token count
|
||||||
|
"""
|
||||||
|
return len(text) // 4
|
||||||
@@ -21,7 +21,6 @@ from httpx import ASGITransport, AsyncClient
|
|||||||
|
|
||||||
from src.main import app
|
from src.main import app
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Command Line Options
|
# Command Line Options
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
Tests for agent REST API endpoints.
|
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
|
import pytest
|
||||||
|
|
||||||
|
from src.domains.agents.schemas import PermissionMode
|
||||||
|
|
||||||
|
|
||||||
class TestAgentListEndpoint:
|
class TestAgentListEndpoint:
|
||||||
"""Tests for GET /agents/ endpoint."""
|
"""Tests for GET /agents/ endpoint."""
|
||||||
@@ -160,3 +167,185 @@ class TestAgentStreamEndpoint:
|
|||||||
)
|
)
|
||||||
# Unknown agent returns 400, not streaming
|
# Unknown agent returns 400, not streaming
|
||||||
assert response.status_code == 400
|
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"]
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""
|
||||||
|
Tests for conversations domain.
|
||||||
|
|
||||||
|
Tests conversation CRUD, context building, and API endpoints.
|
||||||
|
"""
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.domains.conversations.models import Conversation, Message
|
||||||
|
from src.domains.conversations.schemas import (
|
||||||
|
AddMessageRequest,
|
||||||
|
CreateConversationRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConversationModels:
|
||||||
|
"""Tests for conversation database models."""
|
||||||
|
|
||||||
|
def test_conversation_creation(self):
|
||||||
|
"""Test Conversation model creation with explicit values."""
|
||||||
|
conv = Conversation(
|
||||||
|
user_id="test-user",
|
||||||
|
agent_type="explore",
|
||||||
|
working_dir=".",
|
||||||
|
total_tokens=0,
|
||||||
|
)
|
||||||
|
assert conv.user_id == "test-user"
|
||||||
|
assert conv.agent_type == "explore"
|
||||||
|
assert conv.working_dir == "."
|
||||||
|
assert conv.total_tokens == 0
|
||||||
|
|
||||||
|
def test_conversation_with_values(self):
|
||||||
|
"""Test Conversation with explicit values."""
|
||||||
|
conv = Conversation(
|
||||||
|
user_id="test-user",
|
||||||
|
agent_type="plan",
|
||||||
|
working_dir="/tmp/project",
|
||||||
|
title="Test Conversation",
|
||||||
|
)
|
||||||
|
assert conv.agent_type == "plan"
|
||||||
|
assert conv.working_dir == "/tmp/project"
|
||||||
|
assert conv.title == "Test Conversation"
|
||||||
|
|
||||||
|
def test_message_creation(self):
|
||||||
|
"""Test Message model creation with explicit values."""
|
||||||
|
msg = Message(
|
||||||
|
conversation_id=uuid4(),
|
||||||
|
role="user",
|
||||||
|
content="Hello",
|
||||||
|
token_count=0,
|
||||||
|
is_summary=False,
|
||||||
|
)
|
||||||
|
assert msg.role == "user"
|
||||||
|
assert msg.content == "Hello"
|
||||||
|
assert msg.token_count == 0
|
||||||
|
assert msg.is_summary is False
|
||||||
|
|
||||||
|
def test_message_repr(self):
|
||||||
|
"""Test Message string representation."""
|
||||||
|
msg = Message(
|
||||||
|
conversation_id=uuid4(),
|
||||||
|
role="user",
|
||||||
|
content="This is a test message",
|
||||||
|
)
|
||||||
|
repr_str = repr(msg)
|
||||||
|
assert "user" in repr_str
|
||||||
|
assert "This is a test" in repr_str
|
||||||
|
|
||||||
|
|
||||||
|
class TestConversationSchemas:
|
||||||
|
"""Tests for Pydantic schemas."""
|
||||||
|
|
||||||
|
def test_create_request_defaults(self):
|
||||||
|
"""Test CreateConversationRequest defaults."""
|
||||||
|
request = CreateConversationRequest()
|
||||||
|
assert request.agent_type == "explore"
|
||||||
|
assert request.working_dir == "."
|
||||||
|
assert request.title is None
|
||||||
|
|
||||||
|
def test_create_request_custom(self):
|
||||||
|
"""Test CreateConversationRequest with values."""
|
||||||
|
request = CreateConversationRequest(
|
||||||
|
agent_type="task",
|
||||||
|
working_dir="/home/user/project",
|
||||||
|
title="My Task",
|
||||||
|
)
|
||||||
|
assert request.agent_type == "task"
|
||||||
|
assert request.working_dir == "/home/user/project"
|
||||||
|
assert request.title == "My Task"
|
||||||
|
|
||||||
|
def test_add_message_request_valid(self):
|
||||||
|
"""Test AddMessageRequest validation."""
|
||||||
|
request = AddMessageRequest(content="Hello, world!")
|
||||||
|
assert request.content == "Hello, world!"
|
||||||
|
|
||||||
|
def test_add_message_request_empty_fails(self):
|
||||||
|
"""Test that empty content fails validation."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
AddMessageRequest(content="")
|
||||||
|
|
||||||
|
|
||||||
|
class TestConversationAPI:
|
||||||
|
"""Tests for conversation API endpoints."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_conversation(self, auth_client):
|
||||||
|
"""Test creating a conversation."""
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/conversations/",
|
||||||
|
json={"agent_type": "explore", "working_dir": "."}
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert "id" in data
|
||||||
|
assert data["agent_type"] == "explore"
|
||||||
|
assert data["total_tokens"] == 0
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_conversation_with_title(self, auth_client):
|
||||||
|
"""Test creating a conversation with title."""
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/conversations/",
|
||||||
|
json={
|
||||||
|
"agent_type": "plan",
|
||||||
|
"working_dir": "/tmp",
|
||||||
|
"title": "Planning Session"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["title"] == "Planning Session"
|
||||||
|
assert data["agent_type"] == "plan"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_list_conversations_empty(self, auth_client):
|
||||||
|
"""Test listing conversations when empty."""
|
||||||
|
response = await auth_client.get("/conversations/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "conversations" in data
|
||||||
|
assert "total" in data
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_get_conversation_not_found(self, auth_client):
|
||||||
|
"""Test getting non-existent conversation."""
|
||||||
|
fake_id = uuid4()
|
||||||
|
response = await auth_client.get(f"/conversations/{fake_id}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_delete_conversation_not_found(self, auth_client):
|
||||||
|
"""Test deleting non-existent conversation."""
|
||||||
|
fake_id = uuid4()
|
||||||
|
response = await auth_client.delete(f"/conversations/{fake_id}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_add_message_not_found(self, auth_client):
|
||||||
|
"""Test adding message to non-existent conversation."""
|
||||||
|
fake_id = uuid4()
|
||||||
|
response = await auth_client.post(
|
||||||
|
f"/conversations/{fake_id}/messages",
|
||||||
|
json={"content": "Hello"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestConversationService:
|
||||||
|
"""Tests for ConversationService business logic."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_context_prompt_no_history(self):
|
||||||
|
"""Test building context prompt with no history."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from src.domains.conversations.service import ConversationService
|
||||||
|
|
||||||
|
# Create mock session
|
||||||
|
mock_session = MagicMock()
|
||||||
|
service = ConversationService(mock_session)
|
||||||
|
|
||||||
|
prompt = service.build_context_prompt([], "What files are here?")
|
||||||
|
|
||||||
|
assert "<current_request>" in prompt
|
||||||
|
assert "What files are here?" in prompt
|
||||||
|
assert "<recent_conversation>" not in prompt
|
||||||
|
assert "<conversation_summary>" not in prompt
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_context_prompt_with_history(self):
|
||||||
|
"""Test building context prompt with message history."""
|
||||||
|
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)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
Message(
|
||||||
|
conversation_id=uuid4(),
|
||||||
|
role="user",
|
||||||
|
content="Find Python files",
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
conversation_id=uuid4(),
|
||||||
|
role="assistant",
|
||||||
|
content="Found 10 Python files.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
prompt = service.build_context_prompt(messages, "Show the largest")
|
||||||
|
|
||||||
|
assert "<recent_conversation>" in prompt
|
||||||
|
assert "USER: Find Python files" in prompt
|
||||||
|
assert "ASSISTANT: Found 10 Python files" in prompt
|
||||||
|
assert "<current_request>" in prompt
|
||||||
|
assert "Show the largest" in prompt
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_context_prompt_with_summary(self):
|
||||||
|
"""Test building context prompt with summary 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)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
Message(
|
||||||
|
conversation_id=uuid4(),
|
||||||
|
role="summary",
|
||||||
|
content="Previously discussed: project setup",
|
||||||
|
is_summary=True,
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
conversation_id=uuid4(),
|
||||||
|
role="user",
|
||||||
|
content="Now what?",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
prompt = service.build_context_prompt(messages, "Continue")
|
||||||
|
|
||||||
|
assert "<conversation_summary>" in prompt
|
||||||
|
assert "Previously discussed: project setup" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
class TestSummarization:
|
||||||
|
"""Tests for conversation summarization."""
|
||||||
|
|
||||||
|
def test_format_messages_for_summary(self):
|
||||||
|
"""Test formatting messages for summarization."""
|
||||||
|
from src.domains.conversations.models import Message
|
||||||
|
from src.domains.conversations.summarize import format_messages_for_summary
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
Message(
|
||||||
|
conversation_id=uuid4(),
|
||||||
|
role="user",
|
||||||
|
content="Hello",
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
conversation_id=uuid4(),
|
||||||
|
role="assistant",
|
||||||
|
content="Hi there!",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
formatted = format_messages_for_summary(messages)
|
||||||
|
|
||||||
|
assert "USER: Hello" in formatted
|
||||||
|
assert "ASSISTANT: Hi there!" in formatted
|
||||||
|
|
||||||
|
def test_format_messages_with_summary(self):
|
||||||
|
"""Test formatting messages that include a summary."""
|
||||||
|
from src.domains.conversations.models import Message
|
||||||
|
from src.domains.conversations.summarize import format_messages_for_summary
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
Message(
|
||||||
|
conversation_id=uuid4(),
|
||||||
|
role="summary",
|
||||||
|
content="Previous context summary",
|
||||||
|
is_summary=True,
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
conversation_id=uuid4(),
|
||||||
|
role="user",
|
||||||
|
content="Continue",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
formatted = format_messages_for_summary(messages)
|
||||||
|
|
||||||
|
assert "[Previous Summary]" in formatted
|
||||||
|
assert "Previous context summary" in formatted
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveMessagesAPI:
|
||||||
|
"""Tests for the save messages endpoint (no agent execution)."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_save_messages(self, auth_client):
|
||||||
|
"""Test saving a message pair without triggering agent."""
|
||||||
|
# First create a conversation
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/conversations/",
|
||||||
|
json={"agent_type": "task", "working_dir": "."}
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
conv_id = response.json()["id"]
|
||||||
|
|
||||||
|
# Save a message pair
|
||||||
|
response = await auth_client.post(
|
||||||
|
f"/conversations/{conv_id}/save",
|
||||||
|
json={
|
||||||
|
"user_content": "Find all Python files",
|
||||||
|
"assistant_content": "I found 5 Python files in the project.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["user_message"]["role"] == "user"
|
||||||
|
assert data["user_message"]["content"] == "Find all Python files"
|
||||||
|
assert data["assistant_message"]["role"] == "assistant"
|
||||||
|
assert data["assistant_message"]["content"] == "I found 5 Python files in the project."
|
||||||
|
assert data["total_tokens"] > 0
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_save_messages_not_found(self, auth_client):
|
||||||
|
"""Test saving messages to non-existent conversation."""
|
||||||
|
fake_id = uuid4()
|
||||||
|
response = await auth_client.post(
|
||||||
|
f"/conversations/{fake_id}/save",
|
||||||
|
json={
|
||||||
|
"user_content": "Test",
|
||||||
|
"assistant_content": "Response",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_save_messages_updates_token_count(self, auth_client):
|
||||||
|
"""Test that saving messages updates the conversation token count."""
|
||||||
|
# Create conversation
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/conversations/",
|
||||||
|
json={"agent_type": "explore", "working_dir": "."}
|
||||||
|
)
|
||||||
|
conv_id = response.json()["id"]
|
||||||
|
assert response.json()["total_tokens"] == 0
|
||||||
|
|
||||||
|
# Save first message pair
|
||||||
|
response = await auth_client.post(
|
||||||
|
f"/conversations/{conv_id}/save",
|
||||||
|
json={
|
||||||
|
"user_content": "Hello",
|
||||||
|
"assistant_content": "Hi there!",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
first_tokens = response.json()["total_tokens"]
|
||||||
|
assert first_tokens > 0
|
||||||
|
|
||||||
|
# Save second message pair
|
||||||
|
response = await auth_client.post(
|
||||||
|
f"/conversations/{conv_id}/save",
|
||||||
|
json={
|
||||||
|
"user_content": "How are you?",
|
||||||
|
"assistant_content": "I'm doing well, thank you for asking!",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
second_tokens = response.json()["total_tokens"]
|
||||||
|
assert second_tokens > first_tokens
|
||||||
|
|
||||||
|
# Verify via get endpoint
|
||||||
|
response = await auth_client.get(f"/conversations/{conv_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["total_tokens"] == second_tokens
|
||||||
|
assert len(response.json()["messages"]) == 4
|
||||||
@@ -6,8 +6,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.domains.tools.gitignore import GitignoreFilter, filter_gitignored
|
|
||||||
from src.domains.tools.file.glob import GlobFilesTool
|
from src.domains.tools.file.glob import GlobFilesTool
|
||||||
|
from src.domains.tools.gitignore import GitignoreFilter, filter_gitignored
|
||||||
from src.domains.tools.search.grep import GrepContentTool
|
from src.domains.tools.search.grep import GrepContentTool
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""The sanitising Ollama client must actually be reachable.
|
||||||
|
|
||||||
|
src/ollama/provider.py exists to work around Ollama rejecting assistant
|
||||||
|
messages that carry `content: null` alongside `tool_calls`. On 2026-08-11 it
|
||||||
|
raised AttributeError the moment anything touched `.chat`: it fetched the
|
||||||
|
parent's getter via `AsyncOpenAI.chat.fget`, and openai had made `chat` a
|
||||||
|
functools.cached_property, whose getter is `.func`.
|
||||||
|
|
||||||
|
Nothing caught it. The line carried a bare `# type: ignore`, so mypy stayed
|
||||||
|
quiet, and the endpoints that reach this code had served no requests in 30 days,
|
||||||
|
so no user hit it either. The mitigation was dead and everything looked fine.
|
||||||
|
|
||||||
|
These tests exercise the path rather than the types, because the failure was a
|
||||||
|
runtime attribute lookup that no annotation would have caught.
|
||||||
|
"""
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
from src.ollama.provider import _parent_chat, _SanitizedAsyncOpenAI, get_ollama_provider
|
||||||
|
|
||||||
|
|
||||||
|
class TestSanitizedClientIsReachable:
|
||||||
|
|
||||||
|
def test_chat_can_be_accessed(self):
|
||||||
|
"""The regression: this raised AttributeError, not a type error."""
|
||||||
|
client = _SanitizedAsyncOpenAI(base_url="http://localhost:11434/v1")
|
||||||
|
chat = client.chat
|
||||||
|
assert chat is not None
|
||||||
|
assert chat.completions is not None
|
||||||
|
|
||||||
|
def test_provider_reaches_completions(self):
|
||||||
|
"""The full chain an agent request walks, short of the network call."""
|
||||||
|
provider = get_ollama_provider()
|
||||||
|
assert provider._openai_client.chat.completions is not None
|
||||||
|
|
||||||
|
def test_parent_lookup_survives_either_descriptor_shape(self):
|
||||||
|
"""openai has used both property and cached_property for `chat`.
|
||||||
|
|
||||||
|
Whichever it is, the parent's own getter must be found — the previous
|
||||||
|
code hardcoded `.fget` and broke on the switch to cached_property.
|
||||||
|
"""
|
||||||
|
descriptor = AsyncOpenAI.__dict__["chat"]
|
||||||
|
assert hasattr(descriptor, "func") or hasattr(descriptor, "fget"), (
|
||||||
|
"AsyncOpenAI.chat exposes neither getter; _parent_chat needs updating"
|
||||||
|
)
|
||||||
|
client = _SanitizedAsyncOpenAI(base_url="http://localhost:11434/v1")
|
||||||
|
assert _parent_chat(client) is not None
|
||||||
|
|
||||||
|
def test_parent_chat_is_not_the_override(self):
|
||||||
|
"""It must return openai's Chat, not recurse into the subclass property.
|
||||||
|
|
||||||
|
Returning the subclass's own `chat` would be infinite recursion, and the
|
||||||
|
sanitiser would wrap itself instead of the real completions resource.
|
||||||
|
"""
|
||||||
|
client = _SanitizedAsyncOpenAI(base_url="http://localhost:11434/v1")
|
||||||
|
assert type(_parent_chat(client)).__name__ != "_SanitizedChat"
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""
|
||||||
|
Tests for the Plan agent.
|
||||||
|
|
||||||
|
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 PlanAgentImpl, plan_agent
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlanAgentRegistration:
|
||||||
|
"""Tests for Plan agent registration."""
|
||||||
|
|
||||||
|
def test_plan_agent_registered(self):
|
||||||
|
"""Test that plan agent is registered in registry."""
|
||||||
|
agent = get_agent("plan")
|
||||||
|
assert agent is not None
|
||||||
|
assert agent.name == "plan"
|
||||||
|
|
||||||
|
def test_plan_agent_in_list(self):
|
||||||
|
"""Test that plan agent appears in agent list."""
|
||||||
|
agents = list_agents()
|
||||||
|
names = [a["name"] for a in agents]
|
||||||
|
assert "plan" in names
|
||||||
|
|
||||||
|
def test_plan_agent_has_description(self):
|
||||||
|
"""Test that plan agent has a description."""
|
||||||
|
agent = get_agent("plan")
|
||||||
|
assert agent is not None
|
||||||
|
assert len(agent.description) > 0
|
||||||
|
assert "plan" in agent.description.lower() or "architect" in agent.description.lower()
|
||||||
|
|
||||||
|
def test_plan_agent_singleton(self):
|
||||||
|
"""Test that plan_agent is the registered instance."""
|
||||||
|
registered = get_agent("plan")
|
||||||
|
assert registered is plan_agent
|
||||||
|
|
||||||
|
def test_plan_agent_is_correct_type(self):
|
||||||
|
"""Test that plan agent is correct implementation type."""
|
||||||
|
assert isinstance(plan_agent, PlanAgentImpl)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlanAgentTools:
|
||||||
|
"""Tests for Plan agent tool restrictions."""
|
||||||
|
|
||||||
|
def test_plan_agent_has_read_only_tools(self):
|
||||||
|
"""Test that plan agent has read-only tools."""
|
||||||
|
# Access the underlying PydanticAI agent to check tools
|
||||||
|
agent = plan_agent.agent
|
||||||
|
tool_names = list(agent._function_toolset.tools.keys())
|
||||||
|
|
||||||
|
# Should have read-only tools
|
||||||
|
assert "read_file" in tool_names
|
||||||
|
assert "glob_files" in tool_names
|
||||||
|
assert "grep_content" in tool_names
|
||||||
|
assert "bash_readonly" in tool_names
|
||||||
|
|
||||||
|
def test_plan_agent_no_write_tools(self):
|
||||||
|
"""Test that plan agent does NOT have write tools."""
|
||||||
|
agent = plan_agent.agent
|
||||||
|
tool_names = list(agent._function_toolset.tools.keys())
|
||||||
|
|
||||||
|
# Should NOT have write tools
|
||||||
|
assert "edit_file" not in tool_names
|
||||||
|
assert "write_file" not in tool_names
|
||||||
|
assert "bash" not in tool_names
|
||||||
|
assert "web_search" not in tool_names
|
||||||
|
|
||||||
|
def test_plan_agent_tool_count(self):
|
||||||
|
"""Test that plan agent has exactly 4 tools."""
|
||||||
|
agent = plan_agent.agent
|
||||||
|
tool_count = len(agent._function_toolset.tools)
|
||||||
|
assert tool_count == 4
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlanAgentAPI:
|
||||||
|
"""Tests for Plan agent REST API."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_list_agents_includes_plan(self, auth_client):
|
||||||
|
"""Test that agent list includes plan agent."""
|
||||||
|
response = await auth_client.get("/agents/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
names = [a["name"] for a in data["agents"]]
|
||||||
|
assert "plan" in names
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_get_plan_agent_info(self, auth_client):
|
||||||
|
"""Test getting plan agent info."""
|
||||||
|
response = await auth_client.get("/agents/plan")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "plan"
|
||||||
|
assert "description" in data
|
||||||
|
assert len(data["description"]) > 0
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_run_plan_with_invalid_body(self, auth_client):
|
||||||
|
"""Test running plan agent with invalid request."""
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/agents/run",
|
||||||
|
json={
|
||||||
|
"agent_type": "plan",
|
||||||
|
# Missing prompt
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_stream_plan_with_invalid_body(self, auth_client):
|
||||||
|
"""Test streaming plan agent with invalid request."""
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/agents/stream",
|
||||||
|
json={
|
||||||
|
"agent_type": "plan",
|
||||||
|
# Missing prompt
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlanAgentProperties:
|
||||||
|
"""Tests for Plan agent properties and configuration."""
|
||||||
|
|
||||||
|
def test_plan_agent_name(self):
|
||||||
|
"""Test plan agent name property."""
|
||||||
|
assert plan_agent.name == "plan"
|
||||||
|
|
||||||
|
def test_plan_agent_description_not_empty(self):
|
||||||
|
"""Test plan agent description is not empty."""
|
||||||
|
assert plan_agent.description
|
||||||
|
assert len(plan_agent.description) > 10
|
||||||
|
|
||||||
|
def test_plan_agent_creates_agent_lazily(self):
|
||||||
|
"""Test that PydanticAI agent is created lazily."""
|
||||||
|
# Create a fresh instance
|
||||||
|
fresh_agent = PlanAgentImpl()
|
||||||
|
|
||||||
|
# _agent should be None before first access
|
||||||
|
assert fresh_agent._agent is None
|
||||||
|
|
||||||
|
# Access the agent property
|
||||||
|
_ = fresh_agent.agent
|
||||||
|
|
||||||
|
# Now _agent should be set
|
||||||
|
assert fresh_agent._agent is not None
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"""
|
||||||
|
Tests for retry utilities.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.shared.retry import (
|
||||||
|
calculate_backoff,
|
||||||
|
is_retryable_exception,
|
||||||
|
is_retryable_http_status,
|
||||||
|
retry_async,
|
||||||
|
with_retry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsRetryableHttpStatus:
|
||||||
|
"""Tests for HTTP status code checking."""
|
||||||
|
|
||||||
|
def test_429_is_retryable(self):
|
||||||
|
"""429 Too Many Requests should be retryable."""
|
||||||
|
assert is_retryable_http_status(429) is True
|
||||||
|
|
||||||
|
def test_500_is_retryable(self):
|
||||||
|
"""500 Internal Server Error should be retryable."""
|
||||||
|
assert is_retryable_http_status(500) is True
|
||||||
|
|
||||||
|
def test_502_is_retryable(self):
|
||||||
|
"""502 Bad Gateway should be retryable."""
|
||||||
|
assert is_retryable_http_status(502) is True
|
||||||
|
|
||||||
|
def test_503_is_retryable(self):
|
||||||
|
"""503 Service Unavailable should be retryable."""
|
||||||
|
assert is_retryable_http_status(503) is True
|
||||||
|
|
||||||
|
def test_504_is_retryable(self):
|
||||||
|
"""504 Gateway Timeout should be retryable."""
|
||||||
|
assert is_retryable_http_status(504) is True
|
||||||
|
|
||||||
|
def test_400_not_retryable(self):
|
||||||
|
"""400 Bad Request should not be retryable."""
|
||||||
|
assert is_retryable_http_status(400) is False
|
||||||
|
|
||||||
|
def test_401_not_retryable(self):
|
||||||
|
"""401 Unauthorized should not be retryable."""
|
||||||
|
assert is_retryable_http_status(401) is False
|
||||||
|
|
||||||
|
def test_404_not_retryable(self):
|
||||||
|
"""404 Not Found should not be retryable."""
|
||||||
|
assert is_retryable_http_status(404) is False
|
||||||
|
|
||||||
|
def test_200_not_retryable(self):
|
||||||
|
"""200 OK should not be retryable."""
|
||||||
|
assert is_retryable_http_status(200) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsRetryableException:
|
||||||
|
"""Tests for exception checking."""
|
||||||
|
|
||||||
|
def test_timeout_exception_is_retryable(self):
|
||||||
|
"""Timeout exceptions should be retryable."""
|
||||||
|
exc = httpx.TimeoutException("timeout")
|
||||||
|
assert is_retryable_exception(exc) is True
|
||||||
|
|
||||||
|
def test_connect_error_is_retryable(self):
|
||||||
|
"""Connection errors should be retryable."""
|
||||||
|
exc = httpx.ConnectError("connection failed")
|
||||||
|
assert is_retryable_exception(exc) is True
|
||||||
|
|
||||||
|
def test_connection_error_is_retryable(self):
|
||||||
|
"""Python ConnectionError should be retryable."""
|
||||||
|
exc = ConnectionError("connection refused")
|
||||||
|
assert is_retryable_exception(exc) is True
|
||||||
|
|
||||||
|
def test_timeout_error_is_retryable(self):
|
||||||
|
"""Python TimeoutError should be retryable."""
|
||||||
|
exc = TimeoutError("timed out")
|
||||||
|
assert is_retryable_exception(exc) is True
|
||||||
|
|
||||||
|
def test_value_error_not_retryable(self):
|
||||||
|
"""ValueError should not be retryable."""
|
||||||
|
exc = ValueError("invalid value")
|
||||||
|
assert is_retryable_exception(exc) is False
|
||||||
|
|
||||||
|
def test_key_error_not_retryable(self):
|
||||||
|
"""KeyError should not be retryable."""
|
||||||
|
exc = KeyError("missing key")
|
||||||
|
assert is_retryable_exception(exc) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestCalculateBackoff:
|
||||||
|
"""Tests for backoff calculation."""
|
||||||
|
|
||||||
|
def test_first_attempt_base_delay(self):
|
||||||
|
"""First attempt should use base delay."""
|
||||||
|
delay = calculate_backoff(0, base_delay=1.0, jitter=False)
|
||||||
|
assert delay == 1.0
|
||||||
|
|
||||||
|
def test_second_attempt_doubles(self):
|
||||||
|
"""Second attempt should double the delay."""
|
||||||
|
delay = calculate_backoff(1, base_delay=1.0, jitter=False)
|
||||||
|
assert delay == 2.0
|
||||||
|
|
||||||
|
def test_third_attempt_quadruples(self):
|
||||||
|
"""Third attempt should quadruple the delay."""
|
||||||
|
delay = calculate_backoff(2, base_delay=1.0, jitter=False)
|
||||||
|
assert delay == 4.0
|
||||||
|
|
||||||
|
def test_max_delay_respected(self):
|
||||||
|
"""Delay should not exceed max_delay."""
|
||||||
|
delay = calculate_backoff(10, base_delay=1.0, max_delay=30.0, jitter=False)
|
||||||
|
assert delay == 30.0
|
||||||
|
|
||||||
|
def test_jitter_adds_randomness(self):
|
||||||
|
"""Jitter should add randomness to delay."""
|
||||||
|
delays = [calculate_backoff(1, base_delay=1.0, jitter=True) for _ in range(10)]
|
||||||
|
# With jitter, delays should vary (not all identical)
|
||||||
|
assert len(set(delays)) > 1
|
||||||
|
|
||||||
|
def test_jitter_within_bounds(self):
|
||||||
|
"""Jitter should keep delay within reasonable bounds."""
|
||||||
|
for _ in range(100):
|
||||||
|
delay = calculate_backoff(0, base_delay=2.0, jitter=True)
|
||||||
|
# Attempt 0 with base 2.0 = 2.0, with jitter should be 0.75-1.25x = 1.5-2.5
|
||||||
|
assert 1.5 <= delay <= 2.5
|
||||||
|
|
||||||
|
|
||||||
|
class TestWithRetryDecorator:
|
||||||
|
"""Tests for the @with_retry decorator."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_success_on_first_attempt(self):
|
||||||
|
"""Function should return on first successful attempt."""
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
@with_retry(max_attempts=3)
|
||||||
|
async def successful_func():
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
result = await successful_func()
|
||||||
|
assert result == "success"
|
||||||
|
assert call_count == 1
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_retry_on_timeout(self):
|
||||||
|
"""Should retry on timeout exception."""
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
@with_retry(max_attempts=3, base_delay=0.01)
|
||||||
|
async def flaky_func():
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count < 3:
|
||||||
|
raise httpx.TimeoutException("timeout")
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
result = await flaky_func()
|
||||||
|
assert result == "success"
|
||||||
|
assert call_count == 3
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_no_retry_on_value_error(self):
|
||||||
|
"""Should not retry on non-retryable exceptions."""
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
@with_retry(max_attempts=3)
|
||||||
|
async def bad_func():
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
raise ValueError("bad value")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await bad_func()
|
||||||
|
assert call_count == 1
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_exhausted_retries(self):
|
||||||
|
"""Should raise last exception after all retries exhausted."""
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
@with_retry(max_attempts=3, base_delay=0.01)
|
||||||
|
async def always_fails():
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
raise httpx.TimeoutException("always times out")
|
||||||
|
|
||||||
|
with pytest.raises(httpx.TimeoutException):
|
||||||
|
await always_fails()
|
||||||
|
assert call_count == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestRetryAsync:
|
||||||
|
"""Tests for the retry_async function."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_success_on_first_attempt(self):
|
||||||
|
"""Function should return on first successful attempt."""
|
||||||
|
async def successful_func():
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
result = await retry_async(successful_func, max_attempts=3)
|
||||||
|
assert result == "success"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_retry_on_connect_error(self):
|
||||||
|
"""Should retry on connection errors."""
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def flaky_func():
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count < 2:
|
||||||
|
raise httpx.ConnectError("connection failed")
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
result = await retry_async(flaky_func, max_attempts=3, base_delay=0.01)
|
||||||
|
assert result == "success"
|
||||||
|
assert call_count == 2
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_passes_args_and_kwargs(self):
|
||||||
|
"""Should pass arguments to the function."""
|
||||||
|
async def add(a, b, multiplier=1):
|
||||||
|
return (a + b) * multiplier
|
||||||
|
|
||||||
|
result = await retry_async(add, 2, 3, max_attempts=1, multiplier=2)
|
||||||
|
assert result == 10
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_no_retry_on_key_error(self):
|
||||||
|
"""Should not retry on non-retryable exceptions."""
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def bad_func():
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
raise KeyError("missing")
|
||||||
|
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
await retry_async(bad_func, max_attempts=3)
|
||||||
|
assert call_count == 1
|
||||||
@@ -6,10 +6,10 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.domains.tools.file.read import ReadFileTool
|
|
||||||
from src.domains.tools.file.write import WriteFileTool
|
|
||||||
from src.domains.tools.file.edit import EditFileTool
|
from src.domains.tools.file.edit import EditFileTool
|
||||||
from src.domains.tools.file.glob import GlobFilesTool
|
from src.domains.tools.file.glob import GlobFilesTool
|
||||||
|
from src.domains.tools.file.read import ReadFileTool
|
||||||
|
from src.domains.tools.file.write import WriteFileTool
|
||||||
from src.domains.tools.shell.bash_full import BashTool
|
from src.domains.tools.shell.bash_full import BashTool
|
||||||
|
|
||||||
|
|
||||||
@@ -275,5 +275,5 @@ class TestResourceLimits:
|
|||||||
|
|
||||||
assert result.success
|
assert result.success
|
||||||
# Should only return 5 files
|
# Should only return 5 files
|
||||||
lines = [l for l in result.data.strip().split("\n") if l]
|
lines = [line for line in result.data.strip().split("\n") if line]
|
||||||
assert len(lines) <= 5
|
assert len(lines) <= 5
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"""
|
||||||
|
Tests for the Task agent.
|
||||||
|
|
||||||
|
Tests registration, API endpoints, tool access, and spawn_agent functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.domains.agents.base import get_agent, list_agents
|
||||||
|
from src.domains.agents.task import TaskAgentImpl, task_agent
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskAgentRegistration:
|
||||||
|
"""Tests for Task agent registration."""
|
||||||
|
|
||||||
|
def test_task_agent_registered(self):
|
||||||
|
"""Test that task agent is registered in registry."""
|
||||||
|
agent = get_agent("task")
|
||||||
|
assert agent is not None
|
||||||
|
assert agent.name == "task"
|
||||||
|
|
||||||
|
def test_task_agent_in_list(self):
|
||||||
|
"""Test that task agent appears in agent list."""
|
||||||
|
agents = list_agents()
|
||||||
|
names = [a["name"] for a in agents]
|
||||||
|
assert "task" in names
|
||||||
|
|
||||||
|
def test_task_agent_has_description(self):
|
||||||
|
"""Test that task agent has a description."""
|
||||||
|
agent = get_agent("task")
|
||||||
|
assert agent is not None
|
||||||
|
assert len(agent.description) > 0
|
||||||
|
assert "task" in agent.description.lower() or "autonomous" in agent.description.lower()
|
||||||
|
|
||||||
|
def test_task_agent_singleton(self):
|
||||||
|
"""Test that task_agent is the registered instance."""
|
||||||
|
registered = get_agent("task")
|
||||||
|
assert registered is task_agent
|
||||||
|
|
||||||
|
def test_task_agent_is_correct_type(self):
|
||||||
|
"""Test that task agent is correct implementation type."""
|
||||||
|
assert isinstance(task_agent, TaskAgentImpl)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskAgentTools:
|
||||||
|
"""Tests for Task agent tool access."""
|
||||||
|
|
||||||
|
def test_task_agent_has_all_tools(self):
|
||||||
|
"""Test that task agent has all 9 tools."""
|
||||||
|
agent = task_agent.agent
|
||||||
|
tool_names = list(agent._function_toolset.tools.keys())
|
||||||
|
|
||||||
|
# Should have 9 tools total
|
||||||
|
assert len(tool_names) == 9
|
||||||
|
|
||||||
|
def test_task_agent_has_read_only_tools(self):
|
||||||
|
"""Test that task agent has read-only tools."""
|
||||||
|
agent = task_agent.agent
|
||||||
|
tool_names = list(agent._function_toolset.tools.keys())
|
||||||
|
|
||||||
|
assert "read_file" in tool_names
|
||||||
|
assert "glob_files" in tool_names
|
||||||
|
assert "grep_content" in tool_names
|
||||||
|
assert "bash_readonly" in tool_names
|
||||||
|
|
||||||
|
def test_task_agent_has_write_tools(self):
|
||||||
|
"""Test that task agent has write tools."""
|
||||||
|
agent = task_agent.agent
|
||||||
|
tool_names = list(agent._function_toolset.tools.keys())
|
||||||
|
|
||||||
|
assert "edit_file" in tool_names
|
||||||
|
assert "write_file" in tool_names
|
||||||
|
assert "bash" in tool_names
|
||||||
|
|
||||||
|
def test_task_agent_has_external_tools(self):
|
||||||
|
"""Test that task agent has external tools."""
|
||||||
|
agent = task_agent.agent
|
||||||
|
tool_names = list(agent._function_toolset.tools.keys())
|
||||||
|
|
||||||
|
assert "web_search" in tool_names
|
||||||
|
|
||||||
|
def test_task_agent_has_spawn_agent_tool(self):
|
||||||
|
"""Test that task agent has spawn_agent orchestration tool."""
|
||||||
|
agent = task_agent.agent
|
||||||
|
tool_names = list(agent._function_toolset.tools.keys())
|
||||||
|
|
||||||
|
assert "spawn_agent" in tool_names
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""Spawning an explore agent should delegate to the explore agent.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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."""
|
||||||
|
|
||||||
|
# We can't easily test the tool directly, but we can verify
|
||||||
|
# the agent type validation logic
|
||||||
|
allowed_types = ["explore", "plan"]
|
||||||
|
assert "nonexistent" not in allowed_types
|
||||||
|
assert "task" not in allowed_types # Task should be blocked
|
||||||
|
|
||||||
|
def test_spawn_task_agent_blocked(self):
|
||||||
|
"""Test that spawning nested task agents is blocked."""
|
||||||
|
# Verify the validation logic prevents recursion
|
||||||
|
# The spawn_agent tool should return an error for agent_type="task"
|
||||||
|
allowed_types = ["explore", "plan"]
|
||||||
|
assert "task" not in allowed_types
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskAgentAPI:
|
||||||
|
"""Tests for Task agent REST API."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_list_agents_includes_task(self, auth_client):
|
||||||
|
"""Test that agent list includes task agent."""
|
||||||
|
response = await auth_client.get("/agents/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
names = [a["name"] for a in data["agents"]]
|
||||||
|
assert "task" in names
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_get_task_agent_info(self, auth_client):
|
||||||
|
"""Test getting task agent info."""
|
||||||
|
response = await auth_client.get("/agents/task")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "task"
|
||||||
|
assert "description" in data
|
||||||
|
assert len(data["description"]) > 0
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_run_task_with_invalid_body(self, auth_client):
|
||||||
|
"""Test running task agent with invalid request."""
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/agents/run",
|
||||||
|
json={
|
||||||
|
"agent_type": "task",
|
||||||
|
# Missing prompt
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_stream_task_with_invalid_body(self, auth_client):
|
||||||
|
"""Test streaming task agent with invalid request."""
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/agents/stream",
|
||||||
|
json={
|
||||||
|
"agent_type": "task",
|
||||||
|
# Missing prompt
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskAgentProperties:
|
||||||
|
"""Tests for Task agent properties and configuration."""
|
||||||
|
|
||||||
|
def test_task_agent_name(self):
|
||||||
|
"""Test task agent name property."""
|
||||||
|
assert task_agent.name == "task"
|
||||||
|
|
||||||
|
def test_task_agent_description_not_empty(self):
|
||||||
|
"""Test task agent description is not empty."""
|
||||||
|
assert task_agent.description
|
||||||
|
assert len(task_agent.description) > 10
|
||||||
|
|
||||||
|
def test_task_agent_creates_agent_lazily(self):
|
||||||
|
"""Test that PydanticAI agent is created lazily."""
|
||||||
|
# Create a fresh instance
|
||||||
|
fresh_agent = TaskAgentImpl()
|
||||||
|
|
||||||
|
# _agents dict should be empty before first access
|
||||||
|
assert len(fresh_agent._agents) == 0
|
||||||
|
|
||||||
|
# Access the agent property (creates default mode agent)
|
||||||
|
_ = fresh_agent.agent
|
||||||
|
|
||||||
|
# Now _agents should have one entry
|
||||||
|
assert len(fresh_agent._agents) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestAllAgentsRegistered:
|
||||||
|
"""Tests to verify all three agents are registered."""
|
||||||
|
|
||||||
|
def test_all_agents_in_registry(self):
|
||||||
|
"""Test that explore, plan, and task agents are all registered."""
|
||||||
|
agents = list_agents()
|
||||||
|
names = [a["name"] for a in agents]
|
||||||
|
|
||||||
|
assert "explore" in names
|
||||||
|
assert "plan" in names
|
||||||
|
assert "task" in names
|
||||||
|
assert len(names) == 3
|
||||||
|
|
||||||
|
def test_agent_hierarchy(self):
|
||||||
|
"""Test the agent capability hierarchy."""
|
||||||
|
explore = get_agent("explore")
|
||||||
|
plan = get_agent("plan")
|
||||||
|
task = get_agent("task")
|
||||||
|
|
||||||
|
explore_tools = list(explore.agent._function_toolset.tools.keys())
|
||||||
|
plan_tools = list(plan.agent._function_toolset.tools.keys())
|
||||||
|
task_tools = list(task.agent._function_toolset.tools.keys())
|
||||||
|
|
||||||
|
# Explore has all tools (read + write)
|
||||||
|
assert "edit_file" in explore_tools
|
||||||
|
assert "write_file" in explore_tools
|
||||||
|
|
||||||
|
# Plan has read-only tools
|
||||||
|
assert "edit_file" not in plan_tools
|
||||||
|
assert "write_file" not in plan_tools
|
||||||
|
|
||||||
|
# Task has all tools plus spawn_agent
|
||||||
|
assert "edit_file" in task_tools
|
||||||
|
assert "write_file" in task_tools
|
||||||
|
assert "spawn_agent" in task_tools
|
||||||
|
|
||||||
|
# Only Task has spawn_agent
|
||||||
|
assert "spawn_agent" not in explore_tools
|
||||||
|
assert "spawn_agent" not in plan_tools
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""
|
||||||
|
Tests for token counting utilities.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from src.shared.tokens import count_message_tokens, count_tokens, estimate_tokens
|
||||||
|
|
||||||
|
|
||||||
|
class TestTokenCounting:
|
||||||
|
"""Tests for token counting functions."""
|
||||||
|
|
||||||
|
def test_estimate_tokens_basic(self):
|
||||||
|
"""Test basic token estimation."""
|
||||||
|
text = "Hello world"
|
||||||
|
tokens = estimate_tokens(text)
|
||||||
|
# ~4 chars per token
|
||||||
|
assert tokens == len(text) // 4
|
||||||
|
|
||||||
|
def test_estimate_tokens_empty(self):
|
||||||
|
"""Test estimation with empty string."""
|
||||||
|
assert estimate_tokens("") == 0
|
||||||
|
|
||||||
|
def test_estimate_tokens_long_text(self):
|
||||||
|
"""Test estimation with longer text."""
|
||||||
|
text = "a" * 400
|
||||||
|
tokens = estimate_tokens(text)
|
||||||
|
assert tokens == 100
|
||||||
|
|
||||||
|
def test_count_tokens_basic(self):
|
||||||
|
"""Test actual token counting."""
|
||||||
|
text = "Hello, how are you today?"
|
||||||
|
tokens = count_tokens(text)
|
||||||
|
# Should return reasonable token count
|
||||||
|
assert tokens > 0
|
||||||
|
assert tokens < len(text) # Should be fewer tokens than characters
|
||||||
|
|
||||||
|
def test_count_tokens_empty(self):
|
||||||
|
"""Test counting empty string."""
|
||||||
|
tokens = count_tokens("")
|
||||||
|
assert tokens == 0
|
||||||
|
|
||||||
|
def test_count_message_tokens_single(self):
|
||||||
|
"""Test counting tokens in single message."""
|
||||||
|
messages = [{"role": "user", "content": "Hello"}]
|
||||||
|
tokens = count_message_tokens(messages)
|
||||||
|
assert tokens > 0
|
||||||
|
|
||||||
|
def test_count_message_tokens_multiple(self):
|
||||||
|
"""Test counting tokens in multiple messages."""
|
||||||
|
messages = [
|
||||||
|
{"role": "user", "content": "Hello, how are you?"},
|
||||||
|
{"role": "assistant", "content": "I'm doing well, thank you!"},
|
||||||
|
]
|
||||||
|
tokens = count_message_tokens(messages)
|
||||||
|
# Should be more than single message
|
||||||
|
single_tokens = count_message_tokens([messages[0]])
|
||||||
|
assert tokens > single_tokens
|
||||||
|
|
||||||
|
def test_count_message_tokens_empty_list(self):
|
||||||
|
"""Test counting empty message list."""
|
||||||
|
tokens = count_message_tokens([])
|
||||||
|
# tiktoken returns small overhead for empty list (assistant priming)
|
||||||
|
assert tokens < 10
|
||||||
|
|
||||||
|
|
||||||
|
class TestTokenCountingAccuracy:
|
||||||
|
"""Tests for token counting accuracy."""
|
||||||
|
|
||||||
|
def test_code_tokens_reasonable(self):
|
||||||
|
"""Test that code is tokenized reasonably."""
|
||||||
|
code = """
|
||||||
|
def hello_world():
|
||||||
|
print("Hello, World!")
|
||||||
|
return True
|
||||||
|
"""
|
||||||
|
tokens = count_tokens(code)
|
||||||
|
# Code should have reasonable token count
|
||||||
|
assert 10 < tokens < 100
|
||||||
|
|
||||||
|
def test_special_characters(self):
|
||||||
|
"""Test tokenization of special characters."""
|
||||||
|
text = "Hello! @#$%^&*() World?"
|
||||||
|
tokens = count_tokens(text)
|
||||||
|
assert tokens > 0
|
||||||
|
|
||||||
|
def test_unicode_text(self):
|
||||||
|
"""Test tokenization of unicode text."""
|
||||||
|
text = "Hello 世界 🌍"
|
||||||
|
tokens = count_tokens(text)
|
||||||
|
assert tokens > 0
|
||||||
@@ -6,8 +6,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.domains.tools.file.read import ReadFileTool
|
|
||||||
from src.domains.tools.file.glob import GlobFilesTool
|
from src.domains.tools.file.glob import GlobFilesTool
|
||||||
|
from src.domains.tools.file.read import ReadFileTool
|
||||||
from src.domains.tools.search.grep import GrepContentTool
|
from src.domains.tools.search.grep import GrepContentTool
|
||||||
from src.domains.tools.shell.bash import BashReadOnlyTool
|
from src.domains.tools.shell.bash import BashReadOnlyTool
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
Tests for WebSearchTool.
|
Tests for WebSearchTool.
|
||||||
"""
|
"""
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, patch, MagicMock
|
|
||||||
|
|
||||||
from src.domains.tools.search.web import WebSearchTool
|
from src.domains.tools.search.web import WebSearchTool
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ class TestWebSearchTool:
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def tool(self):
|
def tool(self):
|
||||||
return WebSearchTool(searxng_url="http://localhost:8087", timeout=5)
|
return WebSearchTool(searxng_url="http://searxng:8080", timeout=5)
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_search_response(self):
|
def mock_search_response(self):
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Changelog - Webber CLI
|
||||||
|
|
||||||
|
All notable changes to the Webber CLI will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-01-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial CLI release as part of monorepo reorganization
|
||||||
|
- Typer + Rich foundation with console theming
|
||||||
|
- Commands:
|
||||||
|
- `webber-cli status` - Check API connection
|
||||||
|
- `webber-cli explore` - One-shot codebase exploration
|
||||||
|
- `webber-cli chat` - Interactive conversation mode
|
||||||
|
- Streaming support with `--stream` flag (default: enabled)
|
||||||
|
- Markdown rendering for agent responses
|
||||||
|
- Configurable API URL via `WEBBER_API_URL` environment variable
|
||||||
@@ -2,3 +2,4 @@
|
|||||||
httpx~=0.28.1
|
httpx~=0.28.1
|
||||||
typer~=0.15.0
|
typer~=0.15.0
|
||||||
rich~=13.9.0
|
rich~=13.9.0
|
||||||
|
prompt_toolkit~=3.0.48
|
||||||
|
|||||||
+315
-11
@@ -2,20 +2,72 @@
|
|||||||
Webber API client.
|
Webber API client.
|
||||||
|
|
||||||
Communicates with the Webber API backend for agent execution.
|
Communicates with the Webber API backend for agent execution.
|
||||||
|
Supports permission modes for controlling agent tool access.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import httpx
|
import httpx
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionMode(str, Enum):
|
||||||
|
"""
|
||||||
|
Permission modes controlling agent tool access.
|
||||||
|
|
||||||
|
- default: All tools available (approval may be required)
|
||||||
|
- plan: Read-only tools only
|
||||||
|
- auto_accept: All tools, no approval prompts
|
||||||
|
"""
|
||||||
|
default = "default"
|
||||||
|
plan = "plan"
|
||||||
|
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
|
@dataclass
|
||||||
class AgentResponse:
|
class AgentResponse:
|
||||||
"""Response from agent execution."""
|
"""Response from agent execution."""
|
||||||
response: str
|
response: str
|
||||||
agent_type: str
|
agent_type: str
|
||||||
success: bool
|
success: bool
|
||||||
|
mode: PermissionMode = PermissionMode.default
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -26,6 +78,47 @@ class AgentInfo:
|
|||||||
description: str
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Message:
|
||||||
|
"""A message in a conversation."""
|
||||||
|
id: str
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
token_count: int
|
||||||
|
is_summary: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Conversation:
|
||||||
|
"""A conversation session."""
|
||||||
|
id: str
|
||||||
|
agent_type: str
|
||||||
|
title: str | None
|
||||||
|
working_dir: str
|
||||||
|
total_tokens: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime | None
|
||||||
|
messages: list[Message] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AddMessageResult:
|
||||||
|
"""Result of adding a message to a conversation."""
|
||||||
|
user_message: Message
|
||||||
|
assistant_message: Message
|
||||||
|
total_tokens: int
|
||||||
|
summarized: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SaveMessagesResult:
|
||||||
|
"""Result of saving a message pair without agent execution."""
|
||||||
|
user_message: Message
|
||||||
|
assistant_message: Message
|
||||||
|
total_tokens: int
|
||||||
|
|
||||||
|
|
||||||
class WebberClient:
|
class WebberClient:
|
||||||
"""
|
"""
|
||||||
Client for the Webber API.
|
Client for the Webber API.
|
||||||
@@ -104,14 +197,16 @@ class WebberClient:
|
|||||||
agent_type: str,
|
agent_type: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
working_dir: str = ".",
|
working_dir: str = ".",
|
||||||
|
mode: PermissionMode = PermissionMode.default,
|
||||||
) -> AgentResponse:
|
) -> AgentResponse:
|
||||||
"""
|
"""
|
||||||
Run an agent with the given prompt.
|
Run an agent with the given prompt.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
agent_type: Type of agent (e.g., "explore")
|
agent_type: Type of agent (e.g., "task")
|
||||||
prompt: User prompt/query
|
prompt: User prompt/query
|
||||||
working_dir: Working directory for the agent
|
working_dir: Working directory for the agent
|
||||||
|
mode: Permission mode controlling tool access
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
AgentResponse with the result
|
AgentResponse with the result
|
||||||
@@ -123,6 +218,7 @@ class WebberClient:
|
|||||||
"agent_type": agent_type,
|
"agent_type": agent_type,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"working_dir": working_dir,
|
"working_dir": working_dir,
|
||||||
|
"mode": mode.value,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -131,6 +227,7 @@ class WebberClient:
|
|||||||
response=data.get("response", ""),
|
response=data.get("response", ""),
|
||||||
agent_type=data.get("agent_type", agent_type),
|
agent_type=data.get("agent_type", agent_type),
|
||||||
success=data.get("success", True),
|
success=data.get("success", True),
|
||||||
|
mode=PermissionMode(data.get("mode", "default")),
|
||||||
error=data.get("error"),
|
error=data.get("error"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -139,17 +236,19 @@ class WebberClient:
|
|||||||
agent_type: str,
|
agent_type: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
working_dir: str = ".",
|
working_dir: str = ".",
|
||||||
) -> AsyncIterator[str]:
|
mode: PermissionMode = PermissionMode.default,
|
||||||
|
) -> AsyncIterator[StreamEvent]:
|
||||||
"""
|
"""
|
||||||
Run an agent with streaming response.
|
Run an agent with streaming response.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
agent_type: Type of agent (e.g., "explore")
|
agent_type: Type of agent (e.g., "task")
|
||||||
prompt: User prompt/query
|
prompt: User prompt/query
|
||||||
working_dir: Working directory for the agent
|
working_dir: Working directory for the agent
|
||||||
|
mode: Permission mode controlling tool access
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Text chunks as they arrive
|
StreamEvent objects as they arrive
|
||||||
"""
|
"""
|
||||||
# Use a fresh client for streaming with longer timeout
|
# Use a fresh client for streaming with longer timeout
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
@@ -163,6 +262,7 @@ class WebberClient:
|
|||||||
"agent_type": agent_type,
|
"agent_type": agent_type,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"working_dir": working_dir,
|
"working_dir": working_dir,
|
||||||
|
"mode": mode.value,
|
||||||
},
|
},
|
||||||
) as response:
|
) as response:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -170,16 +270,220 @@ class WebberClient:
|
|||||||
if line.startswith("data: "):
|
if line.startswith("data: "):
|
||||||
try:
|
try:
|
||||||
data = json.loads(line[6:])
|
data = json.loads(line[6:])
|
||||||
event = data.get("event")
|
event_type = data.get("event")
|
||||||
if event == "chunk":
|
|
||||||
yield data.get("data", "")
|
# Parse event type
|
||||||
elif event == "error":
|
try:
|
||||||
raise Exception(data.get("data", "Unknown error"))
|
evt_type = StreamEventType(event_type)
|
||||||
elif event == "done":
|
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
|
break
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# === Conversation API ===
|
||||||
|
|
||||||
|
def _parse_message(self, data: dict) -> Message:
|
||||||
|
"""Parse a Message from API response data."""
|
||||||
|
return Message(
|
||||||
|
id=data["id"],
|
||||||
|
role=data["role"],
|
||||||
|
content=data["content"],
|
||||||
|
token_count=data["token_count"],
|
||||||
|
is_summary=data["is_summary"],
|
||||||
|
created_at=datetime.fromisoformat(data["created_at"].replace("Z", "+00:00")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_conversation(self, data: dict, with_messages: bool = False) -> Conversation:
|
||||||
|
"""Parse a Conversation from API response data."""
|
||||||
|
messages = []
|
||||||
|
if with_messages and "messages" in data:
|
||||||
|
messages = [self._parse_message(m) for m in data["messages"]]
|
||||||
|
|
||||||
|
updated_at = None
|
||||||
|
if data.get("updated_at"):
|
||||||
|
updated_at = datetime.fromisoformat(data["updated_at"].replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
return Conversation(
|
||||||
|
id=data["id"],
|
||||||
|
agent_type=data["agent_type"],
|
||||||
|
title=data.get("title"),
|
||||||
|
working_dir=data["working_dir"],
|
||||||
|
total_tokens=data["total_tokens"],
|
||||||
|
created_at=datetime.fromisoformat(data["created_at"].replace("Z", "+00:00")),
|
||||||
|
updated_at=updated_at,
|
||||||
|
messages=messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_conversations(
|
||||||
|
self,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[Conversation], int]:
|
||||||
|
"""
|
||||||
|
List user's conversations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of conversations to return
|
||||||
|
offset: Offset for pagination
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (conversations, total_count)
|
||||||
|
"""
|
||||||
|
client = await self._get_client()
|
||||||
|
response = await client.get(
|
||||||
|
"/conversations/",
|
||||||
|
params={"limit": limit, "offset": offset},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
conversations = [self._parse_conversation(c) for c in data["conversations"]]
|
||||||
|
return conversations, data["total"]
|
||||||
|
|
||||||
|
async def get_conversation(self, conversation_id: str) -> Conversation | None:
|
||||||
|
"""
|
||||||
|
Get a conversation with all messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: UUID of the conversation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Conversation with messages, or None if not found
|
||||||
|
"""
|
||||||
|
client = await self._get_client()
|
||||||
|
response = await client.get(f"/conversations/{conversation_id}")
|
||||||
|
if response.status_code == 404:
|
||||||
|
return None
|
||||||
|
response.raise_for_status()
|
||||||
|
return self._parse_conversation(response.json(), with_messages=True)
|
||||||
|
|
||||||
|
async def create_conversation(
|
||||||
|
self,
|
||||||
|
agent_type: str = "task",
|
||||||
|
working_dir: str = ".",
|
||||||
|
title: str | None = None,
|
||||||
|
) -> Conversation:
|
||||||
|
"""
|
||||||
|
Create a new conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_type: Type of agent to use
|
||||||
|
working_dir: Working directory for the agent
|
||||||
|
title: Optional title for the conversation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created conversation
|
||||||
|
"""
|
||||||
|
client = await self._get_client()
|
||||||
|
response = await client.post(
|
||||||
|
"/conversations/",
|
||||||
|
json={
|
||||||
|
"agent_type": agent_type,
|
||||||
|
"working_dir": working_dir,
|
||||||
|
"title": title,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return self._parse_conversation(response.json())
|
||||||
|
|
||||||
|
async def add_message(
|
||||||
|
self,
|
||||||
|
conversation_id: str,
|
||||||
|
content: str,
|
||||||
|
) -> AddMessageResult:
|
||||||
|
"""
|
||||||
|
Add a message to a conversation and get agent response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: UUID of the conversation
|
||||||
|
content: Message content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AddMessageResult with user and assistant messages
|
||||||
|
"""
|
||||||
|
client = await self._get_client()
|
||||||
|
response = await client.post(
|
||||||
|
f"/conversations/{conversation_id}/messages",
|
||||||
|
json={"content": content},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return AddMessageResult(
|
||||||
|
user_message=self._parse_message(data["user_message"]),
|
||||||
|
assistant_message=self._parse_message(data["assistant_message"]),
|
||||||
|
total_tokens=data["total_tokens"],
|
||||||
|
summarized=data.get("summarized", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def save_messages(
|
||||||
|
self,
|
||||||
|
conversation_id: str,
|
||||||
|
user_content: str,
|
||||||
|
assistant_content: str,
|
||||||
|
) -> SaveMessagesResult:
|
||||||
|
"""
|
||||||
|
Save a user/assistant message pair without triggering agent execution.
|
||||||
|
|
||||||
|
Used when streaming responses separately via run_agent_stream().
|
||||||
|
Allows persisting the exchange after streaming completes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: UUID of the conversation
|
||||||
|
user_content: User message content
|
||||||
|
assistant_content: Assistant response content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SaveMessagesResult with both messages
|
||||||
|
"""
|
||||||
|
client = await self._get_client()
|
||||||
|
response = await client.post(
|
||||||
|
f"/conversations/{conversation_id}/save",
|
||||||
|
json={
|
||||||
|
"user_content": user_content,
|
||||||
|
"assistant_content": assistant_content,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return SaveMessagesResult(
|
||||||
|
user_message=self._parse_message(data["user_message"]),
|
||||||
|
assistant_message=self._parse_message(data["assistant_message"]),
|
||||||
|
total_tokens=data["total_tokens"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_conversation(self, conversation_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: UUID of the conversation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found
|
||||||
|
"""
|
||||||
|
client = await self._get_client()
|
||||||
|
response = await client.delete(f"/conversations/{conversation_id}")
|
||||||
|
if response.status_code == 404:
|
||||||
|
return False
|
||||||
|
response.raise_for_status()
|
||||||
|
return True
|
||||||
|
|
||||||
async def __aenter__(self) -> "WebberClient":
|
async def __aenter__(self) -> "WebberClient":
|
||||||
"""Async context manager entry."""
|
"""Async context manager entry."""
|
||||||
return self
|
return self
|
||||||
|
|||||||
@@ -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
|
||||||
+576
-128
@@ -5,45 +5,146 @@ Webber CLI - Client for the Webber API.
|
|||||||
Usage:
|
Usage:
|
||||||
webber-cli --help
|
webber-cli --help
|
||||||
webber-cli chat [OPTIONS]
|
webber-cli chat [OPTIONS]
|
||||||
webber-cli explore QUERY [OPTIONS]
|
webber-cli status
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
from rich.live import Live
|
from prompt_toolkit import PromptSession
|
||||||
|
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||||
|
from prompt_toolkit.completion import Completer, Completion, PathCompleter
|
||||||
|
from prompt_toolkit.history import FileHistory
|
||||||
|
from prompt_toolkit.styles import Style
|
||||||
from rich.markdown import Markdown
|
from rich.markdown import Markdown
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
|
from rich.prompt import Confirm
|
||||||
|
|
||||||
from webber_cli.client import WebberClient
|
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
|
from webber_cli.theme import get_console, get_theme
|
||||||
|
|
||||||
|
|
||||||
|
# === Prompt Toolkit Setup ===
|
||||||
|
|
||||||
|
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 = [
|
||||||
|
"exit",
|
||||||
|
"quit",
|
||||||
|
"clear",
|
||||||
|
"mode plan",
|
||||||
|
"mode default",
|
||||||
|
"mode auto_accept",
|
||||||
|
"cd ",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class WebberCompleter(Completer):
|
||||||
|
"""Custom completer for Webber CLI commands."""
|
||||||
|
|
||||||
|
def __init__(self, working_dir: str):
|
||||||
|
self.working_dir = working_dir
|
||||||
|
self.path_completer = PathCompleter(expanduser=True)
|
||||||
|
|
||||||
|
def get_completions(self, document, complete_event):
|
||||||
|
text = document.text_before_cursor.lower()
|
||||||
|
|
||||||
|
# Complete built-in commands
|
||||||
|
if not text or not text.startswith("cd "):
|
||||||
|
for cmd in BUILTIN_COMMANDS:
|
||||||
|
if cmd.startswith(text):
|
||||||
|
yield Completion(
|
||||||
|
cmd,
|
||||||
|
start_position=-len(text),
|
||||||
|
display_meta="command",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Complete file paths after "cd "
|
||||||
|
if text.startswith("cd "):
|
||||||
|
path_text = text[3:]
|
||||||
|
# Create a sub-document for path completion
|
||||||
|
from prompt_toolkit.document import Document
|
||||||
|
path_doc = Document(path_text, len(path_text))
|
||||||
|
for completion in self.path_completer.get_completions(path_doc, complete_event):
|
||||||
|
yield Completion(
|
||||||
|
"cd " + (path_text + completion.text),
|
||||||
|
start_position=-len(text),
|
||||||
|
display_meta="directory",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Prompt style matching Rich theme
|
||||||
|
PROMPT_STYLE = Style.from_dict({
|
||||||
|
"prompt": "#5f87d7 bold", # info color
|
||||||
|
"": "", # default text
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def create_prompt_session(working_dir: str) -> PromptSession:
|
||||||
|
"""Create a configured prompt session with history and completion."""
|
||||||
|
return PromptSession(
|
||||||
|
history=FileHistory(str(_get_history_file())),
|
||||||
|
auto_suggest=AutoSuggestFromHistory(),
|
||||||
|
completer=WebberCompleter(working_dir),
|
||||||
|
style=PROMPT_STYLE,
|
||||||
|
complete_while_typing=False, # Only complete on Tab
|
||||||
|
)
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
name="webber-cli",
|
name="webber-cli",
|
||||||
help="CLI client for the Webber API",
|
help="CLI client for the Webber API",
|
||||||
no_args_is_help=True,
|
no_args_is_help=False,
|
||||||
|
invoke_without_command=True,
|
||||||
add_completion=False,
|
add_completion=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
console = get_console()
|
console = get_console()
|
||||||
|
|
||||||
# Default API URL (can be overridden via env or option)
|
|
||||||
# Development port is 8095, production is 8086
|
def _get_api_url() -> str:
|
||||||
DEFAULT_API_URL = os.environ.get("WEBBER_API_URL", "http://localhost:8095")
|
"""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:
|
def version_callback(value: bool) -> None:
|
||||||
"""Display version and exit."""
|
"""Display version and exit."""
|
||||||
if value:
|
if value:
|
||||||
from cli import __version__
|
from webber_cli import __version__
|
||||||
console.print(f"[title]webber-cli[/] version [success]{__version__}[/]")
|
console.print(f"[title]webber-cli[/] version [success]{__version__}[/]")
|
||||||
raise typer.Exit()
|
raise typer.Exit()
|
||||||
|
|
||||||
|
|
||||||
@app.callback()
|
def _confirm_auto_accept() -> bool:
|
||||||
|
"""
|
||||||
|
Prompt user to confirm auto_accept mode.
|
||||||
|
|
||||||
|
Returns True if user confirms, False otherwise.
|
||||||
|
"""
|
||||||
|
console.print()
|
||||||
|
console.print("[warning]WARNING:[/] auto_accept mode bypasses all safety prompts.")
|
||||||
|
console.print("The agent will execute write operations without confirmation.")
|
||||||
|
console.print()
|
||||||
|
return Confirm.ask(
|
||||||
|
"[warning]Are you sure you want to enable auto_accept mode?[/]",
|
||||||
|
default=False,
|
||||||
|
console=console,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.callback(invoke_without_command=True)
|
||||||
def main(
|
def main(
|
||||||
|
ctx: typer.Context,
|
||||||
version: bool = typer.Option(
|
version: bool = typer.Option(
|
||||||
False,
|
False,
|
||||||
"--version",
|
"--version",
|
||||||
@@ -54,7 +155,79 @@ def main(
|
|||||||
),
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Webber CLI - Talk to the Webber API."""
|
"""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(
|
||||||
|
None,
|
||||||
|
"--api",
|
||||||
|
"-a",
|
||||||
|
help="Webber API URL (default from config)",
|
||||||
|
),
|
||||||
|
limit: int = typer.Option(
|
||||||
|
20,
|
||||||
|
"--limit",
|
||||||
|
"-n",
|
||||||
|
help="Maximum number of sessions to show",
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
List previous conversation sessions.
|
||||||
|
|
||||||
|
Shows recent sessions that can be resumed with 'chat --resume <id>'.
|
||||||
|
"""
|
||||||
|
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=_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}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
conversations, total = await client.list_conversations(limit=limit)
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[error]Error listing sessions:[/] {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not conversations:
|
||||||
|
console.print("[dim]No sessions found. Start one with 'webber-cli chat'[/]")
|
||||||
|
return
|
||||||
|
|
||||||
|
console.print(f"[title]Sessions[/] [dim]({len(conversations)} of {total})[/]\n")
|
||||||
|
|
||||||
|
for conv in conversations:
|
||||||
|
# Format the date
|
||||||
|
date_str = conv.created_at.strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
# Title or first message preview
|
||||||
|
title = conv.title or "[dim]untitled[/]"
|
||||||
|
|
||||||
|
# Truncate ID for display
|
||||||
|
short_id = conv.id[:8]
|
||||||
|
|
||||||
|
console.print(
|
||||||
|
f" [info]{short_id}[/] {date_str} "
|
||||||
|
f"[path]{conv.working_dir}[/] {title} "
|
||||||
|
f"[dim]({conv.total_tokens} tokens)[/]"
|
||||||
|
)
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print("[dim]Resume with: webber-cli chat --resume <id>[/]")
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
@@ -63,50 +236,95 @@ def chat(
|
|||||||
".",
|
".",
|
||||||
"--directory",
|
"--directory",
|
||||||
"-d",
|
"-d",
|
||||||
help="Working directory for exploration",
|
help="Working directory for the agent",
|
||||||
),
|
),
|
||||||
api_url: str = typer.Option(
|
api_url: str = typer.Option(
|
||||||
DEFAULT_API_URL,
|
None,
|
||||||
"--api",
|
"--api",
|
||||||
"-a",
|
"-a",
|
||||||
help="Webber API URL",
|
help="Webber API URL (default from config)",
|
||||||
),
|
),
|
||||||
agent: str = typer.Option(
|
mode: str = typer.Option(
|
||||||
"explore",
|
None,
|
||||||
"--agent",
|
"--mode",
|
||||||
help="Agent to use",
|
"-m",
|
||||||
|
help="Permission mode: default, plan, auto_accept (default from config)",
|
||||||
|
),
|
||||||
|
resume: str = typer.Option(
|
||||||
|
None,
|
||||||
|
"--resume",
|
||||||
|
"-r",
|
||||||
|
help="Resume a previous session by ID (use 'sessions' to list)",
|
||||||
),
|
),
|
||||||
stream: bool = typer.Option(
|
stream: bool = typer.Option(
|
||||||
True,
|
None,
|
||||||
"--stream/--no-stream",
|
"--stream/--no-stream",
|
||||||
"-s",
|
"-s",
|
||||||
help="Stream responses in real-time",
|
help="Stream responses in real-time (default from config)",
|
||||||
),
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Start interactive chat session.
|
Start interactive chat session with the Task agent.
|
||||||
|
|
||||||
Connects to the Webber API backend for agent execution.
|
The Task agent is the main orchestrator that can:
|
||||||
|
- Explore and analyze codebases
|
||||||
|
- Plan implementation strategies
|
||||||
|
- Execute code modifications (in default/auto_accept modes)
|
||||||
|
- Spawn sub-agents for focused tasks
|
||||||
|
|
||||||
|
Permission modes:
|
||||||
|
- default: Full capabilities with approval prompts for writes
|
||||||
|
- plan: Read-only mode for safe exploration and planning
|
||||||
|
- auto_accept: Full capabilities without approval prompts (use with caution)
|
||||||
|
|
||||||
|
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())
|
working_dir = str(Path(directory).resolve())
|
||||||
|
|
||||||
if not Path(working_dir).exists():
|
if not Path(working_dir).exists():
|
||||||
console.print(f"[error]Error:[/] Directory not found: {working_dir}")
|
console.print(f"[error]Error:[/] Directory not found: {working_dir}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
# Parse and validate mode
|
||||||
try:
|
try:
|
||||||
asyncio.run(_chat_loop(api_url, agent, working_dir, stream))
|
permission_mode = PermissionMode(mode_str)
|
||||||
|
except ValueError:
|
||||||
|
console.print(f"[error]Error:[/] Invalid mode: {mode_str}")
|
||||||
|
console.print("[dim]Valid modes: default, plan, auto_accept[/]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
# Confirm auto_accept mode (security risk)
|
||||||
|
if permission_mode == PermissionMode.auto_accept:
|
||||||
|
if not _confirm_auto_accept():
|
||||||
|
console.print("[dim]Cancelled. Using default mode instead.[/]")
|
||||||
|
permission_mode = PermissionMode.default
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(_chat_loop(url, working_dir, permission_mode, use_stream, resume))
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
console.print("\n[dim]Goodbye![/]")
|
console.print("\n[dim]Goodbye![/]")
|
||||||
|
|
||||||
|
|
||||||
async def _chat_loop(
|
async def _chat_loop(
|
||||||
api_url: str, agent_type: str, working_dir: str, stream: bool = True
|
api_url: str,
|
||||||
|
working_dir: str,
|
||||||
|
mode: PermissionMode,
|
||||||
|
stream: bool = True,
|
||||||
|
resume_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Interactive chat loop."""
|
"""Interactive chat loop with the Task agent."""
|
||||||
theme = get_theme()
|
theme = get_theme()
|
||||||
|
agent_type = "task"
|
||||||
|
conversation_id: str | None = None
|
||||||
|
conversation_title: str | None = None
|
||||||
|
|
||||||
async with WebberClient(api_url) as client:
|
async with WebberClient(api_url, api_key=_get_api_key()) as client:
|
||||||
# Check API health
|
# Check API health
|
||||||
if not await client.health_check():
|
if not await client.health_check():
|
||||||
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}")
|
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}")
|
||||||
@@ -116,28 +334,99 @@ async def _chat_loop(
|
|||||||
# Get agent info
|
# Get agent info
|
||||||
agent_info = await client.get_agent(agent_type)
|
agent_info = await client.get_agent(agent_type)
|
||||||
if not agent_info:
|
if not agent_info:
|
||||||
console.print(f"[error]Error:[/] Unknown agent: {agent_type}")
|
console.print(f"[error]Error:[/] Task agent not found")
|
||||||
agents = await client.list_agents()
|
|
||||||
console.print("[dim]Available agents:[/]")
|
|
||||||
for a in agents:
|
|
||||||
console.print(f" - {a.name}: {a.description}")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Handle resume or create new conversation
|
||||||
|
if resume_id:
|
||||||
|
# Try to find conversation by ID prefix
|
||||||
|
try:
|
||||||
|
conversations, _ = await client.list_conversations(limit=100)
|
||||||
|
matching = [c for c in conversations if c.id.startswith(resume_id)]
|
||||||
|
if not matching:
|
||||||
|
console.print(f"[error]Error:[/] Session not found: {resume_id}")
|
||||||
|
console.print("[dim]Use 'webber-cli sessions' to list available sessions[/]")
|
||||||
|
return
|
||||||
|
if len(matching) > 1:
|
||||||
|
console.print(f"[error]Error:[/] Ambiguous ID, multiple matches: {resume_id}")
|
||||||
|
for m in matching:
|
||||||
|
console.print(f" - {m.id[:8]} ({m.title or 'untitled'})")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load the conversation with messages
|
||||||
|
conv = await client.get_conversation(matching[0].id)
|
||||||
|
if not conv:
|
||||||
|
console.print(f"[error]Error:[/] Could not load session")
|
||||||
|
return
|
||||||
|
|
||||||
|
conversation_id = conv.id
|
||||||
|
conversation_title = conv.title
|
||||||
|
working_dir = conv.working_dir # Use the session's working directory
|
||||||
|
|
||||||
|
# Display conversation history
|
||||||
|
console.print(f"\n[title]Resuming session[/] [dim]{conv.id[:8]}[/]")
|
||||||
|
if conv.messages:
|
||||||
|
console.print(f"[dim]({len(conv.messages)} messages, {conv.total_tokens} tokens)[/]\n")
|
||||||
|
for msg in conv.messages[-6:]: # Show last 6 messages
|
||||||
|
if msg.role == "user":
|
||||||
|
console.print(f"[prompt]>[/] {msg.content[:100]}{'...' if len(msg.content) > 100 else ''}")
|
||||||
|
else:
|
||||||
|
preview = msg.content[:200].replace('\n', ' ')
|
||||||
|
console.print(f"[dim]{preview}{'...' if len(msg.content) > 200 else ''}[/]\n")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[error]Error resuming session:[/] {e}")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# Create a new conversation
|
||||||
|
try:
|
||||||
|
conv = await client.create_conversation(
|
||||||
|
agent_type=agent_type,
|
||||||
|
working_dir=working_dir,
|
||||||
|
title=None, # Will be set later based on first message
|
||||||
|
)
|
||||||
|
conversation_id = conv.id
|
||||||
|
console.print(f"[dim]Session: {conv.id[:8]}[/]")
|
||||||
|
except Exception as e:
|
||||||
|
# If conversation API fails, continue without persistence
|
||||||
|
console.print(f"[dim]Note: Session persistence unavailable ({e})[/]")
|
||||||
|
|
||||||
|
# Mode display
|
||||||
|
mode_display = {
|
||||||
|
PermissionMode.default: "[info]default[/] (full with approvals)",
|
||||||
|
PermissionMode.plan: "[success]plan[/] (read-only)",
|
||||||
|
PermissionMode.auto_accept: "[warning]auto_accept[/] (no prompts)",
|
||||||
|
}
|
||||||
|
|
||||||
# Welcome message
|
# Welcome message
|
||||||
console.print()
|
console.print()
|
||||||
console.print(f"[title]Webber CLI[/] [dim]→ {api_url}[/]")
|
console.print(f"[title]Webber CLI[/] [dim]→ {api_url}[/]")
|
||||||
console.print(f"[dim]Working in:[/] [path]{working_dir}[/]")
|
console.print(f"[dim]Working in:[/] [path]{working_dir}[/]")
|
||||||
console.print(f"[dim]Agent:[/] {agent_info.name} - {agent_info.description}")
|
console.print(f"[dim]Mode:[/] {mode_display[mode]}")
|
||||||
mode = "streaming" if stream else "batch"
|
console.print(f"[dim]Streaming:[/] {'enabled' if stream else 'disabled'}")
|
||||||
console.print(f"[dim]Mode:[/] {mode}")
|
|
||||||
console.print()
|
console.print()
|
||||||
console.print("[dim]Type 'exit' to quit, 'clear' to clear screen.[/]")
|
console.print("[dim]Commands: 'exit' to quit, 'clear' to clear, 'mode <plan|default|auto_accept>' to switch[/]")
|
||||||
|
console.print("[dim]Tab for completion, Up/Down for history[/]")
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
|
current_mode = mode
|
||||||
|
|
||||||
|
# Create prompt session with history and completion
|
||||||
|
session = create_prompt_session(working_dir)
|
||||||
|
|
||||||
# Chat loop
|
# Chat loop
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
user_input = console.input("[prompt]>[/] ").strip()
|
# Use prompt_toolkit for input (with history and completion)
|
||||||
|
try:
|
||||||
|
user_input = await session.prompt_async(
|
||||||
|
[("class:prompt", "> ")],
|
||||||
|
)
|
||||||
|
user_input = user_input.strip()
|
||||||
|
except EOFError:
|
||||||
|
# Ctrl+D pressed
|
||||||
|
console.print("[dim]Goodbye![/]")
|
||||||
|
break
|
||||||
|
|
||||||
if not user_input:
|
if not user_input:
|
||||||
continue
|
continue
|
||||||
@@ -152,36 +441,123 @@ async def _chat_loop(
|
|||||||
|
|
||||||
if user_input.lower().startswith("cd "):
|
if user_input.lower().startswith("cd "):
|
||||||
new_dir = user_input[3:].strip()
|
new_dir = user_input[3:].strip()
|
||||||
new_path = Path(new_dir).resolve()
|
# Handle ~ expansion
|
||||||
|
new_path = Path(new_dir).expanduser().resolve()
|
||||||
if new_path.exists() and new_path.is_dir():
|
if new_path.exists() and new_path.is_dir():
|
||||||
working_dir = str(new_path)
|
working_dir = str(new_path)
|
||||||
|
# Update completer's working directory
|
||||||
|
session.completer.working_dir = working_dir
|
||||||
console.print(f"[info]Changed to:[/] [path]{working_dir}[/]")
|
console.print(f"[info]Changed to:[/] [path]{working_dir}[/]")
|
||||||
else:
|
else:
|
||||||
console.print(f"[error]Directory not found:[/] {new_dir}")
|
console.print(f"[error]Directory not found:[/] {new_dir}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Mode switching
|
||||||
|
if user_input.lower().startswith("mode "):
|
||||||
|
new_mode_str = user_input[5:].strip()
|
||||||
|
try:
|
||||||
|
new_mode = PermissionMode(new_mode_str)
|
||||||
|
if new_mode == PermissionMode.auto_accept:
|
||||||
|
if not _confirm_auto_accept():
|
||||||
|
console.print("[dim]Mode unchanged.[/]")
|
||||||
|
continue
|
||||||
|
current_mode = new_mode
|
||||||
|
console.print(f"[info]Mode changed to:[/] {mode_display[current_mode]}")
|
||||||
|
except ValueError:
|
||||||
|
console.print(f"[error]Invalid mode:[/] {new_mode_str}")
|
||||||
|
console.print("[dim]Valid modes: default, plan, auto_accept[/]")
|
||||||
|
continue
|
||||||
|
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
if stream:
|
if stream:
|
||||||
# Stream response in real-time
|
# Stream response with structured events
|
||||||
full_response = ""
|
response_chunks: list[str] = []
|
||||||
|
current_tool: str | None = None
|
||||||
try:
|
try:
|
||||||
async for chunk in client.run_agent_stream(
|
async for event in client.run_agent_stream(
|
||||||
agent_type, user_input, working_dir
|
agent_type, user_input, working_dir, current_mode
|
||||||
):
|
):
|
||||||
sys.stdout.write(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()
|
sys.stdout.flush()
|
||||||
full_response += chunk
|
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
|
console.print() # Newline after streaming
|
||||||
|
|
||||||
|
# Save messages to conversation if we have a session
|
||||||
|
if conversation_id and response_chunks:
|
||||||
|
try:
|
||||||
|
full_response = "".join(response_chunks)
|
||||||
|
await client.save_messages(
|
||||||
|
conversation_id,
|
||||||
|
user_input,
|
||||||
|
full_response,
|
||||||
|
)
|
||||||
|
except Exception as save_error:
|
||||||
|
# Log but don't fail the interaction
|
||||||
|
console.print(f"[dim]Note: Could not save to session ({save_error})[/]")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
console.print(f"\n[error]Stream error:[/] {e}")
|
console.print(f"\n[error]Stream error:[/] {e}")
|
||||||
else:
|
else:
|
||||||
# Batch mode with spinner
|
# Batch mode with spinner
|
||||||
with console.status("[info]Thinking...[/]", spinner=theme.spinner):
|
with console.status("[info]Thinking...[/]", spinner=theme.spinner):
|
||||||
result = await client.run_agent(agent_type, user_input, working_dir)
|
result = await client.run_agent(
|
||||||
|
agent_type, user_input, working_dir, current_mode
|
||||||
|
)
|
||||||
|
|
||||||
if result.success:
|
if result.success:
|
||||||
console.print(Markdown(result.response))
|
console.print(Markdown(result.response))
|
||||||
|
|
||||||
|
# Save messages to conversation if we have a session
|
||||||
|
if conversation_id:
|
||||||
|
try:
|
||||||
|
await client.save_messages(
|
||||||
|
conversation_id,
|
||||||
|
user_input,
|
||||||
|
result.response,
|
||||||
|
)
|
||||||
|
except Exception as save_error:
|
||||||
|
console.print(f"[dim]Note: Could not save to session ({save_error})[/]")
|
||||||
else:
|
else:
|
||||||
console.print(f"[error]Error:[/] {result.error}")
|
console.print(f"[error]Error:[/] {result.error}")
|
||||||
|
|
||||||
@@ -194,98 +570,18 @@ async def _chat_loop(
|
|||||||
console.print(f"[error]Error:[/] {e}")
|
console.print(f"[error]Error:[/] {e}")
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
|
||||||
def explore(
|
|
||||||
query: str = typer.Argument(..., help="What to search for"),
|
|
||||||
directory: str = typer.Option(
|
|
||||||
".",
|
|
||||||
"--directory",
|
|
||||||
"-d",
|
|
||||||
help="Working directory",
|
|
||||||
),
|
|
||||||
api_url: str = typer.Option(
|
|
||||||
DEFAULT_API_URL,
|
|
||||||
"--api",
|
|
||||||
"-a",
|
|
||||||
help="Webber API URL",
|
|
||||||
),
|
|
||||||
stream: bool = typer.Option(
|
|
||||||
True,
|
|
||||||
"--stream/--no-stream",
|
|
||||||
"-s",
|
|
||||||
help="Stream responses in real-time",
|
|
||||||
),
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
One-shot codebase exploration.
|
|
||||||
|
|
||||||
Sends a query to the Webber API and displays the result.
|
|
||||||
"""
|
|
||||||
working_dir = str(Path(directory).resolve())
|
|
||||||
|
|
||||||
if not Path(working_dir).exists():
|
|
||||||
console.print(f"[error]Error:[/] Directory not found: {working_dir}")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
asyncio.run(_explore(api_url, query, working_dir, stream))
|
|
||||||
|
|
||||||
|
|
||||||
async def _explore(
|
|
||||||
api_url: str, query: str, working_dir: str, stream: bool = True
|
|
||||||
) -> None:
|
|
||||||
"""Execute exploration query."""
|
|
||||||
theme = get_theme()
|
|
||||||
|
|
||||||
async with WebberClient(api_url) as client:
|
|
||||||
# Check API health
|
|
||||||
if not await client.health_check():
|
|
||||||
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}")
|
|
||||||
console.print("[dim]Make sure the server is running: ./wakeup.sh[/]")
|
|
||||||
return
|
|
||||||
|
|
||||||
console.print(f"[dim]Exploring:[/] [path]{working_dir}[/]")
|
|
||||||
console.print(f"[dim]Query:[/] {query}")
|
|
||||||
console.print()
|
|
||||||
|
|
||||||
if stream:
|
|
||||||
# Stream response in real-time
|
|
||||||
try:
|
|
||||||
async for chunk in client.run_agent_stream("explore", query, working_dir):
|
|
||||||
sys.stdout.write(chunk)
|
|
||||||
sys.stdout.flush()
|
|
||||||
console.print() # Newline after streaming
|
|
||||||
except Exception as e:
|
|
||||||
console.print(f"\n[error]Stream error:[/] {e}")
|
|
||||||
else:
|
|
||||||
# Batch mode with spinner
|
|
||||||
with console.status("[info]Searching...[/]", spinner=theme.spinner):
|
|
||||||
result = await client.run_agent("explore", query, working_dir)
|
|
||||||
|
|
||||||
if result.success:
|
|
||||||
console.print(Panel(
|
|
||||||
Markdown(result.response),
|
|
||||||
title="[success]Findings[/]",
|
|
||||||
border_style=theme.colors.border_success,
|
|
||||||
))
|
|
||||||
else:
|
|
||||||
console.print(Panel(
|
|
||||||
f"[error]{result.error}[/]",
|
|
||||||
title="[error]Error[/]",
|
|
||||||
border_style=theme.colors.border_error,
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def status(
|
def status(
|
||||||
api_url: str = typer.Option(
|
api_url: str = typer.Option(
|
||||||
DEFAULT_API_URL,
|
None,
|
||||||
"--api",
|
"--api",
|
||||||
"-a",
|
"-a",
|
||||||
help="Webber API URL",
|
help="Webber API URL (default from config)",
|
||||||
),
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Check API status and list available agents."""
|
"""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:
|
async def _status(api_url: str) -> None:
|
||||||
@@ -304,5 +600,157 @@ async def _status(api_url: str) -> None:
|
|||||||
console.print("[error]Status:[/] Cannot connect")
|
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(
|
||||||
|
query: str = typer.Argument(..., help="What to search for"),
|
||||||
|
directory: str = typer.Option(
|
||||||
|
".",
|
||||||
|
"--directory",
|
||||||
|
"-d",
|
||||||
|
help="Working directory",
|
||||||
|
),
|
||||||
|
api_url: str = typer.Option(
|
||||||
|
None,
|
||||||
|
"--api",
|
||||||
|
"-a",
|
||||||
|
help="Webber API URL (default from config)",
|
||||||
|
),
|
||||||
|
stream: bool = typer.Option(
|
||||||
|
None,
|
||||||
|
"--stream/--no-stream",
|
||||||
|
"-s",
|
||||||
|
help="Stream responses in real-time (default from config)",
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
[DEPRECATED] One-shot exploration (use 'chat --mode plan' instead).
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
working_dir = str(Path(directory).resolve())
|
||||||
|
|
||||||
|
if not Path(working_dir).exists():
|
||||||
|
console.print(f"[error]Error:[/] Directory not found: {working_dir}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
asyncio.run(_explore(url, query, working_dir, use_stream))
|
||||||
|
|
||||||
|
|
||||||
|
async def _explore(
|
||||||
|
api_url: str, query: str, working_dir: str, stream: bool = True
|
||||||
|
) -> None:
|
||||||
|
"""Execute exploration query in plan mode."""
|
||||||
|
theme = get_theme()
|
||||||
|
mode = PermissionMode.plan
|
||||||
|
|
||||||
|
async with WebberClient(api_url) as client:
|
||||||
|
# Check API health
|
||||||
|
if not await client.health_check():
|
||||||
|
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}")
|
||||||
|
console.print("[dim]Make sure the server is running: ./wakeup.sh[/]")
|
||||||
|
return
|
||||||
|
|
||||||
|
console.print(f"[dim]Exploring:[/] [path]{working_dir}[/]")
|
||||||
|
console.print(f"[dim]Query:[/] {query}")
|
||||||
|
console.print(f"[dim]Mode:[/] [success]plan[/] (read-only)")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
if stream:
|
||||||
|
# Stream response with structured events
|
||||||
|
try:
|
||||||
|
async for event in client.run_agent_stream(
|
||||||
|
"task", query, working_dir, mode
|
||||||
|
):
|
||||||
|
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}")
|
||||||
|
else:
|
||||||
|
# Batch mode with spinner
|
||||||
|
with console.status("[info]Searching...[/]", spinner=theme.spinner):
|
||||||
|
result = await client.run_agent("task", query, working_dir, mode)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
console.print(Panel(
|
||||||
|
Markdown(result.response),
|
||||||
|
title="[success]Findings[/]",
|
||||||
|
border_style=theme.colors.border_success,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
console.print(Panel(
|
||||||
|
f"[error]{result.error}[/]",
|
||||||
|
title="[error]Error[/]",
|
||||||
|
border_style=theme.colors.border_error,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app()
|
app()
|
||||||
|
|||||||
@@ -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