Compare commits
@@ -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
|
||||
@@ -1,20 +1,31 @@
|
||||
name: Build and Push
|
||||
name: Build and Push API
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- 'api/v*'
|
||||
|
||||
env:
|
||||
IMAGE_NAME: git.schweitz.net/jpmschweitzer/webber-api
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: |
|
||||
# Extract version from api/v0.3.0 -> v0.3.0
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#api/}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Gitea Release
|
||||
run: |
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tag_name": "${{ github.ref_name }}", "name": "Release ${{ github.ref_name }}", "body": "Automated release for ${{ github.ref_name }}"}' \
|
||||
-d '{"tag_name": "${{ github.ref_name }}", "name": "API Release ${{ steps.version.outputs.version }}", "body": "Automated release for webber-api ${{ steps.version.outputs.version }}"}' \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||
|
||||
build:
|
||||
@@ -23,21 +34,28 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: |
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#api/}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.schweitz.internal
|
||||
registry: git.schweitz.net
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
context: ./webber-api
|
||||
push: true
|
||||
tags: |
|
||||
git.schweitz.internal/jpmschweitzer/webber:latest
|
||||
git.schweitz.internal/jpmschweitzer/webber:${{ github.ref_name }}
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Trigger Watchtower update
|
||||
if: success()
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Build and Release CLI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'cli/v*'
|
||||
|
||||
# TODO: Implement CLI installer build
|
||||
# This workflow will be implemented when CLI distribution is ready.
|
||||
# Possible targets:
|
||||
# - PyPI package
|
||||
# - Standalone binary (PyInstaller)
|
||||
# - Platform-specific installers
|
||||
|
||||
jobs:
|
||||
placeholder:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: |
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#cli/}"
|
||||
echo "CLI release triggered for version: $VERSION"
|
||||
echo "TODO: Implement CLI build and distribution"
|
||||
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
|
||||
+24
@@ -64,3 +64,27 @@ Thumbs.db
|
||||
# Project specific
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# Monorepo - subproject venvs (explicit for clarity)
|
||||
webber-api/.venv/
|
||||
webber-cli/.venv/
|
||||
webber-sandbox/.venv/
|
||||
|
||||
# Sandbox marker file
|
||||
webber-sandbox/.current_template
|
||||
|
||||
# Ruff cache
|
||||
.ruff_cache/
|
||||
|
||||
# Claude Code user-specific settings
|
||||
.claude/settings.local.json
|
||||
.pql/*
|
||||
!.pql/changelog/
|
||||
|
||||
# pql shims planted by `pql init` into the dir core.hooksPath points at.
|
||||
# Per-clone: each embeds the absolute path of the pql binary that planted it.
|
||||
# Only .githooks/pre-push is shared.
|
||||
.githooks/pre-commit
|
||||
.githooks/post-merge
|
||||
.githooks/post-checkout
|
||||
.githooks/post-rewrite
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Changelog format marker, written by pql. Comments only: this file
|
||||
-- is never executed — Import descends into the per-table directories
|
||||
-- and does not read the changelog root.
|
||||
--
|
||||
-- A changelog carrying no marker is format 1, the shape that existed
|
||||
-- before formats were versioned. An older format is migrated forward
|
||||
-- by `pql plan upgrade` (and automatically from the post-merge hook);
|
||||
-- a newer one is refused rather than replayed under rules this binary
|
||||
-- does not know. See D-28 and docs/versions.md.
|
||||
-- pql:changelog_format: 2.0.0
|
||||
-- pql:written_by: 2.2.0
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -1,116 +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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
+6
-46
@@ -1,50 +1,10 @@
|
||||
# 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/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- **[webber-api/CHANGELOG.md](webber-api/CHANGELOG.md)** - API server changes
|
||||
- **[webber-cli/CHANGELOG.md](webber-cli/CHANGELOG.md)** - CLI client changes
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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)
|
||||
Each package is versioned independently using prefixed git tags:
|
||||
- `api/vX.Y.Z` for API releases
|
||||
- `cli/vX.Y.Z` for CLI releases
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,123 @@
|
||||
# Webber - Multi-Agent AI Development System
|
||||
|
||||
A Claude Code-inspired development assistant powered by local LLMs via Ollama.
|
||||
|
||||
## Features
|
||||
|
||||
- **3 Agents** - Explore (read-only), Plan (architecture), Task (orchestrator)
|
||||
- **8 Tools** - File read/write/edit, glob, grep, bash, web search
|
||||
- **Conversations** - Multi-turn memory with context summarization
|
||||
- **Streaming** - Real-time response display
|
||||
- **Self-hosted** - Runs on your own hardware with Ollama
|
||||
|
||||
## Structure
|
||||
|
||||
This is a monorepo containing three subprojects:
|
||||
|
||||
| Directory | Description |
|
||||
|-----------|-------------|
|
||||
| `webber-api/` | FastAPI backend server with agent orchestration |
|
||||
| `webber-cli/` | Command-line client for interacting with the API |
|
||||
| `webber-sandbox/` | Test project for functional testing |
|
||||
|
||||
### Additional Directories
|
||||
|
||||
| Directory | Description |
|
||||
|-----------|-------------|
|
||||
| `sandbox-templates/` | Reusable project templates for the sandbox |
|
||||
| `.gitea/workflows/` | CI/CD workflows for releases |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Start the API Server
|
||||
|
||||
```bash
|
||||
cd webber-api
|
||||
python3.12 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt -r requirements-dev.txt
|
||||
./wakeup.sh
|
||||
```
|
||||
|
||||
### 2. Set Up the CLI
|
||||
|
||||
```bash
|
||||
cd webber-cli
|
||||
python3.12 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
pip install -e .
|
||||
|
||||
# Test connection
|
||||
webber-cli status
|
||||
```
|
||||
|
||||
### 3. Explore with Webber
|
||||
|
||||
```bash
|
||||
# One-shot exploration
|
||||
webber-cli explore "find all bugs in the code" -d /path/to/project
|
||||
|
||||
# Interactive chat
|
||||
webber-cli chat -d /path/to/project
|
||||
```
|
||||
|
||||
## Available Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `read_file` | Read file contents with line numbers |
|
||||
| `glob_files` | Find files by pattern |
|
||||
| `grep_content` | Search file contents with regex |
|
||||
| `bash_readonly` | Safe bash commands (ls, git status, etc.) |
|
||||
| `edit_file` | Find-and-replace editing |
|
||||
| `write_file` | Create/overwrite files |
|
||||
| `bash` | Full bash with safety controls |
|
||||
| `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
|
||||
|
||||
This project uses prefixed tags for independent release cycles:
|
||||
|
||||
- `api/v0.4.0` - Triggers API Docker build and deployment
|
||||
- `cli/v0.1.0` - Triggers CLI installer build (future)
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.12+
|
||||
- Ollama running with `gemma4:e2b` model
|
||||
- Docker (for production deployment)
|
||||
- SearXNG (optional, for web search)
|
||||
|
||||
## Documentation
|
||||
|
||||
- `CLAUDE.md` - Agent development guidelines (repo-wide)
|
||||
- `webber-api/docs/COVERAGE.md` - Feature coverage and roadmap
|
||||
- `webber-api/docs/architecture.md` - System architecture
|
||||
- `webber-cli/README.md` - CLI usage guide
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
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)_
|
||||
@@ -1,628 +0,0 @@
|
||||
# Webber FastAPI Boilerplate Plan
|
||||
|
||||
## Overview
|
||||
Set up FastAPI boilerplate for "Webber" - a multi-agent AI development system (similar to Claude Code, but local with different models). Follows core-api patterns with defensive coding practices.
|
||||
|
||||
**Key Decision: PydanticAI Framework**
|
||||
After research, [PydanticAI](https://ai.pydantic.dev/) is the recommended agent coordination framework:
|
||||
- Model-agnostic: supports Ollama, OpenAI, Anthropic, and 20+ providers
|
||||
- Type-safe with Pydantic validation (same ecosystem as FastAPI)
|
||||
- Built-in tool/function calling with automatic schema generation
|
||||
- Multi-agent support for complex workflows
|
||||
- Maintained by Pydantic team (285M+ monthly downloads)
|
||||
|
||||
**Port: 8086** (next available slot after Headscale 8085 per CONTAINERS.md)
|
||||
|
||||
**Default Models (always hot in VRAM on tower-of-joy):**
|
||||
- Agent reasoning: `mistral-nemo-large:latest`
|
||||
- Embeddings: `nomic-embed-text:latest`
|
||||
|
||||
**Target Clients:**
|
||||
- **Tatlock Butler**: External advisor integration for coding/software guidance
|
||||
- **CLI Interface**: TBD - command-line interface for local development
|
||||
|
||||
**Multi-tenancy:** API key authentication integrated with tatlock-ui/core-api user management
|
||||
|
||||
---
|
||||
|
||||
## 1. Directory Structure
|
||||
|
||||
```
|
||||
webber/
|
||||
├── AGENTS.md # Expanded with defensive LLM guidelines
|
||||
├── README.md # Project overview
|
||||
├── CHANGELOG.md # Version history
|
||||
├── pyproject.toml # Package metadata
|
||||
├── requirements.txt # Production dependencies only (~= pinned)
|
||||
├── requirements-dev.txt # Dev/test dependencies (pytest, pip-audit, etc.)
|
||||
├── .env.example # Environment template
|
||||
├── wakeup.sh # Dev startup (update port to 8086)
|
||||
│
|
||||
├── src/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI app, lifespan, user provider init
|
||||
│ │ # NO routes here - delegates to domain routers
|
||||
│ │
|
||||
│ ├── shared/ # Cross-cutting concerns
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── base.py # BaseController, BaseSchema
|
||||
│ │ ├── config.py # Pydantic BaseSettings
|
||||
│ │ ├── logging.py # Logger decorator + centralized setup
|
||||
│ │ ├── exceptions.py # Custom exception hierarchy
|
||||
│ │ ├── auth.py # API key validation, multi-tenant support
|
||||
│ │ └── context.py # UserProvider singleton, request context
|
||||
│ │
|
||||
│ └── domains/ # Feature domains (each with router.py)
|
||||
│ ├── __init__.py
|
||||
│ ├── router.py # Root router - includes all domain routers
|
||||
│ │
|
||||
│ ├── health/ # Health endpoints
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── router.py # Health routes
|
||||
│ │ └── controller.py # Health logic
|
||||
│ │
|
||||
│ ├── auth/ # Authentication domain
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── router.py # Auth routes (API key mgmt)
|
||||
│ │ ├── controller.py
|
||||
│ │ └── schemas.py
|
||||
│ │
|
||||
│ │── agents/ # Agent domain container
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── router.py # Agent routes (lists agents, runs them)
|
||||
│ │ ├── controller.py # Agent orchestration logic
|
||||
│ │ ├── schemas.py
|
||||
│ │ │
|
||||
│ │ ├── explore/ # Explore agent (codebase navigation)
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── agent.py # PydanticAI agent definition
|
||||
│ │ │ └── prompts.py # System prompts
|
||||
│ │ │
|
||||
│ │ ├── plan/ # Plan agent (implementation design)
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── agent.py
|
||||
│ │ │ └── prompts.py
|
||||
│ │ │
|
||||
│ │ └── task/ # Task agent (execution)
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── agent.py
|
||||
│ │ └── prompts.py
|
||||
│ │
|
||||
│ └── tools/ # Tool domain container
|
||||
│ ├── __init__.py
|
||||
│ ├── router.py # Tool routes (list tools, execute)
|
||||
│ ├── controller.py # Tool orchestration
|
||||
│ ├── schemas.py
|
||||
│ │
|
||||
│ ├── file/ # File operation tools
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── read.py
|
||||
│ │ ├── write.py
|
||||
│ │ └── glob.py
|
||||
│ │
|
||||
│ ├── shell/ # Shell execution tools
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── bash.py
|
||||
│ │
|
||||
│ └── search/ # Search tools
|
||||
│ ├── __init__.py
|
||||
│ ├── grep.py
|
||||
│ └── web.py
|
||||
│
|
||||
├── tests/
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py
|
||||
│ └── test_health.py
|
||||
│
|
||||
└── docs/
|
||||
└── architecture.md
|
||||
```
|
||||
|
||||
### Key Architectural Decisions
|
||||
|
||||
1. **Clean main.py**: Only app creation, lifespan, and UserProvider init. All routes in domain routers.
|
||||
2. **Domain routers**: Each domain has `router.py` that defines routes. Root `domains/router.py` composes them.
|
||||
3. **Separate agent domains**: Each agent type (explore, plan, task) in its own subdir under `agents/`.
|
||||
4. **Separate tool domains**: Each tool category (file, shell, search) in its own subdir under `tools/`.
|
||||
5. **UserProvider singleton**: Set once in main.py lifespan, accessible everywhere via `shared/context.py`.
|
||||
6. **Multi-tenant auth**: API key validation in `shared/auth.py`, integrates with tatlock-ui/core-api.
|
||||
|
||||
---
|
||||
|
||||
## 2. Key Files to Create
|
||||
|
||||
### Phase 1: Foundation (fully implemented)
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/shared/base.py` | BaseController, BaseSchema |
|
||||
| `src/shared/config.py` | Settings via Pydantic BaseSettings |
|
||||
| `src/shared/logging.py` | Logger decorator + centralized setup |
|
||||
| `src/shared/exceptions.py` | Custom exception hierarchy |
|
||||
| `src/shared/auth.py` | API key validation, tatlock integration stub |
|
||||
| `src/shared/context.py` | UserProvider singleton pattern |
|
||||
| `src/main.py` | FastAPI app, lifespan, UserProvider init (no routes!) |
|
||||
| `src/domains/router.py` | Root router composing all domain routers |
|
||||
| `src/domains/health/router.py` | Health routes |
|
||||
| `src/domains/health/controller.py` | Health logic |
|
||||
| `pyproject.toml` | Package metadata, pytest config |
|
||||
| `requirements.txt` | Production deps (~= pinned) |
|
||||
| `requirements-dev.txt` | Dev/test deps (pytest, pip-audit) |
|
||||
| `.env.example` | Environment variable template |
|
||||
| `tests/conftest.py` | Pytest fixtures |
|
||||
| `tests/test_health.py` | Basic endpoint tests |
|
||||
|
||||
### Phase 2: Placeholders (structure + README docs)
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `src/domains/auth/` | API key management (stub) |
|
||||
| `src/domains/agents/` | Agent container with explore/plan/task subdirs |
|
||||
| `src/domains/tools/` | Tool container with file/shell/search subdirs |
|
||||
| `docs/architecture.md` | System design documentation |
|
||||
|
||||
---
|
||||
|
||||
## 3. Dependency Management
|
||||
|
||||
### requirements.txt (Production - baked into Docker)
|
||||
```
|
||||
# Webber Production Dependencies
|
||||
# Minor version pinning (~=) for security patches
|
||||
# CVE check date: 2026-01-09
|
||||
# CVE check sources: PyPI, GitHub Advisories, Snyk, NVD
|
||||
|
||||
# Core FastAPI
|
||||
fastapi~=0.115.0
|
||||
starlette~=0.45.0
|
||||
uvicorn[standard]~=0.34.0
|
||||
pydantic~=2.11.0
|
||||
pydantic-settings~=2.7.0
|
||||
|
||||
# Agent Framework
|
||||
pydantic-ai~=0.0.39 # Multi-agent LLM orchestration
|
||||
|
||||
# HTTP
|
||||
httpx~=0.28.0
|
||||
aiofiles~=24.1.0
|
||||
|
||||
# Utilities
|
||||
python-multipart~=0.0.18
|
||||
python-dotenv~=1.0.0
|
||||
```
|
||||
|
||||
### requirements-dev.txt (Dev/Test only - NOT in Docker)
|
||||
```
|
||||
# Webber Development Dependencies
|
||||
# Install with: pip install -r requirements-dev.txt
|
||||
|
||||
-r requirements.txt # Include production deps
|
||||
|
||||
# Testing
|
||||
pytest~=8.3.0
|
||||
pytest-asyncio~=0.24.0
|
||||
pytest-cov~=6.0.0
|
||||
|
||||
# Security auditing
|
||||
pip-audit~=2.7.0 # Run before releases: pip-audit
|
||||
|
||||
# Type checking
|
||||
mypy~=1.13.0
|
||||
|
||||
# Code formatting (optional)
|
||||
# ruff~=0.8.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. AGENTS.md Additions
|
||||
|
||||
Add these new sections:
|
||||
|
||||
### Section 3: Defensive LLM Coding Practices
|
||||
- Input validation requirements
|
||||
- Output parsing guidelines (expect malformed responses)
|
||||
- Timeout and retry policies
|
||||
- Security: no secrets in prompts, sandbox execution
|
||||
|
||||
### Section 4: Pattern Reuse Requirements
|
||||
- Search existing code before writing new
|
||||
- Check `src/shared/` for base classes
|
||||
- Follow domain structure template
|
||||
- Code review checklist
|
||||
|
||||
### Section 5: CVE Check Process
|
||||
- Check PyPI, GitHub Advisories, Snyk, NVD before adding deps
|
||||
- Document CVE decisions in requirements.txt
|
||||
- Run `pip-audit` before releases
|
||||
|
||||
### Section 6: Mandatory Documentation
|
||||
- Required reading before work: AGENTS.md, docs/architecture.md, src/shared/base.py
|
||||
- Changelog and docstring requirements
|
||||
|
||||
### Section 7: Project Structure Reference
|
||||
- Directory tree with explanations
|
||||
- Domain structure template
|
||||
|
||||
---
|
||||
|
||||
## 5. Configuration (Settings)
|
||||
|
||||
Environment variables for:
|
||||
- **App**: DEBUG, LOG_LEVEL
|
||||
- **Server**: HOST, PORT (default **8086** per CONTAINERS.md allocation)
|
||||
- **CORS**: origins, methods, headers
|
||||
- **LLM Models** (hot in VRAM on tower-of-joy):
|
||||
- OLLAMA_URL (default: http://192.168.86.149:11434)
|
||||
- OLLAMA_AGENT_MODEL (default: mistral-nemo-large:latest)
|
||||
- OLLAMA_EMBED_MODEL (default: nomic-embed-text:latest)
|
||||
- **Auth**:
|
||||
- TATLOCK_API_URL (default: http://192.168.86.149:8000)
|
||||
- Internal API key for tatlock user validation
|
||||
- **Tools**: TOOL_TIMEOUT_SECONDS, SANDBOX_ENABLED, ALLOWED_PATHS
|
||||
- **Sessions**: SESSION_TTL_HOURS, MAX_CONTEXT_TOKENS
|
||||
|
||||
---
|
||||
|
||||
## 6. Core Patterns
|
||||
|
||||
### Logger Decorator with Temporal Benchmarking (shared/logging.py)
|
||||
```python
|
||||
import functools
|
||||
import asyncio
|
||||
import time
|
||||
import logging
|
||||
from typing import Callable, Optional
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, field
|
||||
from uuid import uuid4
|
||||
|
||||
# Trace context for nested timing
|
||||
@dataclass
|
||||
class TraceSpan:
|
||||
name: str
|
||||
trace_id: str
|
||||
parent_id: Optional[str] = None
|
||||
span_id: str = field(default_factory=lambda: uuid4().hex[:8])
|
||||
start_time: float = field(default_factory=time.perf_counter)
|
||||
end_time: Optional[float] = None
|
||||
|
||||
@property
|
||||
def duration_ms(self) -> float:
|
||||
if self.end_time is None:
|
||||
return (time.perf_counter() - self.start_time) * 1000
|
||||
return (self.end_time - self.start_time) * 1000
|
||||
|
||||
# Context variable for trace propagation
|
||||
_current_span: ContextVar[Optional[TraceSpan]] = ContextVar('current_span', default=None)
|
||||
_trace_id: ContextVar[Optional[str]] = ContextVar('trace_id', default=None)
|
||||
|
||||
def get_current_trace_id() -> Optional[str]:
|
||||
"""Get current trace ID for correlation."""
|
||||
return _trace_id.get()
|
||||
|
||||
def logged(
|
||||
logger: logging.Logger = None,
|
||||
slow_threshold_ms: float = 100.0,
|
||||
warn_threshold_ms: float = 500.0,
|
||||
include_args: bool = False,
|
||||
):
|
||||
"""
|
||||
Decorator for automatic function logging with temporal benchmarking.
|
||||
|
||||
Args:
|
||||
logger: Logger instance (defaults to module logger)
|
||||
slow_threshold_ms: Log INFO if execution exceeds this (default 100ms)
|
||||
warn_threshold_ms: Log WARNING if execution exceeds this (default 500ms)
|
||||
include_args: Include function arguments in log (careful with sensitive data)
|
||||
|
||||
Usage:
|
||||
@logged()
|
||||
async def my_function(): ...
|
||||
|
||||
@logged(slow_threshold_ms=50, warn_threshold_ms=200)
|
||||
def critical_path(): ...
|
||||
"""
|
||||
def decorator(func: Callable):
|
||||
nonlocal logger
|
||||
if logger is None:
|
||||
logger = logging.getLogger(func.__module__)
|
||||
|
||||
func_name = f"{func.__module__}.{func.__qualname__}"
|
||||
|
||||
def _create_span() -> TraceSpan:
|
||||
parent = _current_span.get()
|
||||
trace_id = _trace_id.get() or uuid4().hex[:16]
|
||||
if _trace_id.get() is None:
|
||||
_trace_id.set(trace_id)
|
||||
return TraceSpan(
|
||||
name=func_name,
|
||||
trace_id=trace_id,
|
||||
parent_id=parent.span_id if parent else None,
|
||||
)
|
||||
|
||||
def _log_completion(span: TraceSpan, error: Exception = None):
|
||||
span.end_time = time.perf_counter()
|
||||
duration = span.duration_ms
|
||||
|
||||
# Build log context
|
||||
ctx = {
|
||||
"trace_id": span.trace_id,
|
||||
"span_id": span.span_id,
|
||||
"duration_ms": round(duration, 2),
|
||||
"func": func_name,
|
||||
}
|
||||
if span.parent_id:
|
||||
ctx["parent_id"] = span.parent_id
|
||||
|
||||
if error:
|
||||
logger.error(
|
||||
f"[{span.trace_id[:8]}] {func_name} FAILED after {duration:.2f}ms: {error}",
|
||||
extra=ctx,
|
||||
exc_info=True
|
||||
)
|
||||
elif duration >= warn_threshold_ms:
|
||||
logger.warning(
|
||||
f"[{span.trace_id[:8]}] {func_name} SLOW: {duration:.2f}ms (threshold: {warn_threshold_ms}ms)",
|
||||
extra=ctx
|
||||
)
|
||||
elif duration >= slow_threshold_ms:
|
||||
logger.info(
|
||||
f"[{span.trace_id[:8]}] {func_name} completed in {duration:.2f}ms",
|
||||
extra=ctx
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"[{span.trace_id[:8]}] {func_name} completed in {duration:.2f}ms",
|
||||
extra=ctx
|
||||
)
|
||||
|
||||
@functools.wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
span = _create_span()
|
||||
token = _current_span.set(span)
|
||||
|
||||
if include_args:
|
||||
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}({args}, {kwargs})")
|
||||
else:
|
||||
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}")
|
||||
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
_log_completion(span)
|
||||
return result
|
||||
except Exception as e:
|
||||
_log_completion(span, error=e)
|
||||
raise
|
||||
finally:
|
||||
_current_span.reset(token)
|
||||
|
||||
@functools.wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
span = _create_span()
|
||||
token = _current_span.set(span)
|
||||
|
||||
if include_args:
|
||||
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}({args}, {kwargs})")
|
||||
else:
|
||||
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}")
|
||||
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
_log_completion(span)
|
||||
return result
|
||||
except Exception as e:
|
||||
_log_completion(span, error=e)
|
||||
raise
|
||||
finally:
|
||||
_current_span.reset(token)
|
||||
|
||||
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
# Convenience for manual span creation (context manager)
|
||||
class trace_span:
|
||||
"""
|
||||
Context manager for manual span creation.
|
||||
|
||||
Usage:
|
||||
with trace_span("database_query"):
|
||||
result = await db.execute(query)
|
||||
|
||||
async with trace_span("llm_call"):
|
||||
response = await agent.run(prompt)
|
||||
"""
|
||||
def __init__(self, name: str, logger: logging.Logger = None):
|
||||
self.name = name
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.span: Optional[TraceSpan] = None
|
||||
self.token = None
|
||||
|
||||
def __enter__(self):
|
||||
parent = _current_span.get()
|
||||
trace_id = _trace_id.get() or uuid4().hex[:16]
|
||||
if _trace_id.get() is None:
|
||||
_trace_id.set(trace_id)
|
||||
|
||||
self.span = TraceSpan(
|
||||
name=self.name,
|
||||
trace_id=trace_id,
|
||||
parent_id=parent.span_id if parent else None,
|
||||
)
|
||||
self.token = _current_span.set(self.span)
|
||||
self.logger.debug(f"[{self.span.trace_id[:8]}] -> {self.name}")
|
||||
return self.span
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
if self.span:
|
||||
self.span.end_time = time.perf_counter()
|
||||
duration = self.span.duration_ms
|
||||
if exc_val:
|
||||
self.logger.error(f"[{self.span.trace_id[:8]}] {self.name} FAILED: {duration:.2f}ms")
|
||||
else:
|
||||
self.logger.debug(f"[{self.span.trace_id[:8]}] {self.name}: {duration:.2f}ms")
|
||||
if self.token:
|
||||
_current_span.reset(self.token)
|
||||
return False
|
||||
|
||||
async def __aenter__(self):
|
||||
return self.__enter__()
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
return self.__exit__(exc_type, exc_val, exc_tb)
|
||||
```
|
||||
|
||||
**Example output:**
|
||||
```
|
||||
DEBUG [a1b2c3d4] -> src.domains.agents.controller.run_agent
|
||||
DEBUG [a1b2c3d4] -> src.domains.llm.service.call_ollama
|
||||
DEBUG [a1b2c3d4] src.domains.llm.service.call_ollama: 45.23ms
|
||||
INFO [a1b2c3d4] src.domains.agents.controller.run_agent completed in 156.78ms
|
||||
WARN [a1b2c3d4] src.domains.tools.file.read.read_file SLOW: 523.45ms (threshold: 500ms)
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- **Trace IDs**: Correlate logs across nested calls
|
||||
- **Parent/child spans**: Track call hierarchy
|
||||
- **Configurable thresholds**: `slow_threshold_ms` (INFO), `warn_threshold_ms` (WARNING)
|
||||
- **Context manager**: `trace_span()` for manual instrumentation of code blocks
|
||||
- **Zero overhead path**: Fast path for sub-threshold calls (DEBUG only)
|
||||
|
||||
### UserProvider Singleton (shared/context.py)
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from contextvars import ContextVar
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
id: str
|
||||
email: str
|
||||
api_key: str
|
||||
tenant_id: Optional[str] = None
|
||||
|
||||
# Context variable for request-scoped user
|
||||
_current_user: ContextVar[Optional[User]] = ContextVar('current_user', default=None)
|
||||
|
||||
class UserProvider:
|
||||
"""Singleton for user context management."""
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def set_user(self, user: User) -> None:
|
||||
_current_user.set(user)
|
||||
|
||||
def get_user(self) -> Optional[User]:
|
||||
return _current_user.get()
|
||||
|
||||
def clear_user(self) -> None:
|
||||
_current_user.set(None)
|
||||
|
||||
# Global singleton
|
||||
user_provider = UserProvider()
|
||||
```
|
||||
|
||||
### BaseController (from core-api)
|
||||
```python
|
||||
class BaseController(ABC):
|
||||
def __init__(self, prefix: str, tags: list[str]):
|
||||
self.prefix = prefix
|
||||
self.tags = tags
|
||||
self._router = None
|
||||
|
||||
@abstractmethod
|
||||
def create_router(self) -> APIRouter: pass
|
||||
|
||||
@property
|
||||
def router(self) -> APIRouter:
|
||||
if self._router is None:
|
||||
self._router = self.create_router()
|
||||
return self._router
|
||||
```
|
||||
|
||||
### PydanticAI Agent Pattern (placeholder for future)
|
||||
```python
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.models.ollama import OllamaModel
|
||||
|
||||
# Use the hot model from VRAM
|
||||
agent = Agent(
|
||||
OllamaModel('mistral-nemo-large:latest'),
|
||||
system_prompt='You are a helpful assistant.',
|
||||
)
|
||||
|
||||
@agent.tool
|
||||
async def search_files(ctx, pattern: str) -> str:
|
||||
"""Search for files matching pattern."""
|
||||
pass # Implementation in tools/search/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation Order
|
||||
|
||||
1. **Create directory structure** (`src/`, `src/shared/`, `src/domains/`)
|
||||
2. **Implement shared modules** (base.py, config.py, logging.py, exceptions.py)
|
||||
3. **Create main.py** with FastAPI app and lifespan
|
||||
4. **Add health domain** as working example
|
||||
5. **Set up tests** (conftest.py, test_health.py)
|
||||
6. **Create placeholder domains** (llm, agents, tools - structure only)
|
||||
7. **Update AGENTS.md** with new sections
|
||||
8. **Create supporting files** (pyproject.toml, requirements.txt, .env.example)
|
||||
9. **Add docs/architecture.md**
|
||||
|
||||
---
|
||||
|
||||
## 8. Verification
|
||||
|
||||
After implementation:
|
||||
1. `./wakeup.sh` starts server without errors
|
||||
2. `curl http://localhost:8086/health` returns healthy
|
||||
3. `http://localhost:8086/docs` shows API documentation
|
||||
4. `.venv/bin/python -m pytest tests/ -v` passes
|
||||
5. Code follows patterns in AGENTS.md
|
||||
|
||||
---
|
||||
|
||||
## 9. Critical Reference Files
|
||||
|
||||
- `/mnt/media/Projects/core-api/src/shared/base.py` - BaseController pattern
|
||||
- `/mnt/media/Projects/core-api/src/shared/config.py` - Settings pattern
|
||||
- `/mnt/media/Projects/core-api/src/domains/health/controller.py` - Controller example
|
||||
- https://ai.pydantic.dev/ - PydanticAI documentation
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**What will be created:**
|
||||
- Complete FastAPI project structure following core-api patterns
|
||||
- Working health endpoint at `http://localhost:8086/health`
|
||||
- **Clean main.py** - no routes, just app init and UserProvider setup
|
||||
- **Domain routers** - each domain has router.py, composed by root router
|
||||
- **Logger decorator** - centralized logging via `@logged` decorator
|
||||
- **UserProvider singleton** - request-scoped user context, no parameter passing
|
||||
- **Multi-tenant auth stub** - API key validation ready for tatlock integration
|
||||
- Separate **requirements.txt** (prod) and **requirements-dev.txt** (dev/test)
|
||||
- Placeholder domains with agent/tool subdirectories (explore, plan, task / file, shell, search)
|
||||
- Comprehensive AGENTS.md with defensive LLM coding practices, CVE checks, pattern reuse
|
||||
- Test infrastructure with pytest
|
||||
- docs/architecture.md explaining the system design
|
||||
|
||||
**What will NOT be created (deferred):**
|
||||
- Database layer (add when needed)
|
||||
- Full agent/tool implementations (PydanticAI patterns documented for future work)
|
||||
- Docker/deployment configuration (can add later)
|
||||
- CLI interface (TBD)
|
||||
|
||||
**Key decisions:**
|
||||
- Port: **8086**
|
||||
- Agent framework: **PydanticAI**
|
||||
- Default model: **mistral-nemo-large:latest** (hot in VRAM)
|
||||
- Embeddings: **nomic-embed-text:latest** (hot in VRAM)
|
||||
- No database initially
|
||||
- Separate prod/dev requirements
|
||||
- UserProvider singleton pattern for multi-tenancy
|
||||
@@ -1,37 +0,0 @@
|
||||
[project]
|
||||
name = "webber"
|
||||
version = "0.2.2"
|
||||
description = "Mrs. Webber - Multi-Agent AI Development System"
|
||||
authors = [
|
||||
{name = "jpmschweitzer"}
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = {text = "MIT"}
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Framework :: FastAPI",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Software Development :: Code Generators",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=75.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["src*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = "-v"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
warn_return_any = true
|
||||
warn_unused_ignores = true
|
||||
strict = false
|
||||
ignore_missing_imports = true
|
||||
@@ -0,0 +1,25 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# Project
|
||||
.current_template
|
||||
@@ -0,0 +1,64 @@
|
||||
# Calculator CLI - Tasks for Webber
|
||||
|
||||
A simple calculator with intentional bugs and missing features for testing Webber's capabilities.
|
||||
|
||||
## Bugs to Fix
|
||||
|
||||
### High Priority
|
||||
- [ ] **Division by zero** - `operations.py:divide()` crashes when dividing by zero instead of returning an error
|
||||
- [ ] **Invalid operation name** - `main.py:get_operation()` raises KeyError for unknown operations instead of helpful error message
|
||||
|
||||
### Medium Priority
|
||||
- [ ] **Power function broken** - `operations.py:power()` doesn't handle negative exponents or fractional exponents correctly
|
||||
- [ ] **No input validation** - `main.py` doesn't validate that command-line arguments are valid numbers
|
||||
|
||||
## Missing Tests
|
||||
|
||||
- [ ] Add `TestDivide` class with tests for:
|
||||
- Normal division
|
||||
- Division by zero (should test error handling once bug is fixed)
|
||||
- Division with negative numbers
|
||||
|
||||
- [ ] Add `TestPower` class with tests for:
|
||||
- Positive integer exponents
|
||||
- Zero exponent (should return 1)
|
||||
- Negative exponents
|
||||
|
||||
- [ ] Complete existing test classes:
|
||||
- `test_add_zero`
|
||||
- `test_add_floats`
|
||||
- `test_subtract_negative`
|
||||
- `test_multiply_by_zero`
|
||||
|
||||
## Features to Add
|
||||
|
||||
- [ ] **Expose power operation** - Add 'pow' to the operations dictionary in `main.py`
|
||||
- [ ] **Add modulo operation** - Implement `modulo(a, b)` in operations.py
|
||||
- [ ] **Add --verbose flag** - Show step-by-step calculation
|
||||
- [ ] **Add history command** - Track and display recent calculations
|
||||
- [ ] **Add REPL mode** - Interactive calculator loop
|
||||
|
||||
## Code Quality
|
||||
|
||||
- [ ] Add type hints to all functions
|
||||
- [ ] Add docstrings following Google style
|
||||
- [ ] Fix any linting errors (run `ruff check src/`)
|
||||
|
||||
## How to Run
|
||||
|
||||
```bash
|
||||
# Setup
|
||||
python3.12 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run calculator
|
||||
python -m calculator.main 10 5 add
|
||||
python -m calculator.main 10 5 div
|
||||
|
||||
# Run tests
|
||||
pytest tests/ -v
|
||||
|
||||
# See failing tests (division by zero)
|
||||
python -m calculator.main 10 0 div
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
[project]
|
||||
name = "calculator"
|
||||
version = "0.1.0"
|
||||
description = "A simple calculator CLI with some bugs"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=75.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
addopts = "-v"
|
||||
@@ -0,0 +1,2 @@
|
||||
# Calculator CLI dependencies
|
||||
pytest>=8.0.0
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Calculator CLI - A simple calculator with some bugs for testing."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Calculator CLI - A simple command-line calculator.
|
||||
|
||||
NOTE: This file contains intentional bugs for testing purposes.
|
||||
|
||||
Usage:
|
||||
python -m calculator.main 10 5 add
|
||||
python -m calculator.main 10 5 sub
|
||||
python -m calculator.main 10 5 mul
|
||||
python -m calculator.main 10 5 div
|
||||
"""
|
||||
import sys
|
||||
|
||||
from calculator.operations import add, subtract, multiply, divide
|
||||
|
||||
|
||||
def get_operation(op_name: str):
|
||||
"""
|
||||
Get the operation function by name.
|
||||
|
||||
BUG: No validation - invalid operation names cause KeyError!
|
||||
"""
|
||||
operations = {
|
||||
"add": add,
|
||||
"sub": subtract,
|
||||
"mul": multiply,
|
||||
"div": divide,
|
||||
# BUG: 'power' is implemented in operations.py but not exposed here
|
||||
}
|
||||
# BUG: Should handle KeyError gracefully
|
||||
return operations[op_name]
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
if len(sys.argv) != 4:
|
||||
print("Usage: python -m calculator.main <a> <b> <operation>")
|
||||
print("Operations: add, sub, mul, div")
|
||||
sys.exit(1)
|
||||
|
||||
# BUG: No validation that a and b are valid numbers
|
||||
a = float(sys.argv[1])
|
||||
b = float(sys.argv[2])
|
||||
op_name = sys.argv[3]
|
||||
|
||||
# BUG: This will crash with KeyError for invalid operation
|
||||
operation = get_operation(op_name)
|
||||
result = operation(a, b)
|
||||
|
||||
print(f"Result: {result}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Math operations for the calculator.
|
||||
|
||||
NOTE: This file contains intentional bugs for testing purposes.
|
||||
"""
|
||||
|
||||
|
||||
def add(a: float, b: float) -> float:
|
||||
"""Add two numbers."""
|
||||
return a + b
|
||||
|
||||
|
||||
def subtract(a: float, b: float) -> float:
|
||||
"""Subtract b from a."""
|
||||
return a - b
|
||||
|
||||
|
||||
def multiply(a: float, b: float) -> float:
|
||||
"""Multiply two numbers."""
|
||||
return a * b
|
||||
|
||||
|
||||
def divide(a: float, b: float) -> float:
|
||||
"""
|
||||
Divide a by b.
|
||||
|
||||
BUG: Does not handle division by zero!
|
||||
"""
|
||||
# BUG: No check for b == 0
|
||||
return a / b
|
||||
|
||||
|
||||
def power(a: float, b: float) -> float:
|
||||
"""
|
||||
Raise a to the power of b.
|
||||
|
||||
BUG: Negative exponents not handled correctly for some cases.
|
||||
"""
|
||||
# BUG: This naive implementation has issues with negative bases and fractional exponents
|
||||
result = 1
|
||||
for _ in range(int(b)):
|
||||
result *= a
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
"""Calculator tests."""
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Tests for calculator operations.
|
||||
|
||||
NOTE: Test coverage is intentionally incomplete for testing purposes.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from calculator.operations import add, subtract, multiply
|
||||
|
||||
|
||||
class TestAdd:
|
||||
"""Tests for add operation."""
|
||||
|
||||
def test_add_positive_numbers(self):
|
||||
assert add(2, 3) == 5
|
||||
|
||||
def test_add_negative_numbers(self):
|
||||
assert add(-2, -3) == -5
|
||||
|
||||
# MISSING: test_add_zero, test_add_floats
|
||||
|
||||
|
||||
class TestSubtract:
|
||||
"""Tests for subtract operation."""
|
||||
|
||||
def test_subtract_positive(self):
|
||||
assert subtract(5, 3) == 2
|
||||
|
||||
# MISSING: test_subtract_negative, test_subtract_resulting_negative
|
||||
|
||||
|
||||
class TestMultiply:
|
||||
"""Tests for multiply operation."""
|
||||
|
||||
def test_multiply_positive(self):
|
||||
assert multiply(3, 4) == 12
|
||||
|
||||
# MISSING: test_multiply_by_zero, test_multiply_negative
|
||||
|
||||
|
||||
# MISSING: TestDivide class entirely!
|
||||
# - test_divide_positive
|
||||
# - test_divide_by_zero (should test error handling)
|
||||
# - test_divide_negative
|
||||
|
||||
# MISSING: TestPower class entirely!
|
||||
@@ -0,0 +1,25 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# Project
|
||||
.current_template
|
||||
@@ -0,0 +1,18 @@
|
||||
# My Project - Tasks
|
||||
|
||||
A blank starter template. Define your own tasks here.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
python3.12 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Define your project goals
|
||||
- [ ] Add source files to `src/myproject/`
|
||||
- [ ] Add tests to `tests/`
|
||||
- [ ] Update `requirements.txt` with dependencies
|
||||
@@ -0,0 +1,17 @@
|
||||
[project]
|
||||
name = "myproject"
|
||||
version = "0.1.0"
|
||||
description = "A blank starter project"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=75.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
addopts = "-v"
|
||||
@@ -0,0 +1,2 @@
|
||||
# Add your dependencies here
|
||||
pytest>=8.0.0
|
||||
@@ -0,0 +1,3 @@
|
||||
"""My Project - A blank starter template."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for myproject."""
|
||||
Executable
+193
@@ -0,0 +1,193 @@
|
||||
#!/bin/bash
|
||||
# Sandbox management script for Webber testing
|
||||
#
|
||||
# Usage:
|
||||
# ./sandbox.sh list - List available templates
|
||||
# ./sandbox.sh load <template> - Load a template into sandbox
|
||||
# ./sandbox.sh reset - Reset sandbox to last loaded template
|
||||
# ./sandbox.sh save <name> - Save current sandbox as new template
|
||||
# ./sandbox.sh status - Show current sandbox status
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SANDBOX_DIR="$SCRIPT_DIR/webber-sandbox"
|
||||
TEMPLATES_DIR="$SCRIPT_DIR/sandbox-templates"
|
||||
MARKER_FILE="$SANDBOX_DIR/.current_template"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
usage() {
|
||||
echo "Webber Sandbox Manager"
|
||||
echo ""
|
||||
echo "Usage: ./sandbox.sh <command> [template]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " list List available templates"
|
||||
echo " load <template> Load a template into sandbox (preserves .venv)"
|
||||
echo " reset Reset sandbox to last loaded template"
|
||||
echo " save <name> Save current sandbox as new template"
|
||||
echo " status Show current sandbox status"
|
||||
echo ""
|
||||
echo "Available templates:"
|
||||
ls -1 "$TEMPLATES_DIR" 2>/dev/null || echo " (none)"
|
||||
}
|
||||
|
||||
list_templates() {
|
||||
echo "Available templates:"
|
||||
echo ""
|
||||
for dir in "$TEMPLATES_DIR"/*/; do
|
||||
if [ -d "$dir" ]; then
|
||||
name=$(basename "$dir")
|
||||
desc=""
|
||||
if [ -f "$dir/TASKS.md" ]; then
|
||||
desc=$(head -1 "$dir/TASKS.md" | sed 's/^#\s*//')
|
||||
fi
|
||||
printf " %-20s %s\n" "$name" "$desc"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
load_template() {
|
||||
local template="$1"
|
||||
|
||||
if [ -z "$template" ]; then
|
||||
echo -e "${RED}Error: Template name required${NC}"
|
||||
echo "Usage: ./sandbox.sh load <template>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "$TEMPLATES_DIR/$template" ]; then
|
||||
echo -e "${RED}Error: Template '$template' not found${NC}"
|
||||
echo "Available templates:"
|
||||
ls -1 "$TEMPLATES_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Loading template: $template${NC}"
|
||||
|
||||
# Create sandbox dir if needed
|
||||
mkdir -p "$SANDBOX_DIR"
|
||||
|
||||
# Clear sandbox contents (except .venv and .git)
|
||||
find "$SANDBOX_DIR" -mindepth 1 -maxdepth 1 ! -name '.venv' ! -name '.git' -exec rm -rf {} +
|
||||
|
||||
# Copy template contents (including hidden files)
|
||||
cp -r "$TEMPLATES_DIR/$template/." "$SANDBOX_DIR/"
|
||||
|
||||
# Mark which template was loaded
|
||||
echo "$template" > "$MARKER_FILE"
|
||||
|
||||
echo -e "${GREEN}Loaded template: $template${NC}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " cd webber-sandbox"
|
||||
if [ ! -d "$SANDBOX_DIR/.venv" ]; then
|
||||
echo " python3.12 -m venv .venv"
|
||||
fi
|
||||
echo " source .venv/bin/activate"
|
||||
echo " pip install -r requirements.txt"
|
||||
echo ""
|
||||
if [ -f "$SANDBOX_DIR/TASKS.md" ]; then
|
||||
echo "Tasks available in TASKS.md"
|
||||
fi
|
||||
}
|
||||
|
||||
reset_template() {
|
||||
if [ ! -f "$MARKER_FILE" ]; then
|
||||
echo -e "${RED}Error: No template loaded yet${NC}"
|
||||
echo "Use './sandbox.sh load <template>' first"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local template=$(cat "$MARKER_FILE")
|
||||
echo "Resetting to template: $template"
|
||||
load_template "$template"
|
||||
}
|
||||
|
||||
save_template() {
|
||||
local name="$1"
|
||||
|
||||
if [ -z "$name" ]; then
|
||||
echo -e "${RED}Error: Template name required${NC}"
|
||||
echo "Usage: ./sandbox.sh save <name>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -d "$TEMPLATES_DIR/$name" ]; then
|
||||
echo -e "${YELLOW}Warning: Template '$name' already exists${NC}"
|
||||
read -p "Overwrite? [y/N] " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Cancelled"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf "$TEMPLATES_DIR/$name"
|
||||
fi
|
||||
|
||||
mkdir -p "$TEMPLATES_DIR/$name"
|
||||
|
||||
# Copy sandbox contents (except .venv, .git, __pycache__)
|
||||
rsync -a --exclude='.venv' --exclude='.git' --exclude='__pycache__' \
|
||||
--exclude='*.pyc' --exclude='.pytest_cache' --exclude='.mypy_cache' \
|
||||
"$SANDBOX_DIR/" "$TEMPLATES_DIR/$name/"
|
||||
|
||||
echo -e "${GREEN}Saved template: $name${NC}"
|
||||
}
|
||||
|
||||
show_status() {
|
||||
echo "Sandbox Status"
|
||||
echo "=============="
|
||||
echo ""
|
||||
echo "Sandbox directory: $SANDBOX_DIR"
|
||||
|
||||
if [ -f "$MARKER_FILE" ]; then
|
||||
echo "Current template: $(cat "$MARKER_FILE")"
|
||||
else
|
||||
echo "Current template: (none loaded)"
|
||||
fi
|
||||
|
||||
if [ -d "$SANDBOX_DIR/.venv" ]; then
|
||||
echo "Virtual env: exists"
|
||||
else
|
||||
echo "Virtual env: not created"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Contents:"
|
||||
if [ -d "$SANDBOX_DIR" ]; then
|
||||
ls -la "$SANDBOX_DIR" 2>/dev/null | tail -n +4
|
||||
else
|
||||
echo " (sandbox not initialized)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Main command dispatch
|
||||
case "${1:-}" in
|
||||
list)
|
||||
list_templates
|
||||
;;
|
||||
load)
|
||||
load_template "$2"
|
||||
;;
|
||||
reset)
|
||||
reset_template
|
||||
;;
|
||||
save)
|
||||
save_template "$2"
|
||||
;;
|
||||
status)
|
||||
show_status
|
||||
;;
|
||||
-h|--help|"")
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unknown command: $1${NC}"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,83 +0,0 @@
|
||||
"""
|
||||
Application configuration via Pydantic Settings.
|
||||
|
||||
All settings loaded from environment variables or .env file.
|
||||
"""
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
def _get_version() -> str:
|
||||
"""Load version from pyproject.toml."""
|
||||
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
|
||||
try:
|
||||
with open(pyproject_path, "rb") as f:
|
||||
return tomllib.load(f).get("project", {}).get("version", "0.0.0")
|
||||
except FileNotFoundError:
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
__version__ = _get_version()
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment."""
|
||||
|
||||
# Application
|
||||
app_name: str = "Webber"
|
||||
app_version: str = __version__
|
||||
debug: bool = False
|
||||
|
||||
# Server
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8086
|
||||
|
||||
# Logging
|
||||
log_level: str = "INFO"
|
||||
|
||||
# CORS
|
||||
cors_origins: list[str] = ["http://localhost:3000", "http://localhost:8080"]
|
||||
cors_credentials: bool = True
|
||||
cors_methods: list[str] = ["*"]
|
||||
cors_headers: list[str] = ["*"]
|
||||
|
||||
# LLM - Ollama (always hot in VRAM on tower-of-joy)
|
||||
ollama_url: str = "http://192.168.86.149:11434"
|
||||
ollama_agent_model: str = "mistral-nemo-large:latest"
|
||||
ollama_embed_model: str = "nomic-embed-text:latest"
|
||||
|
||||
# Auth - Tatlock integration
|
||||
tatlock_api_url: Optional[str] = "http://192.168.86.149:8000"
|
||||
internal_api_key: Optional[str] = None
|
||||
|
||||
# Tool execution
|
||||
tool_timeout_seconds: int = 120
|
||||
sandbox_enabled: bool = True
|
||||
allowed_paths: Optional[list[str]] = None
|
||||
|
||||
# Sessions
|
||||
session_ttl_hours: int = 24
|
||||
max_context_tokens: int = 128000
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
env_parse_none_str="", # Treat empty string as None
|
||||
)
|
||||
|
||||
@property
|
||||
def effective_allowed_paths(self) -> list[str]:
|
||||
"""Return allowed_paths or empty list if None."""
|
||||
return self.allowed_paths or []
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""Cached settings singleton."""
|
||||
return Settings()
|
||||
@@ -1,34 +0,0 @@
|
||||
"""
|
||||
Pytest configuration and fixtures.
|
||||
"""
|
||||
import pytest
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from src.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
"""Use asyncio for async tests."""
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
"""Async HTTP client for testing."""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test"
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_client():
|
||||
"""Async HTTP client with API key for authenticated requests."""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"X-API-Key": "test-api-key"}
|
||||
) as ac:
|
||||
yield ac
|
||||
@@ -14,13 +14,16 @@ CORS_ORIGINS=["http://localhost:3000","http://localhost:8080"]
|
||||
|
||||
# LLM - Ollama (tower-of-joy)
|
||||
OLLAMA_URL=http://192.168.86.149:11434
|
||||
OLLAMA_AGENT_MODEL=mistral-nemo-large:latest
|
||||
OLLAMA_AGENT_MODEL=gemma4:e2b
|
||||
OLLAMA_EMBED_MODEL=nomic-embed-text:latest
|
||||
|
||||
# Auth - Tatlock integration (optional)
|
||||
# TATLOCK_API_URL=http://192.168.86.149:8000
|
||||
# TATLOCK_API_URL=http://tatlock:8000
|
||||
# INTERNAL_API_KEY=your-internal-key
|
||||
|
||||
# Web search - SearXNG (container name on docker-dataplane; internal port 8080)
|
||||
# SEARXNG_URL=http://searxng:8080
|
||||
|
||||
# Tool execution
|
||||
TOOL_TIMEOUT_SECONDS=120
|
||||
SANDBOX_ENABLED=true
|
||||
@@ -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)
|
||||
@@ -19,4 +19,7 @@ ENV PYTHONPATH=/app
|
||||
|
||||
EXPOSE 8086
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8086/health || exit 1
|
||||
|
||||
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8086", "--workers", "1"]
|
||||
@@ -0,0 +1,275 @@
|
||||
# Webber Feature Coverage
|
||||
|
||||
> Tracking progress towards Claude Code-like functionality
|
||||
|
||||
## Current Status: ~85% Complete
|
||||
|
||||
Last updated: 2026-01-14
|
||||
|
||||
---
|
||||
|
||||
## Phase 1-6: Foundation (Original Plan)
|
||||
|
||||
### Phase 1: Tool Infrastructure ✅ Complete
|
||||
|
||||
| Component | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| `BaseTool` abstract class | ✅ | `src/domains/tools/base.py` |
|
||||
| `ToolResult` dataclass | ✅ | Consistent success/error/truncated handling |
|
||||
| `ReadFileTool` | ✅ | With line numbers, offset/limit support |
|
||||
| `GlobFilesTool` | ✅ | Pattern matching, sorted by mtime |
|
||||
| `GrepContentTool` | ✅ | Regex search with context lines |
|
||||
| `BashReadOnlyTool` | ✅ | Allowlist-based command filtering |
|
||||
| `EditFileTool` | ✅ | Find-and-replace with unique match validation |
|
||||
| `WriteFileTool` | ✅ | Create/overwrite files with size limits |
|
||||
| `BashTool` (full) | ✅ | Write-enabled shell with safety controls |
|
||||
| `WebSearchTool` | ✅ | SearXNG integration for web search |
|
||||
| Path validation | ✅ | `allowed_paths` restriction |
|
||||
|
||||
**Status:** Tools honor `.gitignore` patterns and default ignores (`.venv/`, `__pycache__/`, etc.)
|
||||
|
||||
### Phase 2: Explore Agent ✅ Complete
|
||||
|
||||
| Component | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| `BaseAgent` abstract class | ✅ | `src/domains/agents/base.py` |
|
||||
| Agent registry | ✅ | `register_agent()`, `get_agent()`, `list_agents()` |
|
||||
| `ExploreAgentImpl` | ✅ | PydanticAI-based implementation |
|
||||
| System prompts | ✅ | Mistral-optimized with tool examples |
|
||||
| Tool registration | ✅ | `@agent.tool` decorator pattern |
|
||||
| Sanitized Ollama provider | ✅ | Fixes `content: null` issue |
|
||||
| Streaming support | ✅ | `run_stream()` method with SSE |
|
||||
|
||||
**Available tools:** `read_file`, `glob_files`, `grep_content`, `bash_readonly`, `edit_file`, `write_file`, `bash`, `web_search`
|
||||
|
||||
**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
|
||||
|
||||
| Component | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| Typer + Rich setup | ✅ | Standalone `webber-cli/` package |
|
||||
| `webber-cli --version` | ✅ | Shows version from pyproject.toml |
|
||||
| Console theming | ✅ | Centralized color palette |
|
||||
| Markdown rendering | ✅ | Rich markdown output |
|
||||
| Streaming display | ✅ | Real-time token output with `--stream` flag |
|
||||
|
||||
### Phase 4: Agentic Loop ✅ Complete
|
||||
|
||||
| Component | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| `webber-cli chat` command | ✅ | Interactive mode with streaming |
|
||||
| `webber-cli explore` command | ✅ | One-shot query with streaming |
|
||||
| `SessionState` dataclass | ✅ | Basic context tracking |
|
||||
| `AgenticLoop` class | ✅ | Basic implementation |
|
||||
| Conversation persistence | ✅ | SQLAlchemy async with SQLite/PostgreSQL |
|
||||
| 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
|
||||
|
||||
| Component | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| `POST /agents/run` | ✅ | Execute agent with prompt |
|
||||
| `POST /agents/stream` | ✅ | SSE streaming responses |
|
||||
| `GET /agents/` | ✅ | List available agents |
|
||||
| `GET /agents/{name}` | ✅ | Get agent info |
|
||||
| Request/response schemas | ✅ | Pydantic models |
|
||||
|
||||
### Phase 6: Polish & Tests ✅ Complete
|
||||
|
||||
| Component | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| Tool unit tests | ✅ | 109 tests total |
|
||||
| API endpoint tests | ✅ | 11 tests for agent routes |
|
||||
| Health check tests | ✅ | 2 tests |
|
||||
| Security tests | ✅ | 14 tests for path traversal, injection |
|
||||
| Integration tests | ✅ | 10 tests with real LLM (requires Ollama) |
|
||||
| E2E tests | ✅ | 12 tests against running API server |
|
||||
|
||||
---
|
||||
|
||||
## Future Work: Remaining Features
|
||||
|
||||
### High Priority
|
||||
|
||||
| Feature | Category | Description | Complexity |
|
||||
|---------|----------|-------------|------------|
|
||||
| ~~**Plan Agent**~~ | Agents | ✅ Design implementation approaches | High |
|
||||
| ~~**Task Agent**~~ | Agents | ✅ Autonomous multi-step execution | High |
|
||||
| ~~**Context summarization**~~ | Infrastructure | ✅ Token counting + auto-summarization | High |
|
||||
| ~~**Conversation persistence**~~ | Infrastructure | ✅ SQLAlchemy async database layer | Medium |
|
||||
|
||||
### Medium Priority
|
||||
|
||||
| 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 |
|
||||
| ~~**Session persistence**~~ | CLI | ✅ Save/resume conversations via `sessions` and `chat --resume` | Medium |
|
||||
| **Todo tracking** | CLI | Built-in task list (`/todo`) | Medium |
|
||||
| **Git integration** | CLI | Auto-commit, branch management | Medium |
|
||||
| ~~**Agent handoff**~~ | Orchestration | ✅ Task agent is main agent, spawns Explore/Plan as needed (Claude Code pattern) | High |
|
||||
| ~~**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
|
||||
|
||||
| Feature | Category | Description | Complexity |
|
||||
|---------|----------|-------------|------------|
|
||||
| **Notebook editing** | Tools | Jupyter cell manipulation | Medium |
|
||||
| **MCP support** | Infrastructure | Model Context Protocol | High |
|
||||
| ~~**Config file**~~ | CLI | ✅ `~/.webber/config.toml` with `config` command | Low |
|
||||
| **IDE integration** | CLI | VS Code extension | High |
|
||||
| **Parallel agents** | Orchestration | Concurrent agent execution | High |
|
||||
| **Agent memory** | Orchestration | Shared context between agents | Medium |
|
||||
|
||||
---
|
||||
|
||||
## Testing Coverage
|
||||
|
||||
| Area | Current | Target | Status |
|
||||
|------|---------|--------|--------|
|
||||
| Tool unit tests | 109 | 109 | ✅ |
|
||||
| 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 | ✅ |
|
||||
| Integration tests | 10 | 10 | ✅ Agent + real LLM |
|
||||
| E2E tests | 12 | 12 | ✅ Full API workflow |
|
||||
|
||||
**Total: 208 tests passing**
|
||||
|
||||
**Test breakdown:**
|
||||
- Read/Glob/Grep tools: 17 tests
|
||||
- Edit/Write tools: 22 tests
|
||||
- Bash tools: 22 tests
|
||||
- Web search: 10 tests
|
||||
- Gitignore filtering: 10 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
|
||||
- Health checks: 2 tests
|
||||
- Integration (LLM): 10 tests
|
||||
- E2E (API): 12 tests
|
||||
|
||||
**Running tests:**
|
||||
```bash
|
||||
# Unit tests only (default)
|
||||
pytest tests/
|
||||
|
||||
# Include integration tests (requires Ollama)
|
||||
pytest tests/ --run-integration
|
||||
|
||||
# Include E2E tests (requires running API server)
|
||||
pytest tests/ --run-e2e
|
||||
|
||||
# All tests
|
||||
pytest tests/ --run-integration --run-e2e
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Issues
|
||||
|
||||
1. **Model hallucination** - Mistral Nemo sometimes makes up file contents instead of using actual tool results.
|
||||
|
||||
2. **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.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Decisions Made
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Monorepo structure | `webber-api/`, `webber-cli/` | Separate packages, shared root |
|
||||
| Sanitized Ollama provider | Custom wrapper | Fixes PydanticAI + Ollama `content: null` bug |
|
||||
| Dev port 8095 | Separate from prod 8086 | Avoid conflicts with Docker deployment |
|
||||
| Tool choice "required" | Force tool use | Mistral Nemo needs explicit instruction |
|
||||
| Temperature 0.3 | Mistral recommendation | 0.0 caused issues with Nemo |
|
||||
| SearXNG for search | Self-hosted | Privacy, no API keys needed |
|
||||
| SSE for streaming | Server-Sent Events | Simple, well-supported |
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: What Works Now
|
||||
|
||||
```bash
|
||||
# Start dev server
|
||||
cd webber-api && ./wakeup.sh
|
||||
|
||||
# CLI commands (from webber-cli/)
|
||||
.venv/bin/webber-cli status # Check API connection
|
||||
.venv/bin/webber-cli chat # Interactive mode (Task agent, full tools)
|
||||
.venv/bin/webber-cli chat --mode plan # Read-only mode (safe exploration)
|
||||
.venv/bin/webber-cli chat --mode auto_accept # No approval prompts (use with caution)
|
||||
|
||||
# API endpoints
|
||||
curl http://localhost:8095/health
|
||||
curl http://localhost:8095/agents/
|
||||
curl -X POST http://localhost:8095/agents/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-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
|
||||
curl -N http://localhost:8095/agents/stream \
|
||||
-H "Content-Type: application/json" \
|
||||
-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"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tools Available
|
||||
|
||||
| Tool | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `read_file` | Read | Read file contents with line numbers |
|
||||
| `glob_files` | Read | Find files by pattern |
|
||||
| `grep_content` | Read | Search file contents with regex |
|
||||
| `bash_readonly` | Read | Safe bash commands (ls, git status, etc.) |
|
||||
| `edit_file` | Write | Find-and-replace editing |
|
||||
| `write_file` | Write | Create/overwrite files |
|
||||
| `bash` | Write | Full bash with safety controls |
|
||||
| `web_search` | External | Search web via SearXNG |
|
||||
@@ -0,0 +1,417 @@
|
||||
# Webber Architecture
|
||||
|
||||
Multi-Agent AI Development System - similar to Claude Code but running locally with configurable models.
|
||||
|
||||
## Overview
|
||||
|
||||
Webber is a FastAPI-based agent orchestration service that provides:
|
||||
- Multi-agent execution (Explore, Plan, Task)
|
||||
- Tool capabilities (file operations, shell, search)
|
||||
- Multi-tenant authentication via Tatlock integration
|
||||
- PydanticAI framework for LLM orchestration
|
||||
|
||||
**Port:** 8086
|
||||
**Runtime:** Python 3.12, FastAPI, Uvicorn
|
||||
**Agent Framework:** PydanticAI
|
||||
**Default LLM:** Ollama with gemma4:e2b
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
webber/
|
||||
├── src/
|
||||
│ ├── 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
|
||||
│ │ ├── base.py # BaseController, BaseSchema
|
||||
│ │ ├── config.py # Pydantic Settings
|
||||
│ │ ├── logging.py # @logged decorator, trace_span
|
||||
│ │ ├── exceptions.py # Custom exception hierarchy
|
||||
│ │ ├── auth.py # API key validation
|
||||
│ │ ├── context.py # UserProvider singleton
|
||||
│ │ └── tokens.py # Token counting utilities (litellm)
|
||||
│ │
|
||||
│ └── domains/ # Feature domains
|
||||
│ ├── router.py # Root router (composes all)
|
||||
│ ├── health/ # Health endpoints
|
||||
│ ├── 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
|
||||
│ │ ├── explore/ # Codebase navigation
|
||||
│ │ ├── plan/ # Implementation design
|
||||
│ │ └── task/ # Execution
|
||||
│ └── tools/ # Tool execution
|
||||
│ ├── file/ # Read, write, glob
|
||||
│ ├── shell/ # Bash execution
|
||||
│ └── search/ # Grep, web search
|
||||
│
|
||||
├── tests/
|
||||
├── docs/
|
||||
└── logs/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### 1. Clean main.py
|
||||
|
||||
The entry point contains ONLY:
|
||||
- FastAPI app creation with lifespan
|
||||
- CORS middleware
|
||||
- Auth middleware (sets UserProvider)
|
||||
- Exception handlers
|
||||
- Single router include (`root_router`)
|
||||
|
||||
All routes live in domain routers. This keeps main.py focused on app initialization.
|
||||
|
||||
### 2. Domain-Based Structure
|
||||
|
||||
Each feature domain has its own directory:
|
||||
```
|
||||
domains/
|
||||
├── router.py # Root router composing all domains
|
||||
├── health/
|
||||
│ ├── router.py # Domain routes
|
||||
│ └── controller.py # Business logic
|
||||
├── agents/
|
||||
│ ├── router.py # Agent routes
|
||||
│ ├── controller.py # Orchestration logic
|
||||
│ ├── schemas.py # Request/response models
|
||||
│ └── explore/ # Agent implementation
|
||||
│ ├── agent.py # PydanticAI agent
|
||||
│ └── prompts.py # System prompts
|
||||
```
|
||||
|
||||
### 3. BaseController Pattern
|
||||
|
||||
Controllers use lazy router instantiation:
|
||||
|
||||
```python
|
||||
from src.shared.base import BaseController
|
||||
|
||||
class MyController(BaseController):
|
||||
def __init__(self):
|
||||
super().__init__(prefix="/my", tags=["My"])
|
||||
|
||||
def create_router(self) -> APIRouter:
|
||||
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||
|
||||
@router.get("/")
|
||||
async def list_items():
|
||||
return []
|
||||
|
||||
return router
|
||||
|
||||
my_controller = MyController()
|
||||
# Use: my_controller.router
|
||||
```
|
||||
|
||||
### 4. UserProvider Singleton
|
||||
|
||||
Request-scoped user context without parameter passing:
|
||||
|
||||
```python
|
||||
# In middleware (main.py):
|
||||
user = await validate_api_key(api_key)
|
||||
user_provider.set_user(user)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
finally:
|
||||
user_provider.clear_user()
|
||||
|
||||
# Anywhere in code:
|
||||
from src.shared.context import get_current_user, require_user
|
||||
|
||||
user = get_current_user() # Returns None if not authenticated
|
||||
user = require_user() # Raises if not authenticated
|
||||
```
|
||||
|
||||
Uses Python's `contextvars` for async-safe request isolation.
|
||||
|
||||
### 5. Logger with Temporal Benchmarking
|
||||
|
||||
The `@logged()` decorator automatically tracks execution time:
|
||||
|
||||
```python
|
||||
from src.shared.logging import logged, trace_span, get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@logged()
|
||||
async def my_function():
|
||||
# Automatically logs entry, exit, and duration
|
||||
pass
|
||||
|
||||
@logged(slow_threshold_ms=50, warn_threshold_ms=200)
|
||||
def critical_path():
|
||||
# Custom thresholds for performance-critical code
|
||||
pass
|
||||
|
||||
async def complex_operation():
|
||||
async with trace_span("llm_call"):
|
||||
# Manual span for specific sections
|
||||
result = await agent.run(prompt)
|
||||
```
|
||||
|
||||
Features:
|
||||
- Trace ID correlation across nested calls
|
||||
- Configurable slow/warn thresholds
|
||||
- DEBUG: all calls logged with timing
|
||||
- INFO: slow calls (>100ms default)
|
||||
- WARNING: very slow calls (>500ms default)
|
||||
- ERROR: failed calls with stack trace
|
||||
|
||||
### 6. Exception Hierarchy
|
||||
|
||||
```python
|
||||
from src.shared.exceptions import (
|
||||
AppException,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
)
|
||||
|
||||
# Raise with context:
|
||||
raise NotFoundError("User", user_id)
|
||||
raise ValidationError("email", "Invalid format")
|
||||
|
||||
# Automatic JSON response via exception handlers in main.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings via environment variables or `.env`:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| DEBUG | false | Enable debug mode |
|
||||
| LOG_LEVEL | INFO | Logging level |
|
||||
| HOST | 0.0.0.0 | Server host |
|
||||
| PORT | 8086 | Server port |
|
||||
| OLLAMA_URL | http://192.168.86.149:11434 | Ollama API URL |
|
||||
| OLLAMA_AGENT_MODEL | gemma4:e2b | Agent reasoning model |
|
||||
| OLLAMA_EMBED_MODEL | nomic-embed-text:latest | Embedding model |
|
||||
| TATLOCK_API_URL | http://tatlock:8000 | Tatlock auth service |
|
||||
| SEARXNG_URL | http://searxng:8080 | SearXNG web search instance |
|
||||
| SEARXNG_TIMEOUT | 10 | SearXNG request timeout (seconds) |
|
||||
| TOOL_TIMEOUT_SECONDS | 120 | Tool execution timeout |
|
||||
| SANDBOX_ENABLED | true | Enable sandboxed execution |
|
||||
| ALLOWED_PATHS | [] | Paths accessible to tools |
|
||||
| SESSION_TTL_HOURS | 24 | Session expiry |
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
## Agent Architecture
|
||||
|
||||
Webber uses PydanticAI for agent orchestration. Each agent type is purpose-built:
|
||||
|
||||
### Explore Agent
|
||||
Fast codebase exploration for:
|
||||
- Finding files by pattern
|
||||
- Searching code for keywords
|
||||
- Answering questions about structure
|
||||
|
||||
### Plan Agent
|
||||
Implementation design for:
|
||||
- Analyzing requirements
|
||||
- Creating step-by-step plans
|
||||
- Identifying files to modify
|
||||
- Considering trade-offs
|
||||
|
||||
### Task Agent
|
||||
Autonomous execution for:
|
||||
- Multi-step implementations
|
||||
- Tool orchestration
|
||||
- Code generation and modification
|
||||
|
||||
---
|
||||
|
||||
## Tool Architecture
|
||||
|
||||
Tools are sandboxed operations agents can invoke:
|
||||
|
||||
### File Tools
|
||||
- **Read**: Read file contents with line limits
|
||||
- **Write**: Create or overwrite files
|
||||
- **Edit**: String replacement in files
|
||||
- **Glob**: Pattern-based file search
|
||||
|
||||
### Shell Tools
|
||||
- **Bash**: Command execution with timeout
|
||||
- Sandboxed to allowed paths
|
||||
- Captures stdout/stderr
|
||||
|
||||
### Search Tools
|
||||
- **Grep**: Regex content search via ripgrep
|
||||
- **WebSearch**: Web search integration (optional)
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
1. Client sends `X-API-Key` header
|
||||
2. Auth middleware calls `validate_api_key()`
|
||||
3. Tatlock validates key and returns user info
|
||||
4. UserProvider stores user in request context
|
||||
5. Routes access via `get_current_user()` or `require_user()`
|
||||
6. Middleware clears user in `finally` block
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY pyproject.toml .
|
||||
COPY src/ ./src/
|
||||
ENV PYTHONPATH=/app
|
||||
EXPOSE 8086
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8086/health || exit 1
|
||||
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8086"]
|
||||
```
|
||||
|
||||
### CI/CD
|
||||
|
||||
Gitea Actions workflow:
|
||||
1. Push tag `v*` triggers build
|
||||
2. Creates Gitea release
|
||||
3. Builds and pushes Docker image to registry
|
||||
4. Watchtower auto-deploys to production
|
||||
|
||||
### Production Stack
|
||||
|
||||
Deployed in Portainer `agents` stack alongside Tatlock:
|
||||
- Network: `docker-dataplane`
|
||||
- Registry: `git.schweitz.net/jpmschweitzer/webber`
|
||||
- Auto-update: Watchtower with label `com.centurylinklabs.watchtower.enable=true`
|
||||
|
||||
---
|
||||
|
||||
## Adding New Domains
|
||||
|
||||
1. Create domain directory under `src/domains/`
|
||||
2. Add `router.py` with routes
|
||||
3. Add `controller.py` with business logic
|
||||
4. Add `schemas.py` for request/response models
|
||||
5. Import and include router in `src/domains/router.py`
|
||||
6. Add tests in `tests/test_<domain>.py`
|
||||
|
||||
---
|
||||
|
||||
## Adding New Agents
|
||||
|
||||
1. Create agent directory under `src/domains/agents/`
|
||||
2. Add `agent.py` with PydanticAI agent definition
|
||||
3. Add `prompts.py` with system prompts
|
||||
4. Register in agents controller
|
||||
5. Document in `src/domains/agents/README.md`
|
||||
|
||||
---
|
||||
|
||||
## Adding New Tools
|
||||
|
||||
1. Create tool file under appropriate `src/domains/tools/` subdir
|
||||
2. Implement tool function with type hints
|
||||
3. Register as PydanticAI tool
|
||||
4. Document in `src/domains/tools/README.md`
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
./wakeup.sh # Start server first
|
||||
pytest tests/ -v
|
||||
|
||||
# With coverage
|
||||
pytest tests/ --cov=src --cov-report=html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- All tool execution is sandboxed when `SANDBOX_ENABLED=true`
|
||||
- File operations restricted to `ALLOWED_PATHS`
|
||||
- No secrets in prompts
|
||||
- Input validation via Pydantic
|
||||
- Output parsing expects malformed LLM responses
|
||||
- Timeouts on all tool execution
|
||||
@@ -0,0 +1,14 @@
|
||||
## Background
|
||||
|
||||
Research with Gemini identified key issues with mistral-nemo and tool calling:
|
||||
- "Pre-computation Hallucination" - model answers before using tools
|
||||
- High default temperature (0.7-0.8) causes wandering
|
||||
- Model is "chatty and confident" - needs explicit constraints
|
||||
|
||||
## Key Recommendations from Gemini Research
|
||||
|
||||
1. **Temperature 0.0** for tool-calling agents (deterministic, follows schema)
|
||||
2. **Chain of Thought (CoT)** - force step-by-step reasoning
|
||||
3. **Negative constraints** - tell model what NOT to do (Nemo responds better)
|
||||
4. **Explicit tool descriptions** - verbose docstrings with "never estimate yourself"
|
||||
5. **"Strictly tool-based assistant"** pattern - NO internal knowledge claim
|
||||
@@ -0,0 +1,77 @@
|
||||
[project]
|
||||
name = "webber-api"
|
||||
version = "1.1.0"
|
||||
description = "Webber API - Multi-Agent AI Development Server"
|
||||
authors = [
|
||||
{name = "jpmschweitzer"}
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = {text = "MIT"}
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Framework :: FastAPI",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Software Development :: Code Generators",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=75.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["src*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = "-q --strict-markers --tb=short"
|
||||
markers = [
|
||||
"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)",
|
||||
"slow: marks tests as slow (may take > 10 seconds)",
|
||||
]
|
||||
filterwarnings = [
|
||||
"ignore::pytest.PytestUnraisableExceptionWarning",
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
warn_return_any = true
|
||||
warn_unused_ignores = true
|
||||
strict = false
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
src = ["src", "tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # Pyflakes
|
||||
"I", # isort
|
||||
"B", # flake8-bugbear
|
||||
"C4", # flake8-comprehensions
|
||||
"UP", # pyupgrade
|
||||
"SIM", # flake8-simplify
|
||||
"TCH", # flake8-type-checking
|
||||
"RUF", # Ruff-specific rules
|
||||
]
|
||||
ignore = [
|
||||
"E501", # line too long (handled by formatter)
|
||||
"B008", # function call in default argument (FastAPI Depends)
|
||||
"B904", # raise without from (sometimes intentional)
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["src"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
skip-magic-trailing-comma = false
|
||||
@@ -15,3 +15,9 @@ pip-audit~=2.9.0
|
||||
|
||||
# Type checking
|
||||
mypy~=1.19.1
|
||||
# Stubs for aiofiles, which ships none. Without them mypy reports
|
||||
# import-untyped on every module that reads or writes a file.
|
||||
types-aiofiles~=25.1
|
||||
|
||||
# Linting and formatting
|
||||
ruff~=0.9.4
|
||||
@@ -17,6 +17,18 @@ pydantic-ai~=1.40.0
|
||||
httpx~=0.28.1
|
||||
aiofiles~=25.1.0
|
||||
|
||||
# CLI
|
||||
typer~=0.15.0
|
||||
rich~=13.9.0
|
||||
|
||||
# Utilities
|
||||
python-multipart~=0.0.21
|
||||
python-dotenv~=1.2.1
|
||||
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)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Webber CLI - Command-line interface for the multi-agent system.
|
||||
"""
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
CLI commands.
|
||||
"""
|
||||
from src.cli.commands import chat, explore, version
|
||||
|
||||
__all__ = ["chat", "explore", "version"]
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Chat command - interactive conversation mode.
|
||||
"""
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
||||
from src.cli.session.loop import AgenticLoop
|
||||
from src.cli.theme import get_theme
|
||||
from src.cli.ui.console import get_console
|
||||
from src.shared.logging import setup_logging
|
||||
|
||||
console = get_console()
|
||||
|
||||
|
||||
def chat_command(
|
||||
directory: str = typer.Option(
|
||||
".",
|
||||
"--directory",
|
||||
"-d",
|
||||
help="Working directory to explore",
|
||||
),
|
||||
verbose: bool = typer.Option(
|
||||
False,
|
||||
"--verbose",
|
||||
"-V",
|
||||
help="Show detailed output and debug logging",
|
||||
),
|
||||
) -> None:
|
||||
"""
|
||||
Start interactive chat session.
|
||||
|
||||
Enters a conversation loop where you can ask questions about the codebase.
|
||||
The explore agent will search files, read code, and answer questions.
|
||||
|
||||
Examples:
|
||||
webber chat
|
||||
webber chat -d ./src
|
||||
webber chat --verbose
|
||||
"""
|
||||
# Set up logging
|
||||
log_level = "DEBUG" if verbose else "WARNING"
|
||||
setup_logging(log_level)
|
||||
|
||||
# Resolve directory
|
||||
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)
|
||||
|
||||
# Run the async chat loop
|
||||
try:
|
||||
asyncio.run(_chat_loop(working_dir, verbose))
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Goodbye![/]")
|
||||
|
||||
|
||||
async def _chat_loop(working_dir: str, verbose: bool) -> None:
|
||||
"""Async chat loop implementation."""
|
||||
from src.domains.agents.explore import explore_agent
|
||||
|
||||
# Create the agentic loop
|
||||
loop = AgenticLoop(
|
||||
agent=explore_agent,
|
||||
console=console,
|
||||
working_dir=working_dir,
|
||||
)
|
||||
|
||||
# Display welcome
|
||||
loop.display_welcome()
|
||||
|
||||
# Main conversation loop
|
||||
while True:
|
||||
try:
|
||||
# Get user input
|
||||
user_input = console.input("[prompt]>[/] ").strip()
|
||||
|
||||
# Handle special commands
|
||||
if not user_input:
|
||||
continue
|
||||
|
||||
if user_input.lower() in ("exit", "quit", "/exit", "/quit"):
|
||||
console.print("[dim]Goodbye![/]")
|
||||
break
|
||||
|
||||
if user_input.lower() in ("clear", "/clear"):
|
||||
loop.state.clear_history()
|
||||
console.print("[info]History cleared.[/]")
|
||||
continue
|
||||
|
||||
if user_input.lower() in ("status", "/status"):
|
||||
loop.display_status()
|
||||
continue
|
||||
|
||||
if user_input.lower().startswith("cd "):
|
||||
new_dir = user_input[3:].strip()
|
||||
new_path = Path(new_dir).resolve()
|
||||
if new_path.exists() and new_path.is_dir():
|
||||
loop.set_working_dir(str(new_path))
|
||||
else:
|
||||
console.print(f"[error]Directory not found:[/] {new_dir}")
|
||||
continue
|
||||
|
||||
# Process with agent
|
||||
theme = get_theme()
|
||||
with console.status("[info]Thinking...[/]", spinner=theme.spinner):
|
||||
response = await loop.run_turn(user_input)
|
||||
|
||||
# Display response
|
||||
console.print()
|
||||
loop.display_response(response)
|
||||
console.print()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Use 'exit' to quit or press Ctrl+C again.[/]")
|
||||
try:
|
||||
# Wait briefly for second Ctrl+C
|
||||
await asyncio.sleep(0.5)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Goodbye![/]")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[error]Error:[/] {e}")
|
||||
if verbose:
|
||||
console.print_exception()
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Explore command - one-shot codebase exploration.
|
||||
"""
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.panel import Panel
|
||||
|
||||
from src.cli.theme import get_theme
|
||||
from src.cli.ui.console import get_console
|
||||
from src.cli.ui.display import format_response
|
||||
from src.shared.logging import setup_logging
|
||||
|
||||
console = get_console()
|
||||
|
||||
|
||||
def explore_command(
|
||||
query: str = typer.Argument(..., help="What to search for in the codebase"),
|
||||
directory: str = typer.Option(
|
||||
".",
|
||||
"--directory",
|
||||
"-d",
|
||||
help="Working directory to explore",
|
||||
),
|
||||
verbose: bool = typer.Option(
|
||||
False,
|
||||
"--verbose",
|
||||
"-V",
|
||||
help="Show detailed output",
|
||||
),
|
||||
) -> None:
|
||||
"""
|
||||
One-shot codebase exploration.
|
||||
|
||||
Searches the codebase for the given query and returns findings.
|
||||
|
||||
Examples:
|
||||
webber explore "where is config loaded"
|
||||
webber explore "find all API endpoints" -d ./src
|
||||
webber explore "how does authentication work"
|
||||
"""
|
||||
# Set up logging based on verbosity
|
||||
log_level = "DEBUG" if verbose else "WARNING"
|
||||
setup_logging(log_level)
|
||||
|
||||
# Resolve directory
|
||||
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)
|
||||
|
||||
console.print(f"[dim]Exploring:[/] [path]{working_dir}[/]")
|
||||
console.print(f"[dim]Query:[/] {query}\n")
|
||||
|
||||
# Run the exploration
|
||||
asyncio.run(_explore_async(query, working_dir, verbose))
|
||||
|
||||
|
||||
async def _explore_async(query: str, working_dir: str, verbose: bool) -> None:
|
||||
"""Async exploration implementation."""
|
||||
from src.domains.agents.explore import explore
|
||||
|
||||
theme = get_theme()
|
||||
|
||||
try:
|
||||
with console.status("[info]Searching codebase...[/]", spinner=theme.spinner):
|
||||
result = await explore(query, working_dir=working_dir)
|
||||
|
||||
# Display result
|
||||
formatted = format_response(result)
|
||||
console.print(Panel(
|
||||
formatted,
|
||||
title="[success]Findings[/]",
|
||||
border_style=theme.colors.border_success,
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[error]Error:[/] {e}")
|
||||
if verbose:
|
||||
console.print_exception()
|
||||
raise typer.Exit(1)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Version command.
|
||||
"""
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
from src.shared.config import get_settings
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def show_version() -> None:
|
||||
"""Display version information."""
|
||||
settings = get_settings()
|
||||
|
||||
version_info = f"""[bold blue]{settings.app_name}[/] [green]v{settings.app_version}[/]
|
||||
|
||||
{settings.app_description}
|
||||
|
||||
[dim]Configuration:[/]
|
||||
Ollama URL: {settings.ollama_url}
|
||||
Model: {settings.ollama_agent_model}
|
||||
Debug: {settings.debug}
|
||||
"""
|
||||
|
||||
console.print(Panel(version_info, title="Version Info", border_style="blue"))
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Webber CLI main entry point.
|
||||
|
||||
Usage:
|
||||
webber --help
|
||||
webber --version
|
||||
webber chat [OPTIONS]
|
||||
webber explore QUERY [OPTIONS]
|
||||
"""
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from src.shared.config import get_settings
|
||||
|
||||
# Create Typer app
|
||||
app = typer.Typer(
|
||||
name="webber",
|
||||
help="Multi-Agent AI Development System",
|
||||
no_args_is_help=True,
|
||||
add_completion=False,
|
||||
)
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def version_callback(value: bool) -> None:
|
||||
"""Display version and exit."""
|
||||
if value:
|
||||
settings = get_settings()
|
||||
console.print(f"[bold blue]{settings.app_name}[/] version [green]{settings.app_version}[/]")
|
||||
console.print(f"[dim]{settings.app_description}[/]")
|
||||
raise typer.Exit()
|
||||
|
||||
|
||||
@app.callback()
|
||||
def main(
|
||||
version: bool = typer.Option(
|
||||
False,
|
||||
"--version",
|
||||
"-v",
|
||||
callback=version_callback,
|
||||
is_eager=True,
|
||||
help="Show version and exit",
|
||||
),
|
||||
) -> None:
|
||||
"""
|
||||
Webber - Multi-Agent AI Development System.
|
||||
|
||||
A CLI tool for codebase exploration and development assistance
|
||||
powered by local LLMs via Ollama.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
# Import and register commands
|
||||
from src.cli.commands import chat, explore, version # noqa: E402
|
||||
|
||||
# Register subcommands
|
||||
app.command(name="chat")(chat.chat_command)
|
||||
app.command(name="explore")(explore.explore_command)
|
||||
# version was imported and never registered, so `webber version` did not exist.
|
||||
# The --version flag above is the terse form; show_version prints the panel with
|
||||
# the resolved Ollama URL, model and debug state, which is the one worth having
|
||||
# when something is misconfigured. The F401 suppression on the import was what
|
||||
# kept the omission quiet.
|
||||
app.command(name="version")(version.show_version)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Session management for CLI.
|
||||
"""
|
||||
from src.cli.session.context import SessionState
|
||||
from src.cli.session.loop import AgenticLoop
|
||||
|
||||
__all__ = ["AgenticLoop", "SessionState"]
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Session state management.
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
|
||||
@dataclass
|
||||
class Message:
|
||||
"""Single message in conversation history."""
|
||||
role: Literal["user", "assistant", "system"]
|
||||
content: str
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"[{self.role}] {self.content[:50]}..."
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionState:
|
||||
"""
|
||||
Persistent state for a CLI session.
|
||||
|
||||
Tracks conversation history and context.
|
||||
"""
|
||||
working_dir: str
|
||||
messages: list[Message] = field(default_factory=list)
|
||||
started_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
# Token tracking (for future context management)
|
||||
estimated_tokens: int = 0
|
||||
max_tokens: int = 128000
|
||||
|
||||
def add_message(self, role: Literal["user", "assistant", "system"], content: str) -> None:
|
||||
"""Add a message to history."""
|
||||
self.messages.append(Message(role=role, content=content))
|
||||
# Rough token estimate (4 chars per token)
|
||||
self.estimated_tokens += len(content) // 4
|
||||
|
||||
def get_history(self, limit: int | None = None) -> list[Message]:
|
||||
"""Get recent message history."""
|
||||
if limit:
|
||||
return self.messages[-limit:]
|
||||
return self.messages
|
||||
|
||||
def clear_history(self) -> None:
|
||||
"""Clear message history."""
|
||||
self.messages.clear()
|
||||
self.estimated_tokens = 0
|
||||
|
||||
@property
|
||||
def message_count(self) -> int:
|
||||
"""Number of messages in history."""
|
||||
return len(self.messages)
|
||||
|
||||
@property
|
||||
def is_near_limit(self) -> bool:
|
||||
"""Check if approaching token limit."""
|
||||
return self.estimated_tokens > (self.max_tokens * 0.8)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Agentic conversation loop for interactive CLI.
|
||||
"""
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from src.cli.session.context import SessionState
|
||||
from src.cli.ui.display import format_response
|
||||
from src.domains.agents.base import BaseAgent
|
||||
from src.shared.logging import get_logger, logged, trace_span
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AgenticLoop:
|
||||
"""
|
||||
Main conversation loop for interactive CLI sessions.
|
||||
|
||||
Manages state, executes agent turns, and handles display.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent: BaseAgent,
|
||||
console: Console,
|
||||
working_dir: str,
|
||||
):
|
||||
"""
|
||||
Initialize the agentic loop.
|
||||
|
||||
Args:
|
||||
agent: The agent to use for responses
|
||||
console: Rich console for output
|
||||
working_dir: Working directory for exploration
|
||||
"""
|
||||
self.agent = agent
|
||||
self.console = console
|
||||
self.state = SessionState(working_dir=working_dir)
|
||||
|
||||
@logged()
|
||||
async def run_turn(self, user_input: str) -> str:
|
||||
"""
|
||||
Execute a single conversation turn.
|
||||
|
||||
Args:
|
||||
user_input: User's prompt/question
|
||||
|
||||
Returns:
|
||||
Agent's response
|
||||
"""
|
||||
# Record user message
|
||||
self.state.add_message("user", user_input)
|
||||
|
||||
async with trace_span("agentic_turn"):
|
||||
try:
|
||||
# Run the agent
|
||||
response = await self.agent.run(
|
||||
user_input,
|
||||
working_dir=self.state.working_dir,
|
||||
)
|
||||
|
||||
# Record assistant response
|
||||
self.state.add_message("assistant", response)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Agent error: {e}")
|
||||
error_msg = f"Error: {e}"
|
||||
self.state.add_message("assistant", error_msg)
|
||||
raise
|
||||
|
||||
def display_response(self, response: str) -> None:
|
||||
"""Display agent response with formatting."""
|
||||
formatted = format_response(response)
|
||||
self.console.print(formatted)
|
||||
|
||||
def display_welcome(self) -> None:
|
||||
"""Display welcome message."""
|
||||
from src.shared.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
self.console.print()
|
||||
self.console.print(f"[title]{settings.app_name}[/] [dim]v{settings.app_version}[/]")
|
||||
self.console.print(f"[dim]Working in:[/] [path]{self.state.working_dir}[/]")
|
||||
self.console.print(f"[dim]Agent:[/] {self.agent.name} - {self.agent.description}")
|
||||
self.console.print()
|
||||
self.console.print("[dim]Type 'exit' or Ctrl+C to quit. Type 'clear' to reset history.[/]")
|
||||
self.console.print()
|
||||
|
||||
def display_status(self) -> None:
|
||||
"""Display session status."""
|
||||
self.console.print(f"[dim]Messages: {self.state.message_count} | Tokens: ~{self.state.estimated_tokens}[/]")
|
||||
|
||||
@property
|
||||
def working_dir(self) -> str:
|
||||
"""Get current working directory."""
|
||||
return self.state.working_dir
|
||||
|
||||
def set_working_dir(self, path: str) -> None:
|
||||
"""Change working directory."""
|
||||
self.state.working_dir = path
|
||||
self.console.print(f"[info]Changed directory to:[/] [path]{path}[/]")
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
CLI theme configuration.
|
||||
|
||||
Centralized color and style definitions for the Webber CLI.
|
||||
All color choices should be defined here for easy customization.
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThemeColors:
|
||||
"""Color palette for the CLI."""
|
||||
|
||||
# Semantic colors
|
||||
info: str = "steel_blue"
|
||||
warning: str = "dark_orange"
|
||||
error: str = "red3"
|
||||
success: str = "sea_green3"
|
||||
|
||||
# UI elements
|
||||
prompt: str = "steel_blue bold"
|
||||
title: str = "steel_blue bold"
|
||||
path: str = "steel_blue underline"
|
||||
code: str = "sea_green3"
|
||||
highlight: str = "medium_purple1"
|
||||
dim: str = "dim white"
|
||||
|
||||
# Panel borders
|
||||
border_default: str = "steel_blue"
|
||||
border_success: str = "sea_green3"
|
||||
border_error: str = "red3"
|
||||
border_warning: str = "dark_orange"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThemeConfig:
|
||||
"""Complete theme configuration."""
|
||||
|
||||
colors: ThemeColors = field(default_factory=ThemeColors)
|
||||
|
||||
# Spinner style for loading indicators
|
||||
spinner: str = "dots"
|
||||
|
||||
# Code syntax highlighting theme
|
||||
syntax_theme: str = "monokai"
|
||||
|
||||
def to_rich_theme_dict(self) -> dict[str, str]:
|
||||
"""Convert to Rich theme dictionary."""
|
||||
return {
|
||||
"info": self.colors.info,
|
||||
"warning": self.colors.warning,
|
||||
"error": self.colors.error,
|
||||
"success": self.colors.success,
|
||||
"prompt": self.colors.prompt,
|
||||
"title": self.colors.title,
|
||||
"path": self.colors.path,
|
||||
"code": self.colors.code,
|
||||
"highlight": self.colors.highlight,
|
||||
"dim": self.colors.dim,
|
||||
}
|
||||
|
||||
|
||||
# Default theme instance
|
||||
DEFAULT_THEME = ThemeConfig()
|
||||
|
||||
|
||||
def get_theme() -> ThemeConfig:
|
||||
"""Get the current theme configuration."""
|
||||
# Future: could load from config file or env vars
|
||||
return DEFAULT_THEME
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
CLI UI components.
|
||||
"""
|
||||
from src.cli.ui.console import get_console
|
||||
from src.cli.ui.display import format_code, format_response
|
||||
|
||||
__all__ = ["format_code", "format_response", "get_console"]
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Rich console helpers.
|
||||
"""
|
||||
from functools import lru_cache
|
||||
|
||||
from rich.console import Console
|
||||
from rich.theme import Theme
|
||||
|
||||
from src.cli.theme import get_theme
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_console() -> Console:
|
||||
"""Get the shared console instance with theme applied."""
|
||||
theme = get_theme()
|
||||
rich_theme = Theme(theme.to_rich_theme_dict())
|
||||
return Console(theme=rich_theme)
|
||||
|
||||
|
||||
def print_info(message: str) -> None:
|
||||
"""Print an info message."""
|
||||
get_console().print(f"[info]{message}[/]")
|
||||
|
||||
|
||||
def print_warning(message: str) -> None:
|
||||
"""Print a warning message."""
|
||||
get_console().print(f"[warning]Warning:[/] {message}")
|
||||
|
||||
|
||||
def print_error(message: str) -> None:
|
||||
"""Print an error message."""
|
||||
get_console().print(f"[error]Error:[/] {message}")
|
||||
|
||||
|
||||
def print_success(message: str) -> None:
|
||||
"""Print a success message."""
|
||||
get_console().print(f"[success]{message}[/]")
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Output formatting and display helpers.
|
||||
"""
|
||||
import re
|
||||
|
||||
from rich.markdown import Markdown
|
||||
from rich.syntax import Syntax
|
||||
from rich.text import Text
|
||||
|
||||
from src.cli.theme import get_theme
|
||||
|
||||
|
||||
def format_response(text: str) -> Markdown | Text:
|
||||
"""
|
||||
Format agent response for display.
|
||||
|
||||
Detects markdown and formats appropriately.
|
||||
"""
|
||||
# Check if response contains markdown patterns
|
||||
has_markdown = any([
|
||||
"```" in text, # Code blocks
|
||||
text.startswith("#"), # Headers
|
||||
"**" in text or "__" in text, # Bold
|
||||
"- " in text or "* " in text, # Lists
|
||||
])
|
||||
|
||||
if has_markdown:
|
||||
return Markdown(text)
|
||||
else:
|
||||
return Text(text)
|
||||
|
||||
|
||||
def format_code(code: str, language: str = "python") -> Syntax:
|
||||
"""
|
||||
Format code with syntax highlighting.
|
||||
|
||||
Args:
|
||||
code: Source code to format
|
||||
language: Programming language for highlighting
|
||||
"""
|
||||
theme = get_theme()
|
||||
return Syntax(
|
||||
code,
|
||||
language,
|
||||
theme=theme.syntax_theme,
|
||||
line_numbers=True,
|
||||
word_wrap=True,
|
||||
)
|
||||
|
||||
|
||||
def format_file_path(path: str, line: int | None = None) -> Text:
|
||||
"""
|
||||
Format a file path for display.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
line: Optional line number
|
||||
"""
|
||||
text = Text()
|
||||
text.append(path, style="path")
|
||||
if line:
|
||||
text.append(f":{line}", style="dim")
|
||||
return text
|
||||
|
||||
|
||||
def truncate_text(text: str, max_length: int = 500, suffix: str = "...") -> str:
|
||||
"""
|
||||
Truncate text to maximum length.
|
||||
|
||||
Args:
|
||||
text: Text to truncate
|
||||
max_length: Maximum character length
|
||||
suffix: Suffix to add if truncated
|
||||
"""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
return text[:max_length - len(suffix)] + suffix
|
||||
|
||||
|
||||
def strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes from text."""
|
||||
ansi_pattern = re.compile(r'\x1b\[[0-9;]*m')
|
||||
return ansi_pattern.sub('', 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
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Agent implementations.
|
||||
|
||||
All agents inherit from BaseAgent and are registered in the global registry.
|
||||
"""
|
||||
from src.domains.agents.base import (
|
||||
AgentContext,
|
||||
AgentProtocol,
|
||||
BaseAgent,
|
||||
get_agent,
|
||||
get_registry,
|
||||
list_agents,
|
||||
register_agent,
|
||||
)
|
||||
from src.domains.agents.explore import (
|
||||
ExploreAgentImpl,
|
||||
ExploreContext,
|
||||
explore,
|
||||
explore_agent,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AgentContext",
|
||||
"AgentProtocol",
|
||||
# Base classes
|
||||
"BaseAgent",
|
||||
# Explore agent
|
||||
"ExploreAgentImpl",
|
||||
"ExploreContext",
|
||||
"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
|
||||
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
Base classes and registry for agent implementations.
|
||||
|
||||
All agents are built on PydanticAI and registered in a central registry.
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Generic, Protocol, TypeVar, runtime_checkable
|
||||
|
||||
from pydantic_ai import Agent
|
||||
|
||||
from src.domains.agents.schemas import StreamEvent
|
||||
from src.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentContext:
|
||||
"""
|
||||
Base context passed to all agent tools.
|
||||
|
||||
Subclass this for agent-specific context (e.g., ExploreContext).
|
||||
"""
|
||||
working_dir: str
|
||||
allowed_paths: list[str] = field(default_factory=list)
|
||||
timeout_seconds: int = 120
|
||||
|
||||
|
||||
# Every agent narrows the context its tools receive — ExploreContext,
|
||||
# PlanContext, TaskContext. Without this parameter BaseAgent could only say
|
||||
# `Agent`, which is `Agent[Any, Any]`, and pydantic_ai then types every
|
||||
# `.run()` result as Any. That is where 35 of this package's mypy errors came
|
||||
# from: functions declared `-> str` returning Any, each looking like a local
|
||||
# annotation slip rather than one missing type parameter in the base class.
|
||||
CtxT = TypeVar("CtxT", bound=AgentContext)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AgentProtocol(Protocol):
|
||||
"""Protocol that all agents must implement."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Unique identifier for the agent."""
|
||||
...
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""Human-readable description of what the agent does."""
|
||||
...
|
||||
|
||||
@property
|
||||
def agent(self) -> Agent:
|
||||
"""The underlying PydanticAI agent."""
|
||||
...
|
||||
|
||||
async def run(self, prompt: str, **kwargs: Any) -> str:
|
||||
"""
|
||||
Execute the agent with a prompt.
|
||||
|
||||
Args:
|
||||
prompt: User prompt/query
|
||||
**kwargs: Additional arguments (working_dir, etc.)
|
||||
|
||||
Returns:
|
||||
Agent response as string
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class BaseAgent(ABC, Generic[CtxT]):
|
||||
"""
|
||||
Abstract base class for agent implementations.
|
||||
|
||||
Provides common functionality and enforces interface.
|
||||
|
||||
Usage:
|
||||
class ExploreAgent(BaseAgent):
|
||||
name = "explore"
|
||||
description = "Fast codebase exploration"
|
||||
|
||||
class ExploreAgent(BaseAgent[ExploreContext]):
|
||||
def _create_agent(self) -> Agent[ExploreContext, str]:
|
||||
# Create and configure PydanticAI agent
|
||||
...
|
||||
|
||||
async def run(self, prompt: str, **kwargs) -> str:
|
||||
# Execute agent
|
||||
...
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Unique identifier for the agent."""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def description(self) -> str:
|
||||
"""Human-readable description."""
|
||||
pass
|
||||
|
||||
# Declared on the base rather than only in each subclass's __init__. The
|
||||
# base reached it through hasattr, so mypy could not determine its type at
|
||||
# all; the guard existed because nothing guaranteed the attribute existed.
|
||||
# Declaring it here makes the None check sufficient.
|
||||
_agent: "Agent[CtxT, str] | None" = None
|
||||
|
||||
@property
|
||||
def agent(self) -> "Agent[CtxT, str]":
|
||||
"""Lazy-loaded PydanticAI agent."""
|
||||
if self._agent is None:
|
||||
self._agent = self._create_agent()
|
||||
return self._agent
|
||||
|
||||
@abstractmethod
|
||||
def _create_agent(self) -> "Agent[CtxT, str]":
|
||||
"""
|
||||
Create and configure the PydanticAI agent.
|
||||
|
||||
Override this to set up model, system prompt, and tools.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def run(self, prompt: str, **kwargs: Any) -> str:
|
||||
"""Execute the agent."""
|
||||
pass
|
||||
|
||||
async def run_stream(
|
||||
self, prompt: str, **kwargs: Any
|
||||
) -> AsyncIterator[str | StreamEvent]:
|
||||
"""
|
||||
Execute the agent with streaming output.
|
||||
|
||||
Default implementation falls back to non-streaming run().
|
||||
Override this for true streaming support.
|
||||
|
||||
Yields:
|
||||
Text chunks as they become available
|
||||
"""
|
||||
# Default: fall back to non-streaming
|
||||
result = await self.run(prompt, **kwargs)
|
||||
yield result
|
||||
|
||||
|
||||
# === Agent Registry ===
|
||||
|
||||
_AGENT_REGISTRY: dict[str, BaseAgent] = {}
|
||||
|
||||
|
||||
def register_agent(agent: BaseAgent) -> BaseAgent:
|
||||
"""
|
||||
Register an agent in the global registry.
|
||||
|
||||
Args:
|
||||
agent: Agent instance to register
|
||||
|
||||
Returns:
|
||||
The registered agent (for decorator chaining)
|
||||
"""
|
||||
if agent.name in _AGENT_REGISTRY:
|
||||
logger.warning(f"Overwriting existing agent: {agent.name}")
|
||||
|
||||
_AGENT_REGISTRY[agent.name] = agent
|
||||
logger.info(f"Registered agent: {agent.name}")
|
||||
return agent
|
||||
|
||||
|
||||
def get_agent(name: str) -> BaseAgent | None:
|
||||
"""
|
||||
Get an agent by name.
|
||||
|
||||
Args:
|
||||
name: Agent name
|
||||
|
||||
Returns:
|
||||
Agent instance or None if not found
|
||||
"""
|
||||
return _AGENT_REGISTRY.get(name)
|
||||
|
||||
|
||||
def list_agents() -> list[dict[str, str]]:
|
||||
"""
|
||||
List all registered agents.
|
||||
|
||||
Returns:
|
||||
List of agent info dicts with name and description
|
||||
"""
|
||||
return [
|
||||
{"name": agent.name, "description": agent.description}
|
||||
for agent in _AGENT_REGISTRY.values()
|
||||
]
|
||||
|
||||
|
||||
def get_registry() -> dict[str, BaseAgent]:
|
||||
"""Get the full agent registry."""
|
||||
return _AGENT_REGISTRY.copy()
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Explore Agent - Fast codebase exploration.
|
||||
"""
|
||||
from src.domains.agents.explore.agent import (
|
||||
ExploreAgentImpl,
|
||||
ExploreContext,
|
||||
explore,
|
||||
explore_agent,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ExploreAgentImpl",
|
||||
"ExploreContext",
|
||||
"explore",
|
||||
"explore_agent",
|
||||
]
|
||||
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Explore Agent implementation using PydanticAI.
|
||||
|
||||
Fast codebase exploration with read-only tools.
|
||||
Uses sanitized Ollama provider for reliable tool calling.
|
||||
"""
|
||||
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.explore.prompts import EXPLORE_SYSTEM_PROMPT
|
||||
from src.ollama.provider import get_ollama_provider
|
||||
from src.shared.config import get_settings
|
||||
from src.shared.logging import get_logger, logged, trace_span
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExploreContext(AgentContext):
|
||||
"""
|
||||
Context for explore agent tools.
|
||||
|
||||
Passed to all tool functions via RunContext.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class ExploreAgentImpl(BaseAgent[ExploreContext]):
|
||||
"""
|
||||
Fast codebase exploration agent.
|
||||
|
||||
Uses glob, grep, read, and bash tools to search and analyze codebases.
|
||||
Read-only mode - cannot modify files.
|
||||
"""
|
||||
|
||||
name = "explore"
|
||||
description = "Fast codebase exploration - find files, search content, read code"
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the explore agent."""
|
||||
self._agent = None
|
||||
self._settings = get_settings()
|
||||
|
||||
def _create_agent(self) -> Agent[ExploreContext, 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[ExploreContext, str] = Agent(
|
||||
model=model,
|
||||
system_prompt=EXPLORE_SYSTEM_PROMPT,
|
||||
deps_type=ExploreContext,
|
||||
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
|
||||
self._register_tools(agent)
|
||||
|
||||
return agent
|
||||
|
||||
def _register_tools(self, agent: Agent[ExploreContext, str]) -> None:
|
||||
"""Register all exploration tools with the agent."""
|
||||
from src.domains.agents.explore.tools import register_explore_tools
|
||||
register_explore_tools(agent)
|
||||
|
||||
def _build_prompt_with_context(self, prompt: str, working_dir: str) -> str:
|
||||
"""Build the prompt with working directory context."""
|
||||
return f"""Working directory: {working_dir}
|
||||
|
||||
Use paths within this working directory for file operations.
|
||||
|
||||
User request: {prompt}"""
|
||||
|
||||
@logged()
|
||||
async def run(
|
||||
self,
|
||||
prompt: str,
|
||||
working_dir: str | None = None,
|
||||
allowed_paths: list[str] | None = None,
|
||||
**kwargs: Any
|
||||
) -> str:
|
||||
"""
|
||||
Run the explore agent with a prompt.
|
||||
|
||||
Args:
|
||||
prompt: User query about the codebase
|
||||
working_dir: Working directory for exploration
|
||||
allowed_paths: Restrict tool access to these paths
|
||||
|
||||
Returns:
|
||||
Agent response with findings
|
||||
"""
|
||||
effective_working_dir = working_dir or os.getcwd()
|
||||
|
||||
ctx = ExploreContext(
|
||||
working_dir=effective_working_dir,
|
||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||
)
|
||||
|
||||
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
|
||||
|
||||
async with trace_span("explore_agent_run"):
|
||||
try:
|
||||
# Use run() not run_stream() - Ollama has bugs with streaming + tools
|
||||
result = await self.agent.run(full_prompt, deps=ctx)
|
||||
return result.output
|
||||
except Exception as e:
|
||||
logger.exception(f"Explore 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 explore agent with streaming output.
|
||||
|
||||
Yields text chunks as they become available.
|
||||
"""
|
||||
effective_working_dir = working_dir or os.getcwd()
|
||||
|
||||
ctx = ExploreContext(
|
||||
working_dir=effective_working_dir,
|
||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||
)
|
||||
|
||||
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
|
||||
|
||||
async with trace_span("explore_agent_stream"):
|
||||
try:
|
||||
async with self.agent.run_stream(full_prompt, deps=ctx) as result:
|
||||
async for chunk in result.stream_text():
|
||||
yield chunk
|
||||
except Exception as e:
|
||||
logger.exception(f"Explore agent stream error: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# Create and register the singleton instance
|
||||
explore_agent = ExploreAgentImpl()
|
||||
register_agent(explore_agent)
|
||||
|
||||
|
||||
async def explore(
|
||||
prompt: str,
|
||||
working_dir: str | None = None,
|
||||
**kwargs: Any
|
||||
) -> str:
|
||||
"""Run exploration query."""
|
||||
return await explore_agent.run(prompt, working_dir=working_dir, **kwargs)
|
||||
|
||||
|
||||
async def explore_stream(
|
||||
prompt: str,
|
||||
working_dir: str | None = None,
|
||||
**kwargs: Any
|
||||
) -> AsyncIterator[str]:
|
||||
"""Run exploration query with streaming."""
|
||||
async for chunk in explore_agent.run_stream(prompt, working_dir=working_dir, **kwargs):
|
||||
yield chunk
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
System prompts for the Explore agent.
|
||||
|
||||
Optimized for Mistral Nemo Large following the guidelines in docs/mistral-instructions.md:
|
||||
- Temperature 0.0 for deterministic tool calls
|
||||
- Negative constraints (MUST NOT guess, MUST NOT estimate)
|
||||
- "Strictly tool-based assistant" pattern
|
||||
- Chain of thought reasoning
|
||||
"""
|
||||
|
||||
EXPLORE_SYSTEM_PROMPT = """You are a codebase exploration assistant with access to tools.
|
||||
|
||||
CRITICAL: You MUST provide ALL required arguments when calling tools.
|
||||
|
||||
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:
|
||||
Call grep_content with pattern="def main"
|
||||
|
||||
To run git commands:
|
||||
Call bash_readonly with command="git status"
|
||||
|
||||
RULES:
|
||||
- ALWAYS provide the required arguments (pattern, file_path, command)
|
||||
- The working directory is pre-configured - you don't need path arguments
|
||||
- Use tools first, then answer based on results
|
||||
- Never guess - always verify with tools
|
||||
|
||||
After getting tool results, provide a clear summary of findings."""
|
||||
|
||||
|
||||
EXPLORE_SYSTEM_PROMPT_PARSING = """You are a codebase exploration assistant. Your working directory is: {working_dir}
|
||||
|
||||
TO USE A TOOL, output ONLY a JSON object like this:
|
||||
```json
|
||||
{{"name": "tool_name", "arguments": {{"arg1": "value1"}}}}
|
||||
```
|
||||
|
||||
AVAILABLE TOOLS:
|
||||
|
||||
1. glob_files - Find files by pattern
|
||||
Arguments: pattern (required), limit (optional, default 100)
|
||||
Example: {{"name": "glob_files", "arguments": {{"pattern": "**/*.py"}}}}
|
||||
|
||||
2. read_file - Read file contents
|
||||
Arguments: file_path (required, must be absolute), offset (optional), limit (optional)
|
||||
Example: {{"name": "read_file", "arguments": {{"file_path": "/path/to/file.py"}}}}
|
||||
|
||||
3. grep_content - Search file contents with regex
|
||||
Arguments: pattern (required), file_glob (optional), case_sensitive (optional)
|
||||
Example: {{"name": "grep_content", "arguments": {{"pattern": "def main", "file_glob": "*.py"}}}}
|
||||
|
||||
4. bash_readonly - Run read-only shell commands (ls, git status, git log, etc.)
|
||||
Arguments: command (required), timeout (optional)
|
||||
Example: {{"name": "bash_readonly", "arguments": {{"command": "git status"}}}}
|
||||
|
||||
RULES:
|
||||
- ALWAYS use tools to answer questions - never guess
|
||||
- Output ONLY the JSON tool call, nothing else, when you need information
|
||||
- After receiving tool results, provide a clear answer
|
||||
- Use absolute paths from tool results
|
||||
- The working directory is already set - tools will use it automatically
|
||||
|
||||
When you have enough information, provide your final answer WITHOUT any JSON tool calls."""
|
||||
|
||||
|
||||
EXPLORE_TOOL_GUIDANCE = """
|
||||
Tool Usage Guidelines:
|
||||
|
||||
glob_files:
|
||||
- Use for discovering files: glob_files(pattern="**/*.py")
|
||||
- Filter by directory: glob_files(pattern="*.ts", path="src/")
|
||||
- Find test files: glob_files(pattern="**/test_*.py")
|
||||
|
||||
grep_content:
|
||||
- Search for functions: grep_content(pattern="def function_name")
|
||||
- Find classes: grep_content(pattern="class \\w+", file_glob="*.py")
|
||||
- Search imports: grep_content(pattern="from.*import", file_glob="*.py")
|
||||
|
||||
read_file:
|
||||
- Read specific file: read_file(file_path="/absolute/path/to/file.py")
|
||||
- Read portion: read_file(file_path="/path/file.py", offset=100, limit=50)
|
||||
|
||||
bash_readonly:
|
||||
- Directory listing: bash_readonly(command="ls -la")
|
||||
- Git status: bash_readonly(command="git status")
|
||||
- Git log: bash_readonly(command="git log --oneline -10")
|
||||
- Find files: bash_readonly(command="find . -name '*.md' -type f")
|
||||
"""
|
||||
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
Tool registrations for the Explore agent.
|
||||
|
||||
Registers our tool implementations with the PydanticAI agent.
|
||||
"""
|
||||
from pydantic_ai import Agent, RunContext
|
||||
|
||||
from src.domains.agents.explore.agent import ExploreContext
|
||||
from src.domains.tools.file.edit import EditFileTool
|
||||
from src.domains.tools.file.glob import GlobFilesTool
|
||||
from src.domains.tools.file.read import ReadFileTool
|
||||
from src.domains.tools.file.write import WriteFileTool
|
||||
from src.domains.tools.search.grep import GrepContentTool
|
||||
from src.domains.tools.search.web import WebSearchTool
|
||||
from src.domains.tools.shell.bash import BashReadOnlyTool
|
||||
from src.domains.tools.shell.bash_full import BashTool
|
||||
|
||||
|
||||
def register_explore_tools(agent: Agent[ExploreContext, str]) -> None:
|
||||
"""
|
||||
Register all exploration tools with the agent.
|
||||
|
||||
Each tool is wrapped to use context from RunContext.
|
||||
"""
|
||||
|
||||
@agent.tool
|
||||
async def read_file(
|
||||
ctx: RunContext[ExploreContext],
|
||||
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. Never guess file contents.
|
||||
"""
|
||||
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[ExploreContext],
|
||||
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[ExploreContext],
|
||||
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 search for code patterns. Escape regex special chars.
|
||||
"""
|
||||
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[ExploreContext],
|
||||
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()
|
||||
|
||||
# === Write-capable tools ===
|
||||
|
||||
@agent.tool
|
||||
async def edit_file(
|
||||
ctx: RunContext[ExploreContext],
|
||||
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[ExploreContext],
|
||||
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 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[ExploreContext],
|
||||
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)
|
||||
|
||||
Returns:
|
||||
Command output or error message.
|
||||
|
||||
Examples:
|
||||
- "mkdir -p src/utils" creates directory
|
||||
- "git add . && git commit -m 'fix: bug'" commits changes
|
||||
- "pytest tests/ -v" runs tests
|
||||
- "rm old_file.py" removes single file
|
||||
"""
|
||||
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()
|
||||
|
||||
# === Web search ===
|
||||
|
||||
@agent.tool
|
||||
async def web_search(
|
||||
ctx: RunContext[ExploreContext],
|
||||
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 since your training
|
||||
- Facts you're uncertain about
|
||||
- Technical references with URLs
|
||||
|
||||
IMPORTANT: Always include a "Sources:" section with URLs in your response.
|
||||
|
||||
Examples:
|
||||
- query="FastAPI best practices 2024"
|
||||
- query="CVE-2024" categories="it"
|
||||
"""
|
||||
tool = WebSearchTool()
|
||||
result = await tool.execute(
|
||||
query=query,
|
||||
num_results=num_results,
|
||||
categories=categories
|
||||
)
|
||||
return result.to_string()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
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
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
# Import agents to ensure they're registered
|
||||
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 (
|
||||
AgentInfo,
|
||||
AgentListResponse,
|
||||
AgentRunRequest,
|
||||
AgentRunResponse,
|
||||
PermissionMode,
|
||||
StreamEvent,
|
||||
)
|
||||
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__)
|
||||
|
||||
router = APIRouter(prefix="/agents", tags=["Agents"])
|
||||
|
||||
|
||||
@router.get("/", response_model=AgentListResponse)
|
||||
async def list_available_agents() -> AgentListResponse:
|
||||
"""List all available agents."""
|
||||
agents = list_agents()
|
||||
return AgentListResponse(
|
||||
agents=[AgentInfo(**a) for a in agents]
|
||||
)
|
||||
|
||||
|
||||
@router.post("/run", response_model=AgentRunResponse)
|
||||
@logged()
|
||||
async def run_agent(request: AgentRunRequest) -> AgentRunResponse:
|
||||
"""
|
||||
Run an agent with the given prompt.
|
||||
|
||||
The agent will use tools to explore the codebase and answer questions.
|
||||
Permission mode controls which tools are available.
|
||||
"""
|
||||
# Get the requested agent
|
||||
agent = get_agent(request.agent_type)
|
||||
if not agent:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown agent type: {request.agent_type}"
|
||||
)
|
||||
|
||||
# Convert mode string to enum (use_enum_values=True in schema)
|
||||
mode = _get_mode(request.mode)
|
||||
|
||||
try:
|
||||
# Run the agent with mode
|
||||
response = await agent.run(
|
||||
request.prompt,
|
||||
working_dir=request.working_dir,
|
||||
mode=mode,
|
||||
)
|
||||
|
||||
return AgentRunResponse(
|
||||
response=response,
|
||||
agent_type=request.agent_type,
|
||||
mode=request.mode, # Keep original for response
|
||||
success=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Agent execution failed: {e}")
|
||||
return AgentRunResponse(
|
||||
response="",
|
||||
agent_type=request.agent_type,
|
||||
mode=request.mode,
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/stream")
|
||||
@logged()
|
||||
async def stream_agent(request: AgentRunRequest) -> StreamingResponse:
|
||||
"""
|
||||
Run an agent with streaming response.
|
||||
|
||||
Returns Server-Sent Events (SSE) with structured events.
|
||||
Permission mode controls which tools are available.
|
||||
|
||||
Event types (from StreamEvent):
|
||||
- tool_start: Tool execution beginning
|
||||
- tool_done: Tool execution complete
|
||||
- thinking: Agent status update
|
||||
- response: Final response text chunk
|
||||
- error: Error occurred
|
||||
- done: Stream complete
|
||||
"""
|
||||
agent = get_agent(request.agent_type)
|
||||
if not agent:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown agent type: {request.agent_type}"
|
||||
)
|
||||
|
||||
# Convert mode string to enum (use_enum_values=True in schema)
|
||||
mode = _get_mode(request.mode)
|
||||
|
||||
async def generate():
|
||||
try:
|
||||
async for event in agent.run_stream(
|
||||
request.prompt,
|
||||
working_dir=request.working_dir,
|
||||
mode=mode,
|
||||
):
|
||||
# Handle both StreamEvent objects and legacy string chunks
|
||||
if isinstance(event, StreamEvent):
|
||||
# New structured event format
|
||||
event_data = event.model_dump(exclude_none=True)
|
||||
yield f"data: {json.dumps(event_data)}\n\n"
|
||||
else:
|
||||
# Legacy string chunk (for explore/plan agents)
|
||||
yield f"data: {json.dumps({'event': 'chunk', 'data': event})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Stream error: {e}")
|
||||
error_event = {"event": "error", "error_message": str(e)}
|
||||
yield f"data: {json.dumps(error_event)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{agent_type}", response_model=AgentInfo)
|
||||
async def get_agent_info(agent_type: str) -> AgentInfo:
|
||||
"""Get information about a specific agent."""
|
||||
agent = get_agent(agent_type)
|
||||
if not agent:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Agent not found: {agent_type}"
|
||||
)
|
||||
|
||||
return AgentInfo(
|
||||
name=agent.name,
|
||||
description=agent.description,
|
||||
)
|
||||
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Request and response schemas for agent API.
|
||||
"""
|
||||
from enum import Enum
|
||||
|
||||
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):
|
||||
"""Request to run an agent."""
|
||||
prompt: str
|
||||
working_dir: str = "."
|
||||
agent_type: str = "task" # Default to task agent (main agent)
|
||||
mode: PermissionMode = PermissionMode.default
|
||||
|
||||
|
||||
class AgentRunResponse(BaseSchema):
|
||||
"""Response from agent execution."""
|
||||
response: str
|
||||
agent_type: str
|
||||
mode: PermissionMode = PermissionMode.default
|
||||
success: bool = True
|
||||
error: str | None = None
|
||||
# Prep for approval flow - if set, CLI should handle approval
|
||||
pending_approval: ToolApprovalRequest | None = None
|
||||
|
||||
|
||||
class AgentInfo(BaseSchema):
|
||||
"""Information about an agent."""
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
class AgentListResponse(BaseSchema):
|
||||
"""List of available agents."""
|
||||
agents: list[AgentInfo]
|
||||
|
||||
|
||||
# Streaming event types for event-based streaming
|
||||
class StreamEventType(str, Enum):
|
||||
"""
|
||||
Event types for structured agent streaming.
|
||||
|
||||
Instead of streaming raw text (which gets garbled during tool calls),
|
||||
we emit structured events that the CLI can render appropriately.
|
||||
"""
|
||||
tool_start = "tool_start" # Tool execution starting
|
||||
tool_done = "tool_done" # Tool execution complete
|
||||
thinking = "thinking" # Agent reasoning status
|
||||
response = "response" # Final response text chunk
|
||||
error = "error" # Error occurred
|
||||
done = "done" # Stream complete
|
||||
|
||||
|
||||
class StreamEvent(BaseSchema):
|
||||
"""
|
||||
Structured streaming event from agent execution.
|
||||
|
||||
Events are emitted instead of raw text to provide clean
|
||||
progress feedback during multi-tool agent loops.
|
||||
"""
|
||||
event: StreamEventType
|
||||
tool: str | None = None # Tool name (for tool_start/tool_done)
|
||||
args: dict | None = None # Tool arguments (for tool_start)
|
||||
result_summary: str | None = None # Brief result (for tool_done)
|
||||
message: str | None = None # Status message (for thinking)
|
||||
text: str | None = None # Response text (for response)
|
||||
error_message: str | None = None # Error details (for error)
|
||||
mode: str | None = None # Permission mode (for done)
|
||||
@@ -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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user