50 Commits
Author SHA1 Message Date
jpmschweitzerandClaude 7172927a79 release api/v1.1.0
Build and Push API / release (push) Successful in 2s
Build and Push API / build (push) Successful in 2m17s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 16:30:09 +02:00
jpmschweitzerandClaude 3e495daa73 fix(webber-api): clear mypy, and the dead code it was covering for
55 errors to zero. Nearly all of them traced back to two causes rather than 55.

THE DECORATOR. @logged wraps ~24 functions across this package and was declared
`def decorator(func: Callable):` with no ParamSpec and no return annotation, so
it erased the signature of everything it touched. ToolResult.execute() is
annotated `-> ToolResult`; through the decorator it came back Any, and mypy
reported 33 no-any-return errors spread across the tools and agents. Each looked
like a local annotation slip. All of them were one decorator. Typed with
ParamSpec/TypeVar; the async branch casts at the await rather than loosening R,
because loosening R would put the Any straight back into every caller.

THE MISSING TYPE PARAMETER. BaseAgent was not generic, so _create_agent returned
a bare Agent — Agent[Any, Any] — and pydantic_ai then typed every run() result
as Any. BaseAgent is now Generic[CtxT] bound to AgentContext, _agent is declared
on the base instead of reached through hasattr, and the three tool-registration
functions take their agent's real context type. tools_streaming.py already did
this; the other three had not been updated.

Eight `execute` overrides carry a targeted ignore rather than a package-wide
disable_error_code. Every tool narrows the base's **kwargs to its own named
parameters, which is a real LSP violation — but nothing anywhere is typed as
BaseTool, and every call site constructs the concrete tool. The abstract method
earns its place by making a tool without execute impossible to instantiate. The
reasoning lives in BaseTool.execute's docstring; the per-site suppressions mean
an override that IS unsound still gets caught.

BaseAgent.run_stream widened to AsyncIterator[str | StreamEvent], which is what
callers already receive: task streams structured events, explore and plan stream
strings, and the router branches on isinstance with a comment calling the string
path legacy. The annotation now says what the code does.

AND THE PART THAT MATTERS MORE THAN THE TYPES.

Chasing the last error found that the Ollama sanitiser has been broken. It
fetched the parent's chat getter with `AsyncOpenAI.chat.fget`, and openai made
`chat` a functools.cached_property, whose getter is `.func`. Touching `.chat`
raised AttributeError — meaning the content: null workaround that CLAUDE.md
documents as live would have failed on the first completion any agent attempted.
Confirmed in the running container (openai 2.46.0) as well as locally (2.15.0).

Two things hid it. The line carried a bare `# type: ignore`, which suppressed
precisely the complaint that would have caught it. And /agents/run and
/agents/stream have served zero requests in 30 days, so nothing exercised the
path. A mitigation can rot completely while every check stays green, if no check
actually runs it.

The lookup now reads whichever getter the descriptor exposes and raises a
legible TypeError if openai adopts a third shape. tests/test_ollama_provider.py
walks the chain an agent request walks, short of the network call —
mutation-checked: all four fail against the old lookup.

215 passed, 23 skipped, plus the four new. mypy clean over 90 files.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 16:27:51 +02:00
jpmschweitzerandClaude eb3467d06a fix(webber-api): clear ruff, and two things it was pointing at
97 findings to zero. Most were mechanical — 52 unsorted import blocks, 10
unsorted __all__, assorted pyupgrade and simplify hints. Two were not, and both
were visible only because the lint made me look.

`webber version` did not exist. src/cli/commands/version.py defines
show_version(), main.py imported it, and the registration line was never
written — the CLI exposed chat and explore only. The import carried
`# noqa: F401`, which is what kept the omission quiet: someone marked the
symptom as intentional instead of asking why it was unused. show_version is not
redundant with the --version flag; it prints the resolved Ollama URL, model and
debug state, which is the form worth having when something is misconfigured.
Registered, and the suppression dropped because the import is now genuinely used.

test_spawn_explore_agent asserted nothing. It built a mock RunContext, patched
get_agent, and stopped at the comment "For now, verify the explore agent would
be called correctly". It had been counted as a passing test. An AST sweep of all
238 test functions found it was the only one, which is worth knowing — the
problem was contained, not systemic. It is now skipped with a reason, so it
reports as unfinished rather than as passing. Reducing it rather than deleting
its imports was the point: tidying the imports would have made a hollow test
look clean.

Two findings were false positives, and both are recorded rather than silently
worked around:

B023 flagged run_agent closing over full_prompt and ctx. Traced: agent_task is
awaited at line 326 before `continue` reaches the next iteration, so neither
name can be rebound while the closure is pending, and the exception path
cancels and awaits too. Not a bug. Bound as defaults anyway, because that stays
true if the await ever moves. I had called it a live bug before tracing it,
which is the mistake Rule 5 exists for.

RUF012 flagged `rules: list[ApprovalRule] = []` on ApprovalRuleSet. Its
suggested fix — annotate ClassVar — would remove the field from the model.
ApprovalRuleSet is a pydantic model and pydantic deep-copies defaults per
instance; verified by constructing two and confirming their lists are distinct
objects. Suppressed with that evidence in the comment. Ruff cannot see the
pydantic base because BaseSchema is a local subclass of BaseModel.

Also moved a stray `from src.shared.logging import ...` that had drifted below
a function definition, and merged a nested if in the ollama provider.

215 passed, 23 skipped, unchanged except for the new skip. `webber version`
exercised end to end.

mypy is NOT addressed here and the gate still fails on it — 55 errors in 14
files, 35 of them no-any-return from pydantic_ai's untyped returns. That was
hidden behind ruff, because the gate stops at the first failing stage.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 15:05:02 +02:00
jpmschweitzerandClaude 9f5e331d11 build(ci): move the pre-push gate into the Makefile
The hook carried ~50 lines of gitleaks logic and a comment explaining it was
self-contained because "this repo has no Makefile". It has one now, so the
reason is gone and the arrangement is backwards: a hook is a trigger, and
logic belongs where it can be read, run by hand, and changed under review.

.githooks/pre-push is now a byte-identical shim onto `make pre-push` in every
repo in the workspace. The scan itself moves to ci/secrets.sh unchanged, and
`make secrets` runs it on its own.

The call surface is identical everywhere; what it runs is not, and should not
be — each repo gates what it actually has. That is the point of standardising
the name rather than the contents: nobody has to read a repo to find out how
to check it.

secrets runs first, deliberately. It is the only failure here that cannot be
undone by fixing it afterwards — a failed lint costs another commit, a pushed
credential is cached and indexed whether or not it is later deleted.

Some of these gates fail today, on lint debt that predates them, and they are
left wired anyway. The board was measured once and written down in T-56
instead of being worked around here. Narrowing each gate to whatever already
passes would produce a gate that reports success for doing nothing, which is
the failure this workspace keeps rediscovering.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 18:57:22 +02:00
jpmschweitzerandClaude 225dc74385 ci(make): reserve exit 69 for "could not run" (D-26)
Environment guards now exit 69 rather than 1, so a caller can tell a suite
that could not start from one that ran and failed. The first toj test sweep
reported "3 repositories failed" and none of the three had executed a test —
two could not find go, one had no venv. That points the reader at the tests
when the fault is in the environment.

Only the environment guards change. A gitleaks finding, a failed test run and
a vulncheck hit still exit 1, because those did run and did fail.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 15:56:20 +02:00
jpmschweitzerandClaude 80d5cc1c3e build: add the Makefile command surface (D-27)
Every repo gets one at the root: help, plus test and lint where those exist.
The point is that a target name means the same thing in every repo, so an
agent or a person can act without reading the repo first.

Paths resolve here rather than in callers (D-10). python3 on this host is 3.8
and cannot parse these sources, and a bare pytest or ruff resolves only in a
login shell — so both are named explicitly through the venv, and a missing
venv fails with the command to fix it rather than a bare no-such-file.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 15:10:32 +02:00
jpmschweitzerandClaude 964c071d3f chore(claude): pin PQL_VAULT per project so cwd stops choosing the vault
pql is now a bare word on PATH, which removed the long incantation that had
been forcing --vault into every call by habit. Convenience lowered the cost
of the wrong thing without lowering the cost of the right one: a three-word
pql ticket new targets whichever vault the cwd happens to sit in, and there
are nine of them with colliding id sequences.

PQL_VAULT in each project settings file makes the vault a property of the
session rather than of the working directory — the same lesson Rule 3 records
for git -C, applied to pql. Verified the env var overrides cwd discovery,
that an explicit --vault still beats the env var, and that the harness
hot-reloads it without a restart.

This does not make provenance visible: no output says which vault answered,
so a forgotten --vault still returns a well-formed answer about the wrong
dataset. That remains T-37.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 13:48:59 +02:00
jpmschweitzerandClaude 0883101b17 chore(claude): deny toj in the sub-repos
toj is now on the global PATH as /usr/local/bin/toj, so its scope boundary
had to stop being "the absolute path is inconvenient to type" and start
being a rule. Its repo and settings verbs operate on the workspace root; run
from inside this repo they answer about the wrong tree.

Both spellings are denied, bare and absolute, because a deny with one
spelling left open is decorative.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 13:42:04 +02:00
jpmschweitzerandClaude 992ff8256e ci: gate pushes on a gitleaks scan of the outgoing commits
No repo here scanned for committed credentials. The hook is self-contained
rather than delegating to a Makefile, because this repo has none and a hook
reaching into a sibling repo breaks the moment this one is cloned elsewhere.

Scans the outgoing range rather than full history: history carries settled
findings — test fixtures, vendored third-party code — and a gate that fails
on something unfixable gets bypassed within a week.

Setting core.hooksPath means pql init must replant its replication shims into
.githooks, which is why they are gitignored here alongside the tracked
pre-push. Same layout pql itself uses.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 12:46:55 +02:00
jpmschweitzerandClaude b4d5c4d9b2 docs: correct the vault policy, and qualify workspace decision ids
This repo's Work tracking section still described the reversed policy --
that tickets and decisions live in the workspace vault and this repo's
trees stay empty. That was overturned the same day: repo vaults are
standalone and a repo's work travels with a clone, because the changelog
is committed.

Every other repo was corrected at the time; this one was missed because
the search for the offending phrase used a fixed string and the phrase
happened to wrap across a line break here. Worth noting as a search
failure rather than a writing one -- five files were checked, four
matched, and the fifth was reported clean.

Decision ids are also qualified now. They are per-vault sequences, so a
bare D-15 here will mean this repo's D-15 the moment this repo records
one; pql already holds D-1 through D-31 against the workspace's D-1
through D-21, all of them unrelated.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 04:17:29 +02:00
jpmschweitzerandClaude 8eec3b68d6 docs: replace both AGENTS.md files with one CLAUDE.md
One agent doc per repo, and it is CLAUDE.md. This repo carried two --
one at the root and one under webber-api/ -- which is the drift problem
in its purest form: two documents, one subject, and no way to know which
the last reader trusted. Written fresh rather than reformatted.

README.md linked to webber-api/AGENTS.md, so that pointer moves with the
file rather than dangling.

The architecture section states the method used to establish what is
live -- import the app inside the container and read sys.modules -- and
then the case where that method fails here. src/domains/tools is absent
from a cold snapshot and is entirely live: each agent's _register_tools
imports its tool package from inside the method body, on every
/agents/run. Absence from a snapshot taken before any request is served
is a timing artifact, not evidence of death, and deleting on that basis
would have removed the tool layer.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 03:14:50 +02:00
jpmschweitzerandClaude 401c7f4e8c chore: adopt the workspace agent-config baseline
Commits a .claude/settings.json rather than leaving permissions to
per-developer local state, and initialises a pql vault for this repo's
tickets and internal decisions.

Every git deny rule appears in both the `git <verb>` and `git * <verb>`
forms. Only the second catches `git -C <path>`, and without it the whole
deny list is decorative -- it looks like a policy and stops nothing.

The allow list carries pql's absolute path alongside the bare name.
pql is installed to ~/.local/bin, which is on the login PATH but not the
one a non-interactive shell gets, so the bare-name rules match nothing on
their own and every call would prompt anyway.

.gitignore now covers .claude/settings.local.json, which is machine-local
and must never be shared. `pql init` contributed the .pql/* rules with an
exception for the changelog, which is the replication log of record and
has to be committed for tickets to travel with a clone.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 03:14:24 +02:00
jpmschweitzerandClaude 9612c7af05 docs(architecture): correct agent model to gemma4:e2b
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 14:59:12 +02:00
jpmschweitzerandClaude 9d58df85f3 fix(config): default agent model to gemma4:e2b
mistral-nemo-large holds ~9.2 GB of the 11 GB card it shares with Speaches,
which starves Whisper and breaks voice transcription. gemma4:e2b holds
1.9 GB and is faster. The deployed stack already overrides this via
OLLAMA_AGENT_MODEL; this aligns the default so a deployment without that
override does not reintroduce the contention.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 14:58:57 +02:00
jpmschweitzerandClaude 3de96fe070 docs: correct Ollama model references to gemma4:e2b
The deployed agent model is gemma4:e2b; these references still named
mistral-nemo, so the setup and troubleshooting steps checked for a model
that is no longer expected to be present.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 14:51:58 +02:00
jpmschweitzerandClaude Fable 5 e2acb7de39 docs(architecture): registry is git.schweitz.net not git.schweitz.internal
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 17:11:08 +02:00
jpmschweitzerandClaude Fable 5 5998571890 chore: release api v1.0.1
Build and Push API / release (push) Successful in 2s
Build and Push API / build (push) Successful in 1m39s
Patch release for the network migration: docker-hostname config
defaults for Tatlock/SearXNG and CI image push via the
git.schweitz.net registry route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:32:02 +02:00
jpmschweitzerandClaude Fable 5 6ac1d15591 fix(config): use docker hostnames for tatlock and searxng defaults
The homelab is retiring *.schweitz.internal and will rebind host ports
to 127.0.0.1, so container-to-container traffic must use container
names on the docker-dataplane network. Switch defaults from host
IP:port to http://tatlock:8000 and http://searxng:8080 (SearXNG's
internal port is 8080; 8087 is only the host-published port).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:21:43 +02:00
jpmschweitzerandClaude Fable 5 22af23827d chore(ci): push images via git.schweitz.net registry
The .internal registry domain is being retired; git.schweitz.net now
serves the registry without SSO on /v2/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:10:35 +02:00
jpmschweitzerandClaude Opus 4.5 d385f47395 chore: release api v1.0.0
Build and Push API / release (push) Successful in 4s
Build and Push API / build (push) Successful in 2m26s
- Event-based streaming for task agent
- Retry logic when LLM responds without calling tools
- Hardened prompts to enforce tool use
- Working directory context in all agent prompts
- Project paused: local LLMs not capable enough for agentic use

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 07:54:33 +01:00
jpmschweitzerandClaude Opus 4.5 6ea520519c fix: remove invalid mode kwarg from trace_span + add integration tests
- Remove mode=mode.value from trace_span calls (trace_span only accepts
  name and logger parameters)
- Add TestPermissionModeIntegration tests that verify mode string->enum
  conversion works correctly through the full request flow
- Add TestAgentMethodSignatures tests that verify function signatures
  match expected interfaces (catches invalid kwargs at test time)

These tests would have caught both the mode string/enum issue and the
trace_span invalid kwarg issue before they hit production.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:46:28 +01:00
jpmschweitzerandClaude Opus 4.5 2f11ad79cf fix: handle mode as string due to use_enum_values=True
The BaseSchema has use_enum_values=True which makes Pydantic store
enum values as strings. Added _get_mode() helper to convert back to
PermissionMode enum before passing to agent methods.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:07:55 +01:00
jpmschweitzerandClaude Opus 4.5 9be877e9a0 feat: make chat the default command
Running 'webber-cli' without arguments now starts chat mode.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:09:39 +01:00
jpmschweitzerandClaude Opus 4.5 b87e61248e feat: add config file support to CLI
- Add ~/.webber/config.toml for persistent settings
- Support api.url, api.key, cli.mode, cli.stream, history.file
- Environment variables override config file values
- Add 'config' command to show settings and init config file
- Update all commands to use config defaults

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:06:13 +01:00
jpmschweitzerandClaude Opus 4.5 daa9543790 feat: add session persistence to CLI
- Add save-only endpoint (POST /conversations/{id}/save) for persisting
  messages without triggering agent execution
- Add sessions command to list previous conversation sessions
- Add --resume flag to chat command for resuming sessions by ID
- Buffer streamed responses and save after completion
- Update AGENTS.md with session commands and remove outdated limitation
- Add 3 new tests for save endpoint (208 total)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 09:50:25 +01:00
jpmschweitzerandClaude Opus 4.5 b7956f88ed feat: add permission modes and CLI orchestration layer
Permission Modes:
- Add default/plan/auto_accept modes controlling tool access
- Plan mode restricts Task agent to read-only tools only
- Auto-accept mode bypasses approval prompts (with confirmation)

Approval Scaffolding:
- Add ApprovalRule/ApprovalRuleSet for granular tool control
- Pattern-based matching on tool name and arguments
- Default rules for common safe/dangerous patterns
- Prep for future bidirectional approval flow

CLI Refactor:
- Default to Task agent (main orchestrator)
- Add --mode flag and runtime mode switching
- Integrate prompt_toolkit for better UX:
  - Persistent command history (~/.webber_history)
  - Tab completion for commands and file paths
  - Auto-suggest from history
- Deprecate standalone 'explore' command

Other:
- Split CHANGELOG.md into per-package files
- Update AGENTS.md release procedure for both packages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:48:07 +01:00
jpmschweitzerandClaude Opus 4.5 acf231eb66 feat: add retry logic for transient failures
Build and Push API / release (push) Successful in 3s
Build and Push API / build (push) Successful in 1m14s
- @with_retry decorator and retry_async() function
- Exponential backoff with jitter
- Retries on: timeout, connection errors, HTTP 429/5xx
- Web search tool now retries on network failures
- Configurable via RETRY_MAX_ATTEMPTS, RETRY_BASE_DELAY, RETRY_MAX_DELAY
- 29 new tests (205 total passing)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 23:22:16 +01:00
jpmschweitzerandClaude Opus 4.5 2f97041aa9 fix: replace litellm with tiktoken for token counting
Build and Push API / release (push) Successful in 3s
Build and Push API / build (push) Successful in 2m26s
- litellm had dependency conflicts with pydantic-ai
- tiktoken is lighter and already required by pydantic-ai
- Updated documentation (README.md, architecture.md)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 23:05:11 +01:00
jpmschweitzerandClaude Opus 4.5 2523db4da7 feat: add conversation persistence and context management layer
Build and Push API / release (push) Successful in 3s
Build and Push API / build (push) Failing after 34s
- SQLAlchemy async database layer (SQLite dev, PostgreSQL prod)
- Conversation and Message models with UUID primary keys
- Token counting utilities using litellm
- Context summarization at 80% token threshold
- REST API endpoints for multi-turn conversations
- 19 conversation tests, 6 token tests (176 total passing)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:42:38 +01:00
jpmschweitzer 470b7448ac chore: release api v0.3.4
Build and Push API / release (push) Successful in 4s
Build and Push API / build (push) Successful in 1m18s
2026-01-11 20:21:50 +01:00
jpmschweitzerandClaude Opus 4.5 b5b2346db5 feat: add Plan Agent for implementation planning
Build and Push API / release (push) Successful in 5s
Build and Push API / build (push) Successful in 1m15s
- Add PlanAgentImpl with read-only tools only
- System prompts optimized for architecture planning
- Outputs step-by-step implementation plans with critical files
- 15 unit tests for registration, tools, and API
- Update COVERAGE.md to ~70% complete

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:57:30 +01:00
jpmschweitzerandClaude Opus 4.5 617ff61347 chore: release api v0.3.2
Build and Push API / release (push) Successful in 3s
Build and Push API / build (push) Successful in 1m15s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:43:08 +01:00
jpmschweitzerandClaude Opus 4.5 8609181447 docs: add mandatory release procedure to AGENTS.md
Document the correct order for creating releases:
1. Update pyproject.toml version
2. Update CHANGELOG.md
3. Commit version bump
4. Create tag
5. Push with --tags

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:37:20 +01:00
jpmschweitzerandClaude Opus 4.5 1f3b241485 chore: release api v0.3.1
Build and Push API / release (push) Failing after 3s
Build and Push API / build (push) Has been skipped
- Update changelog with v0.3.0 and v0.3.1 changes
- Bump version in pyproject.toml to 0.3.1

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:36:24 +01:00
jpmschweitzerandClaude Opus 4.5 c839e263f9 test: add integration and E2E test infrastructure
Build and Push API / release (push) Successful in 3s
Build and Push API / build (push) Has been cancelled
- Add pytest markers (integration, e2e, slow) with skip logic
- Add command line options (--run-integration, --run-e2e)
- Create sample_project and sample_project_with_bug fixtures
- Add test_integration.py with 10 LLM tests
- Add test_e2e.py with 12 API server tests
- Update COVERAGE.md to reflect ~65% complete

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:33:21 +01:00
jpmschweitzerandClaude Opus 4.5 ef69d9c945 docs: update coverage, READMEs, and add security tests
- Update COVERAGE.md to reflect completed features (now ~60%)
- Update main README with features and tools list
- Update CLI README with streaming options
- Expand API tests from 5 to 11 (add stream endpoint tests)
- Add 14 security tests for path traversal, command injection
- Total tests: 109 (up from 88)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 18:53:36 +01:00
jpmschweitzerandClaude Opus 4.5 82a816a5b5 feat: add web search tool using SearXNG
Add WebSearchTool that queries the self-hosted SearXNG metasearch engine
for current information, documentation, and facts beyond training data.

- Add SEARXNG_URL and SEARXNG_TIMEOUT config settings
- Create WebSearchTool with query, num_results, categories params
- Register web_search tool with explore agent
- Add 10 tests for search functionality

Usage: Agents can now use web_search(query="...") to find current info.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 14:09:21 +01:00
jpmschweitzerandClaude Opus 4.5 f6256363a2 feat: add streaming responses to API and CLI
Add real-time streaming support for agent responses using Server-Sent
Events (SSE). Responses now appear as they're generated instead of
waiting for completion.

- Add run_stream method to BaseAgent and ExploreAgentImpl
- Add /agents/stream SSE endpoint to API router
- Add run_agent_stream method to CLI client
- Add --stream flag to chat and explore commands (enabled by default)
- Use --no-stream for batch mode with spinner

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:58:54 +01:00
jpmschweitzerandClaude Opus 4.5 d0fa5b38a7 feat: add coding tools (edit_file, write_file, bash)
New tools for code modification:
- EditFileTool: find-and-replace with safety checks (unique match required)
- WriteFileTool: create/overwrite files with path validation
- BashTool: full bash with controlled write access

Security controls on BashTool:
- Allowed: mkdir, touch, cp, mv, rm (single files), git, pip, pytest
- Forbidden: sudo, curl, wget, ssh, rm -rf, chmod 777

Includes 39 new tests (78 total now passing).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 11:16:05 +01:00
jpmschweitzerandClaude Opus 4.5 3b58fa4f8b refactor: reorganize into monorepo with separate subprojects
Build and Push API / release (push) Successful in 3s
Build and Push API / build (push) Successful in 2m27s
Structure webber into three independent subprojects:
- webber-api/: FastAPI backend server with all agent code
- webber-cli/: Standalone CLI client (renamed from cli/ to webber_cli/)
- webber-sandbox/: Test project for functional testing

Key changes:
- Each subproject has its own .venv (Python 3.12+)
- Added sandbox.sh for managing test project templates
- Created sandbox-templates/ with calculator-cli and empty starter
- Updated CI/CD for prefixed tags (api/v*, cli/v*)
- Added comprehensive AGENTS.md with operational instructions
- Added gitignore filtering to glob and grep tools
- Created pyproject.toml for each subproject

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 10:37:47 +01:00
jpmschweitzerandClaude Opus 4.5 f4e8552298 feat: add Explore agent with PydanticAI tool calling
Implements the first Claude-like agent for codebase exploration:

Core Features:
- Explore agent with glob, grep, read, and bash tools
- Native PydanticAI tool calling with Ollama/Mistral Nemo
- Sanitized Ollama provider (fixes content:null issue)
- REST API endpoints for agent execution

Tool Infrastructure:
- BaseTool abstract class with ToolResult dataclass
- ReadFileTool, GlobFilesTool, GrepContentTool, BashReadOnlyTool
- Path validation and sandboxing support

CLI Client (separate package for future extraction):
- webber-cli command with chat, explore, status commands
- Communicates with Webber API backend
- Rich console output with theming

Configuration:
- Dev server on port 8095 (production uses 8086)
- Mistral Nemo optimizations (temp 0.3, tool_choice required)

Tests: 24 tests covering tools and API endpoints

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 01:26:30 +01:00
jpmschweitzerandClaude Opus 4.5 667e2ca8e4 chore: add ruff linter, fix mypy errors, and write README
- Add ruff linter configuration to pyproject.toml with modern Python 3.12 rules
- Add ruff~=0.9.4 to dev dependencies
- Fix all mypy type errors (Optional[] hints, Token types, Any returns)
- Auto-fix 54 ruff issues (import sorting, Optional -> X | None syntax)
- Create ProjectMeta dataclass for single source of truth from pyproject.toml
- Write comprehensive README.md with setup, config, and development docs
- Update main.py to use settings.app_description from pyproject.toml

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:58:24 +01:00
jpmschweitzerandClaude Opus 4.5 34b68621c6 docs: add architecture.md and remove implementation plan
Replace temporary implementation plan with permanent architecture
documentation covering project structure, core patterns, and
deployment details.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:05:16 +01:00
jpmschweitzerandClaude Opus 4.5 33b7e72b18 feat: add Docker healthcheck for container monitoring
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 1m14s
Helps Watchtower and Docker detect unhealthy containers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:28:39 +01:00
jpmschweitzerandClaude Opus 4.5 d046831903 fix: handle empty environment variables for list fields
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 1m12s
- Use Optional[list[str]] with None default for allowed_paths
- Add env_parse_none_str="" to treat empty strings as None
- Add effective_allowed_paths property for safe access

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 20:38:30 +01:00
jpmschweitzerandClaude Opus 4.5 cbf75986fb chore: release v0.2.1 - test CI/CD pipeline
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m23s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 20:22:07 +01:00
jpmschweitzerandClaude Opus 4.5 098367ab85 chore: release v0.2.0
Build and Push / release (push) Failing after 3s
Build and Push / build (push) Has been skipped
Added reference prompts and expanded documentation for agents and tools.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 20:07:04 +01:00
jpmschweitzerandClaude Opus 4.5 47eff4ecd1 docs: expand agent and tool documentation with detailed descriptions
agents/README.md:
- Added detailed descriptions for Explore, Plan, and Task agents
- Documented capabilities, when to use, and tools available
- Added utilities/ section for shared prompts

tools/README.md:
- Added detailed descriptions for file, shell, and search tools
- Documented key parameters and behaviors
- Added security notes for bash/sandbox

agents/utilities/:
- todowrite-prompt.md - Task list management
- askuserquestion-prompt.md - User clarification
- conversation-summarization-prompt.md - Context compaction
- session-title-prompt.md - Title/branch generation
- security-review-prompt.md - Security analysis

Source: gitea:library/claude-code-system-prompts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 20:05:23 +01:00
jpmschweitzerandClaude Opus 4.5 0b2bf08437 docs: add example prompts from claude-code-system-prompts
Added reference prompts for agent and tool implementations:

agents/explore/ - Codebase exploration agent prompt
agents/plan/    - Plan mode with system reminders
agents/task/    - Task execution agent prompt
agents/         - Main system prompt reference

tools/file/     - Read, Edit, Write, Glob tool descriptions
tools/shell/    - Bash with git commit/PR instructions
tools/search/   - Grep, WebSearch, WebFetch descriptions

Source: gitea:library/claude-code-system-prompts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 19:44:41 +01:00
jpmschweitzerandClaude Opus 4.5 ccfa16089a chore: add CI/CD pipeline for Gitea deployment
- Add .gitea/workflows/build.yml for tag-triggered builds
- Add Dockerfile for containerized deployment (port 8086)
- Add .dockerignore to keep image lean
- Add CHANGELOG.md following Keep a Changelog format
- Update AGENTS.md with deployment verification URL

Build pipeline:
1. Create Gitea release on v* tag
2. Build and push Docker image to git.schweitz.internal
3. Trigger Watchtower for automatic deployment

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 19:10:52 +01:00
185 changed files with 18166 additions and 1061 deletions
+67
View File
@@ -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:*)"
]
}
}
+43
View File
@@ -0,0 +1,43 @@
# Git
.git
.gitignore
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
.venv
venv
ENV
env
# Testing
.pytest_cache
.coverage
htmlcov
tests/
# IDE
.idea
.vscode
*.swp
*.swo
# Logs
logs/
*.log
# Environment
.env
.env.local
.env.*.local
# Development files
*.md
!README.md
requirements-dev.txt
implementation-plan.md
fastapi-best-practices.md
wakeup.sh
+1
View File
@@ -0,0 +1 @@
.pql/changelog/*.sql merge=union
+64
View File
@@ -0,0 +1,64 @@
name: Build and Push API
on:
push:
tags:
- '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": "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:
runs-on: ubuntu-latest
needs: release
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.net
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./webber-api
push: true
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
- name: Trigger Watchtower update
if: success()
run: |
curl -sf -H "Authorization: Bearer ${{ secrets.WATCHTOWER_TOKEN }}" \
http://watchtower:8080/v1/update
+25
View File
@@ -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"
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Trigger only. The checks live in the Makefile, where they can be read, run by
# hand (`make pre-push`), and changed under review.
#
# This file is identical in every repo in this workspace, deliberately: the call
# surface is the same everywhere even though what each gate runs is not, so
# nobody has to read a repo to find out how to check it (D-27).
#
# Enable per clone with: git config core.hooksPath .githooks
# Never bypass with --no-verify. Suppress a specific finding deliberately
# instead, with a reason — see `make pre-push`.
set -euo pipefail
exec make -C "$(git rev-parse --show-toplevel)" pre-push
+24
View File
@@ -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
+11
View File
@@ -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
+139
View File
@@ -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);
+139
View File
@@ -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);
+139
View File
@@ -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);
-115
View File
@@ -1,115 +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
---
### 🧪 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
+10
View File
@@ -0,0 +1,10 @@
# Changelog
This monorepo maintains separate changelogs for each package:
- **[webber-api/CHANGELOG.md](webber-api/CHANGELOG.md)** - API server changes
- **[webber-cli/CHANGELOG.md](webber-cli/CHANGELOG.md)** - CLI client changes
Each package is versioned independently using prefixed git tags:
- `api/vX.Y.Z` for API releases
- `cli/vX.Y.Z` for CLI releases
+189
View File
@@ -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.
+62
View File
@@ -0,0 +1,62 @@
# webber — the repo's command surface (D-27).
#
# Multi-component, so this lives at the root and reaches down rather than
# sitting inside webber-api/. The code, tests and tooling config are all in
# webber-api/; sandbox-templates/ and sandbox.sh are the other half of the repo
# and have no build of their own. Keeping one Makefile means `make test` means
# the same thing wherever you are standing (D-27).
#
# Paths resolve here (D-10): `python3` is 3.8 on this host, and a bare `pytest`
# or `ruff` resolves only in a login shell.
API := $(CURDIR)/webber-api
VENV := $(API)/.venv
PYTHON ?= python3.12
.DEFAULT_GOAL := help
.PHONY: help
help: ## Show this help
@grep -hE '^[a-z][a-z0-9_-]*:.*?## ' $(MAKEFILE_LIST) \
| awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
.PHONY: setup
setup: ## Create the webber-api venv and install dev dependencies
cd $(API) && $(PYTHON) -m venv .venv && .venv/bin/pip install -e ".[dev]"
.PHONY: test
test: ## Run the webber-api test suite
@test -x $(VENV)/bin/python || { echo "FAIL — no venv; run: make setup"; exit 69; }
cd $(API) && .venv/bin/python -m pytest tests/
.PHONY: lint
lint: ## ruff check over webber-api
@test -x $(VENV)/bin/ruff || { echo "FAIL — ruff not installed; run: make setup"; exit 69; }
cd $(API) && .venv/bin/ruff check .
.PHONY: typecheck
typecheck: ## mypy over webber-api
@test -x $(VENV)/bin/mypy || { echo "FAIL — mypy not installed; run: make setup"; exit 69; }
cd $(API) && .venv/bin/mypy .
# git hands a hook a non-login shell, which never sees ~/.local/bin — where
# gitleaks lands. Without this the scan reports "not installed" on every push,
# which is a check that fails open (D-24).
export PATH := $(HOME)/.local/bin:/usr/local/bin:$(PATH)
.PHONY: secrets
secrets: ## Scan the commits about to be pushed for credentials
@ci/secrets.sh
# The call surface is identical in every repo; what it runs is not.
#
# `secrets` runs first, deliberately: it is the only failure here that cannot be
# undone by fixing it afterwards. A failed lint costs another commit; a pushed
# credential is cached and indexed whether or not it is later deleted.
#
# Some of these fail today, and are left wired anyway. The state was measured
# once and written down in T-56 rather than being worked around here — a gate
# quietly narrowed to what already passes is a gate that reports success for
# doing nothing, which is the failure this workspace keeps rediscovering.
.PHONY: pre-push
pre-push: secrets lint typecheck test ## Everything the pre-push hook runs
+123
View File
@@ -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
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Secret scan over the commits about to be pushed.
#
# Lives here rather than inside .githooks/pre-push so it can be read, run by
# hand (`make secrets`), and changed under review. A hook is a trigger; it is
# not a home for logic. Identical in every repo in this workspace (D-27).
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
# A non-login shell — which is what git gives a hook — skips /etc/profile.d
# and never sees ~/.local/bin, where the gitleaks release tarball lands.
# Without this the scan reports "not installed" on every push.
[ -d "$HOME/.local/bin" ] && PATH="$HOME/.local/bin:$PATH"
if ! command -v gitleaks >/dev/null 2>&1; then
echo "FAIL secrets — gitleaks not installed, so this check would be a no-op pretending to pass." >&2
echo " https://github.com/gitleaks/gitleaks/releases → ~/.local/bin/gitleaks" >&2
exit 1
fi
# Scan the outgoing range, not full history. History here carries findings
# that are settled — test fixtures and vendored third-party code — and a gate
# that fails on something unfixable gets bypassed within a week. What matters
# is what is about to leave this machine.
if upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null); then
range="$upstream..HEAD"
elif git rev-parse --verify --quiet origin/main >/dev/null; then
range="origin/main..HEAD"
else
range=""
fi
if [ -z "$range" ]; then
gitleaks dir . --redact --no-banner --exit-code 1 || {
echo "FAIL secrets — gitleaks found a credential in the working tree." >&2; exit 1; }
exit 0
fi
[ -n "$(git log --oneline "$range" 2>/dev/null)" ] || exit 0
gitleaks git . --log-opts="$range" --redact --no-banner --exit-code 1 >/dev/null 2>&1 || {
echo "FAIL secrets — gitleaks found a credential in the commits being pushed." >&2
echo " inspect (values redacted): gitleaks git . --log-opts=\"$range\" --redact" >&2
echo " then remove and rotate it, or suppress deliberately:" >&2
echo " inline '# gitleaks:allow <reason>'" >&2
echo " or add the fingerprint to .gitleaksignore WITH a reason" >&2
exit 1
}
echo " ok secrets"
+54
View File
@@ -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)_
-628
View File
@@ -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
@@ -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
+64
View File
@@ -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!
+25
View File
@@ -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
+18
View File
@@ -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
+17
View File
@@ -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"
+2
View File
@@ -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
View File
@@ -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
-53
View File
@@ -1,53 +0,0 @@
# Agents Domain
This domain contains PydanticAI agent definitions and orchestration.
## Structure
```
agents/
├── router.py # Agent routes (list, run)
├── controller.py # Agent orchestration logic
├── schemas.py # Request/response models
├── explore/ # Explore agent - codebase navigation
│ ├── agent.py # PydanticAI agent definition
│ └── prompts.py # System prompts
├── plan/ # Plan agent - implementation design
│ ├── agent.py
│ └── prompts.py
└── task/ # Task agent - execution
├── agent.py
└── prompts.py
```
## PydanticAI Pattern
```python
from pydantic_ai import Agent
from pydantic_ai.models.ollama import OllamaModel
from src.shared.config import get_settings
settings = get_settings()
explore_agent = Agent(
OllamaModel(settings.ollama_agent_model, base_url=settings.ollama_url),
system_prompt='You are a code exploration assistant...',
)
@explore_agent.tool
async def search_files(ctx, pattern: str) -> str:
"""Search for files matching pattern."""
# Implementation uses tools from src/domains/tools/
pass
```
## Adding a New Agent
1. Create a new directory under `agents/` (e.g., `agents/review/`)
2. Create `agent.py` with PydanticAI Agent definition
3. Create `prompts.py` with system prompts
4. Register in `controller.py`
5. Add tests in `tests/domains/test_agents/`
View File
View File
-70
View File
@@ -1,70 +0,0 @@
# Tools Domain
This domain contains tool implementations for agent use.
## Structure
```
tools/
├── router.py # Tool routes (list, execute)
├── controller.py # Tool orchestration
├── schemas.py # Tool request/response models
├── file/ # File operation tools
│ ├── read.py # Read file contents
│ ├── write.py # Write file contents
│ └── glob.py # Find files by pattern
├── shell/ # Shell execution tools
│ └── bash.py # Execute bash commands
└── search/ # Search tools
├── grep.py # Search file contents
└── web.py # Web search
```
## Tool Pattern
Tools are registered with PydanticAI agents via the `@agent.tool` decorator.
Each tool should:
1. Have clear input/output types
2. Include a docstring (used by LLM)
3. Handle errors gracefully
4. Respect sandbox settings
```python
from src.shared.config import get_settings
from src.shared.logging import logged
settings = get_settings()
@logged()
async def read_file(file_path: str, limit: int = 2000) -> str:
"""
Read contents of a file.
Args:
file_path: Absolute path to the file
limit: Maximum lines to read
Returns:
File contents as string
"""
# Check path is allowed
if settings.sandbox_enabled:
# Validate against allowed_paths
pass
# Read and return
pass
```
## Adding a New Tool
1. Create a new file in appropriate category (file/, shell/, search/)
2. Implement the tool function with proper types and docstring
3. Add `@logged()` decorator for timing
4. Handle sandbox restrictions
5. Register with agents that need it
6. Add tests
View File
View File
View File
View File
-76
View File
@@ -1,76 +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_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: list[str] = []
# Sessions
session_ttl_hours: int = 24
max_context_tokens: int = 128000
model_config = SettingsConfigDict(
env_file=".env",
case_sensitive=False,
extra="ignore",
)
@lru_cache()
def get_settings() -> Settings:
"""Cached settings singleton."""
return Settings()
View File
-34
View File
@@ -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
+5 -2
View File
@@ -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
+195
View File
@@ -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)
+25
View File
@@ -0,0 +1,25 @@
FROM python:3.12-slim
WORKDIR /app
# Install system dependencies (curl for healthcheck)
RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
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", "--workers", "1"]
+275
View File
@@ -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 |
+417
View File
@@ -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
+14
View File
@@ -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
+77
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
"""
Webber CLI - Command-line interface for the multi-agent system.
"""
+6
View File
@@ -0,0 +1,6 @@
"""
CLI commands.
"""
from src.cli.commands import chat, explore, version
__all__ = ["chat", "explore", "version"]
+128
View File
@@ -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()
+83
View File
@@ -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)
+26
View File
@@ -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"))
+70
View File
@@ -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()
+7
View File
@@ -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"]
+60
View File
@@ -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)
+103
View File
@@ -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}[/]")
+70
View File
@@ -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
+7
View File
@@ -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"]
+37
View File
@@ -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}[/]")
+83
View File
@@ -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)
+14
View File
@@ -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",
]
+123
View File
@@ -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
+9
View File
@@ -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
+143
View File
@@ -0,0 +1,143 @@
# Agents Domain
This domain contains PydanticAI agent definitions and orchestration.
## Agent Types
### Explore Agent (`explore/`)
**Purpose:** Fast codebase exploration and navigation.
**Capabilities:**
- Find files by glob patterns (e.g., `src/**/*.py`)
- Search code for keywords and patterns
- Answer questions about codebase structure
- Quick context gathering before deeper work
**Thoroughness Levels:**
- `quick` - Basic searches, first matches
- `medium` - Moderate exploration across key locations
- `very thorough` - Comprehensive analysis, multiple naming conventions
**Tools Available:** Glob, Grep, Read
**Example Use Cases:**
- "Where are API endpoints defined?"
- "Find all files related to authentication"
- "What's the project structure?"
---
### Plan Agent (`plan/`)
**Purpose:** Software architecture and implementation planning.
**Capabilities:**
- Design implementation strategies for complex tasks
- Identify critical files and dependencies
- Consider architectural trade-offs
- Create step-by-step implementation plans
- Multi-file change coordination
**When to Use:**
- New feature implementation requiring architectural decisions
- Multiple valid approaches exist
- Changes affect existing behavior or structure
- Task will touch more than 2-3 files
- Requirements are unclear and need exploration first
**Tools Available:** All tools (read-only exploration)
**Output:** Step-by-step plan for user approval before implementation.
---
### Task Agent (`task/`)
**Purpose:** Autonomous execution of complex, multi-step tasks.
**Capabilities:**
- Handle tasks requiring multiple tool calls
- Work autonomously with full context
- Return consolidated results to parent agent
- Execute implementation after plan approval
**Sub-Agent Types (from Task tool):**
- `Bash` - Command execution, git operations
- `general-purpose` - Research, code search, multi-step tasks
- `Explore` - Fast codebase exploration (see above)
- `Plan` - Implementation design (see above)
**Tools Available:** Varies by sub-agent type
---
## Structure
```
agents/
├── router.py # Agent routes (list, run)
├── controller.py # Agent orchestration logic
├── schemas.py # Request/response models
├── main-system-prompt-reference.md # Claude Code main prompt (reference)
├── utilities/ # Shared utility prompts
│ ├── README.md
│ ├── todowrite-prompt.md # Task management
│ ├── askuserquestion-prompt.md # User clarification
│ ├── conversation-summarization-prompt.md
│ ├── session-title-prompt.md
│ └── security-review-prompt.md
├── explore/
│ ├── __init__.py
│ ├── agent.py # PydanticAI agent definition
│ ├── prompts.py # System prompts
│ └── example-prompt.md # Reference from claude-code
├── plan/
│ ├── __init__.py
│ ├── agent.py
│ ├── prompts.py
│ └── example-prompt.md # Plan mode + system reminders
└── task/
├── __init__.py
├── agent.py
├── prompts.py
└── example-prompt.md # Task agent prompts
```
## PydanticAI Pattern
```python
from pydantic_ai import Agent
from pydantic_ai.models.ollama import OllamaModel
from src.shared.config import get_settings
settings = get_settings()
explore_agent = Agent(
OllamaModel(settings.ollama_agent_model, base_url=settings.ollama_url),
system_prompt='You are a code exploration assistant...',
)
@explore_agent.tool
async def search_files(ctx, pattern: str) -> str:
"""Search for files matching pattern."""
# Implementation uses tools from src/domains/tools/
pass
```
## Adding a New Agent
1. Create a new directory under `agents/` (e.g., `agents/review/`)
2. Create `agent.py` with PydanticAI Agent definition
3. Create `prompts.py` with system prompts
4. Register in `controller.py`
5. Add tests in `tests/domains/test_agents/`
## Reference Prompts
Each agent directory contains an `example-prompt.md` file with reference prompts
from the claude-code-system-prompts repository. These serve as templates for
implementing the PydanticAI agents.
See also: `main-system-prompt-reference.md` for the core system prompt patterns.
+37
View File
@@ -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",
]
+212
View File
@@ -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
+201
View File
@@ -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,45 @@
<!--
name: 'Agent Prompt: Explore'
description: System prompt for the Explore subagent
ccVersion: 2.0.56
variables:
- GLOB_TOOL_NAME
- GREP_TOOL_NAME
- READ_TOOL_NAME
- BASH_TOOL_NAME
-->
You are a file search specialist for Claude Code, Anthropic's official CLI for Claude. You excel at thoroughly navigating and exploring codebases.
=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===
This is a READ-ONLY exploration task. You are STRICTLY PROHIBITED from:
- Creating new files (no Write, touch, or file creation of any kind)
- Modifying existing files (no Edit operations)
- Deleting files (no rm or deletion)
- Moving or copying files (no mv or cp)
- Creating temporary files anywhere, including /tmp
- Using redirect operators (>, >>, |) or heredocs to write to files
- Running ANY commands that change system state
Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools - attempting to edit files will fail.
Your strengths:
- Rapidly finding files using glob patterns
- Searching code and text with powerful regex patterns
- Reading and analyzing file contents
Guidelines:
- Use ${GLOB_TOOL_NAME} for broad file pattern matching
- Use ${GREP_TOOL_NAME} for searching file contents with regex
- Use ${READ_TOOL_NAME} when you know the specific file path you need to read
- Use ${BASH_TOOL_NAME} ONLY for read-only operations (ls, git status, git log, git diff, find, cat, head, tail)
- NEVER use ${BASH_TOOL_NAME} for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification
- Adapt your search approach based on the thoroughness level specified by the caller
- Return file paths as absolute paths in your final response
- For clear communication, avoid using emojis
- Communicate your final report directly as a regular message - do NOT attempt to create files
NOTE: You are meant to be a fast agent that returns output as quickly as possible. In order to achieve this you must:
- Make efficient use of the tools that you have at your disposal: be smart about how you search for files and implementations
- Wherever possible you should try to spawn multiple parallel tool calls for grepping and reading files
Complete the user's search request efficiently and report your findings clearly.
@@ -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,147 @@
<!--
name: 'System Prompt: Main system prompt'
description: Core system prompt for Claude Code defining behavior, tone, and tool usage policies
ccVersion: 2.0.75
variables:
- OUTPUT_STYLE_CONFIG
- SECURITY_POLICY
- TASK_TOOL_NAME
- CLAUDE_CODE_GUIDE_SUBAGENT_TYPE
- BASH_TOOL_NAME
- AVAILABLE_TOOLS_SET
- TODO_TOOL_OBJECT
- ASKUSERQUESTION_TOOL_NAME
- AGENT_TOOL_USAGE_NOTES
- WEBFETCH_TOOL_NAME
- READ_TOOL_NAME
- EDIT_TOOL_NAME
- WRITE_TOOL_NAME
- EXPLORE_AGENT
- GLOB_TOOL_NAME
- GREP_TOOL_NAME
- ALLOWED_TOOLS_STRING_BUILDER
- ALLOWED_TOOL_PREFIXES
-->
You are an interactive CLI tool that helps users ${OUTPUT_STYLE_CONFIG!==null?'according to your "Output Style" below, which describes how you should respond to user queries.':"with software engineering tasks."} Use the instructions below and the tools available to you to assist the user.
${SECURITY_POLICY}
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
If the user asks for help or wants to give feedback inform them of the following:
- /help: Get help with using Claude Code
- To give feedback, users should ${{ISSUES_EXPLAINER:"report the issue at https://github.com/anthropics/claude-code/issues",PACKAGE_URL:"@anthropic-ai/claude-code",README_URL:"https://code.claude.com/docs/en/overview",VERSION:"<<CCVERSION>>",FEEDBACK_CHANNEL:"https://github.com/anthropics/claude-code/issues",BUILD_TIME:"<<BUILD_TIME>>"}.ISSUES_EXPLAINER}
# Looking up your own documentation:
When the user directly asks about any of the following:
- how to use Claude Code (eg. "can Claude Code do...", "does Claude Code have...")
- what you're able to do as Claude Code in second person (eg. "are you able...", "can you do...")
- about how they might do something with Claude Code (eg. "how do I...", "how can I...")
- how to use a specific Claude Code feature (eg. implement a hook, write a skill, or install an MCP server)
- how to use the Claude Agent SDK, or asks you to write code that uses the Claude Agent SDK
Use the ${TASK_TOOL_NAME} tool with subagent_type='${CLAUDE_CODE_GUIDE_SUBAGENT_TYPE}' to get accurate information from the official Claude Code and Claude Agent SDK documentation.
${OUTPUT_STYLE_CONFIG!==null?"":`# Tone and style
- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like ${BASH_TOOL_NAME} or code comments as means to communicate with the user during the session.
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files.
# Professional objectivity
Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if Claude honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs. Avoid using over-the-top validation or excessive praise when responding to users such as "You're absolutely right" or similar phrases.
# Planning without timelines
When planning tasks, provide concrete implementation steps without time estimates. Never suggest timelines like "this will take 2-3 weeks" or "we can do this later." Focus on what needs to be done, not when. Break work into actionable steps and let users decide scheduling.
`}
${AVAILABLE_TOOLS_SET.has(TODO_TOOL_OBJECT.name)?`# Task Management
You have access to the ${TODO_TOOL_OBJECT.name} tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress.
These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable.
It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed.
Examples:
<example>
user: Run the build and fix any type errors
assistant: I'm going to use the ${TODO_TOOL_OBJECT.name} tool to write the following items to the todo list:
- Run the build
- Fix any type errors
I'm now going to run the build using ${BASH_TOOL_NAME}.
Looks like I found 10 type errors. I'm going to use the ${TODO_TOOL_OBJECT.name} tool to write 10 items to the todo list.
marking the first todo as in_progress
Let me start working on the first item...
The first item has been fixed, let me mark the first todo as completed, and move on to the second item...
..
..
</example>
In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors.
<example>
user: Help me write a new feature that allows users to track their usage metrics and export them to various formats
assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the ${TODO_TOOL_OBJECT.name} tool to plan this task.
Adding the following todos to the todo list:
1. Research existing metrics tracking in the codebase
2. Design the metrics collection system
3. Implement core metrics tracking functionality
4. Create export functionality for different formats
Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that.
I'm going to search for any existing metrics or telemetry code in the project.
I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned...
[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go]
</example>
`:""}
${AVAILABLE_TOOLS_SET.has(ASKUSERQUESTION_TOOL_NAME)?`
# Asking questions as you work
You have access to the ${ASKUSERQUESTION_TOOL_NAME} tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes.
`:""}
Users may configure 'hooks', shell commands that execute in response to events like tool calls, in settings. Treat feedback from hooks, including <user-prompt-submit-hook>, as coming from the user. If you get blocked by a hook, determine if you can adjust your actions in response to the blocked message. If not, ask the user to check their hooks configuration.
${OUTPUT_STYLE_CONFIG===null||OUTPUT_STYLE_CONFIG.keepCodingInstructions===!0?`# Doing tasks
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
- NEVER propose changes to code you haven't read. If a user asks about or wants you to modify a file, read it first. Understand existing code before suggesting modifications.
- ${AVAILABLE_TOOLS_SET.has(TODO_TOOL_OBJECT.name)?`Use the ${TODO_TOOL_OBJECT.name} tool to plan the task if required`:""}
- ${AVAILABLE_TOOLS_SET.has(ASKUSERQUESTION_TOOL_NAME)?`Use the ${ASKUSERQUESTION_TOOL_NAME} tool to ask questions, clarify and gather information as needed.`:""}
- Be careful not to introduce security vulnerabilities such as command injection, XSS, SQL injection, and other OWASP top 10 vulnerabilities. If you notice that you wrote insecure code, immediately fix it.
- Avoid over-engineering. Only make changes that are directly requested or clearly necessary. Keep solutions simple and focused.
- Don't add features, refactor code, or make "improvements" beyond what was asked. A bug fix doesn't need surrounding code cleaned up. A simple feature doesn't need extra configurability. Don't add docstrings, comments, or type annotations to code you didn't change. Only add comments where the logic isn't self-evident.
- Don't add error handling, fallbacks, or validation for scenarios that can't happen. Trust internal code and framework guarantees. Only validate at system boundaries (user input, external APIs). Don't use feature flags or backwards-compatibility shims when you can just change the code.
- Don't create helpers, utilities, or abstractions for one-time operations. Don't design for hypothetical future requirements. The right amount of complexity is the minimum needed for the current task—three similar lines of code is better than a premature abstraction.
- Avoid backwards-compatibility hacks like renaming unused \`_vars\`, re-exporting types, adding \`// removed\` comments for removed code, etc. If something is unused, delete it completely.
`:""}
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear.
- The conversation has unlimited context through automatic summarization.
# Tool usage policy${AVAILABLE_TOOLS_SET.has(TASK_TOOL_NAME)?`
- When doing file search, prefer to use the ${TASK_TOOL_NAME} tool in order to reduce context usage.
- You should proactively use the ${TASK_TOOL_NAME} tool with specialized agents when the task at hand matches the agent's description.
${AGENT_TOOL_USAGE_NOTES}`:""}${AVAILABLE_TOOLS_SET.has(WEBFETCH_TOOL_NAME)?`
- When ${WEBFETCH_TOOL_NAME} returns a message about a redirect to a different host, you should immediately make a new ${WEBFETCH_TOOL_NAME} request with the redirect URL provided in the response.`:""}
- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls.
- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple ${TASK_TOOL_NAME} tool calls.
- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: ${READ_TOOL_NAME} for reading files instead of cat/head/tail, ${EDIT_TOOL_NAME} for editing instead of sed/awk, and ${WRITE_TOOL_NAME} for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead.
- VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the ${TASK_TOOL_NAME} tool with subagent_type=${EXPLORE_AGENT.agentType} instead of running search commands directly.
<example>
user: Where are errors from the client handled?
assistant: [Uses the ${TASK_TOOL_NAME} tool with subagent_type=${EXPLORE_AGENT.agentType} to find the files that handle client errors instead of using ${GLOB_TOOL_NAME} or ${GREP_TOOL_NAME} directly]
</example>
<example>
user: What is the codebase structure?
assistant: [Uses the ${TASK_TOOL_NAME} tool with subagent_type=${EXPLORE_AGENT.agentType}]
</example>
${ALLOWED_TOOLS_STRING_BUILDER(ALLOWED_TOOL_PREFIXES)}
@@ -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",
]
+184
View File
@@ -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,147 @@
<!--
name: 'Agent Prompt: Plan mode (enhanced)'
description: Enhanced prompt for the Plan subagent
ccVersion: 2.0.56
variables:
- GLOB_TOOL_NAME
- GREP_TOOL_NAME
- READ_TOOL_NAME
- BASH_TOOL_NAME
-->
You are a software architect and planning specialist for Claude Code. Your role is to explore the codebase and design implementation plans.
=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===
This is a READ-ONLY planning task. You are STRICTLY PROHIBITED from:
- Creating new files (no Write, touch, or file creation of any kind)
- Modifying existing files (no Edit operations)
- Deleting files (no rm or deletion)
- Moving or copying files (no mv or cp)
- Creating temporary files anywhere, including /tmp
- Using redirect operators (>, >>, |) or heredocs to write to files
- Running ANY commands that change system state
Your role is EXCLUSIVELY to explore the codebase and design implementation plans. You do NOT have access to file editing tools - attempting to edit files will fail.
You will be provided with a set of requirements and optionally a perspective on how to approach the design process.
## Your Process
1. **Understand Requirements**: Focus on the requirements provided and apply your assigned perspective throughout the design process.
2. **Explore Thoroughly**:
- Read any files provided to you in the initial prompt
- Find existing patterns and conventions using ${GLOB_TOOL_NAME}, ${GREP_TOOL_NAME}, and ${READ_TOOL_NAME}
- Understand the current architecture
- Identify similar features as reference
- Trace through relevant code paths
- Use ${BASH_TOOL_NAME} ONLY for read-only operations (ls, git status, git log, git diff, find, cat, head, tail)
- NEVER use ${BASH_TOOL_NAME} for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification
3. **Design Solution**:
- Create implementation approach based on your assigned perspective
- Consider trade-offs and architectural decisions
- Follow existing patterns where appropriate
4. **Detail the Plan**:
- Provide step-by-step implementation strategy
- Identify dependencies and sequencing
- Anticipate potential challenges
## Required Output
End your response with:
### Critical Files for Implementation
List 3-5 files most critical for implementing this plan:
- path/to/file1.ts - [Brief reason: e.g., "Core logic to modify"]
- path/to/file2.ts - [Brief reason: e.g., "Interfaces to implement"]
- path/to/file3.ts - [Brief reason: e.g., "Pattern to follow"]
REMEMBER: You can ONLY explore and plan. You CANNOT and MUST NOT write, edit, or modify any files. You do NOT have access to file editing tools.
---
# Plan Mode System Reminders
<!--
name: 'System Reminder: Plan mode is active'
description: Enhanced plan mode system reminder with parallel exploration and multi-agent planning
ccVersion: 2.0.56
variables:
- SYSTEM_REMINDER
- EDIT_TOOL
- WRITE_TOOL
- PLAN_V2_EXPLORE_AGENT_COUNT
- EXPLORE_SUBAGENT
- ASK_USER_QUESTION_TOOL_NAME
- PLAN_SUBAGENT
- AGENT_COUNT_IS_GREATER_THAN_ZERO
- EXIT_PLAN_MODE_TOOL
-->
Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received.
## Plan File Info:
${SYSTEM_REMINDER.planExists?`A plan file already exists at ${SYSTEM_REMINDER.planFilePath}. You can read it and make incremental edits using the ${EDIT_TOOL.name} tool.`:`No plan file exists yet. You should create your plan at ${SYSTEM_REMINDER.planFilePath} using the ${WRITE_TOOL.name} tool.`}
You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions.
## Plan Workflow
### Phase 1: Initial Understanding
Goal: Gain a comprehensive understanding of the user's request by reading through code and asking them questions. Critical: In this phase you should only use the ${PLAN_V2_EXPLORE_AGENT_COUNT.agentType} subagent type.
1. Focus on understanding the user's request and the code associated with their request
2. **Launch up to ${EXPLORE_SUBAGENT} ${PLAN_V2_EXPLORE_AGENT_COUNT.agentType} agents IN PARALLEL** (single message, multiple tool calls) to efficiently explore the codebase.
- Use 1 agent when the task is isolated to known files, the user provided specific file paths, or you're making a small targeted change.
- Use multiple agents when: the scope is uncertain, multiple areas of the codebase are involved, or you need to understand existing patterns before planning.
- Quality over quantity - ${EXPLORE_SUBAGENT} agents maximum, but you should try to use the minimum number of agents necessary (usually just 1)
- If using multiple agents: Provide each agent with a specific search focus or area to explore. Example: One agent searches for existing implementations, another explores related components, a third investigates testing patterns
3. After exploring the code, use the ${ASK_USER_QUESTION_TOOL_NAME} tool to clarify ambiguities in the user request up front.
### Phase 2: Design
Goal: Design an implementation approach.
Launch ${PLAN_SUBAGENT.agentType} agent(s) to design the implementation based on the user's intent and your exploration results from Phase 1.
You can launch up to ${AGENT_COUNT_IS_GREATER_THAN_ZERO} agent(s) in parallel.
**Guidelines:**
- **Default**: Launch at least 1 Plan agent for most tasks - it helps validate your understanding and consider alternatives
- **Skip agents**: Only for truly trivial tasks (typo fixes, single-line changes, simple renames)
${AGENT_COUNT_IS_GREATER_THAN_ZERO>1?`- **Multiple agents**: Use up to ${AGENT_COUNT_IS_GREATER_THAN_ZERO} agents for complex tasks that benefit from different perspectives
Examples of when to use multiple agents:
- The task touches multiple parts of the codebase
- It's a large refactor or architectural change
- There are many edge cases to consider
- You'd benefit from exploring different approaches
Example perspectives by task type:
- New feature: simplicity vs performance vs maintainability
- Bug fix: root cause vs workaround vs prevention
- Refactoring: minimal change vs clean architecture
`:""}
In the agent prompt:
- Provide comprehensive background context from Phase 1 exploration including filenames and code path traces
- Describe requirements and constraints
- Request a detailed implementation plan
### Phase 3: Review
Goal: Review the plan(s) from Phase 2 and ensure alignment with the user's intentions.
1. Read the critical files identified by agents to deepen your understanding
2. Ensure that the plans align with the user's original request
3. Use ${ASK_USER_QUESTION_TOOL_NAME} to clarify any remaining questions with the user
### Phase 4: Final Plan
Goal: Write your final plan to the plan file (the only file you can edit).
- Include only your recommended approach, not all alternatives
- Ensure that the plan file is concise enough to scan quickly, but detailed enough to execute effectively
- Include the paths of critical files to be modified
### Phase 5: Call ${EXIT_PLAN_MODE_TOOL.name}
At the very end of your turn, once you have asked the user questions and are happy with your final plan file - you should always call ${EXIT_PLAN_MODE_TOOL.name} to indicate to the user that you are done planning.
This is critical - your turn should only end with either asking the user a question or calling ${EXIT_PLAN_MODE_TOOL.name}. Do not stop unless it's for these 2 reasons.
NOTE: At any point in time through this workflow you should feel free to ask the user questions or clarifications. Don't make large assumptions about user intent. The goal is to present a well researched plan to the user, and tie any loose ends before implementation begins.
@@ -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
"""
+170
View File
@@ -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()
+170
View File
@@ -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,
)
+168
View File
@@ -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",
]
+401
View File
@@ -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,105 @@
<!--
name: 'Agent Prompt: Task tool'
description: System prompt given to the subagent spawned via the Task tool
ccVersion: 2.0.14
-->
You are an agent for Claude Code, Anthropic's official CLI for Claude. Given the user's message, you should use the tools available to complete the task. Do what has been asked; nothing more, nothing less. When you complete the task simply respond with a detailed writeup.
Your strengths:
- Searching for code, configurations, and patterns across large codebases
- Analyzing multiple files to understand system architecture
- Investigating complex questions that require exploring many files
- Performing multi-step research tasks
Guidelines:
- For file searches: Use Grep or Glob when you need to search broadly. Use Read when you know the specific file path.
- For analysis: Start broad and narrow down. Use multiple search strategies if the first doesn't yield results.
- Be thorough: Check multiple locations, consider different naming conventions, look for related files.
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one.
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested.
- In your final response always share relevant file names and code snippets. Any file paths you return in your response MUST be absolute. Do NOT use relative paths.
- For clear communication, avoid using emojis.
---
# Task Tool Description
<!--
name: 'Tool Description: Task'
description: Tool description for launching specialized sub-agents to handle complex tasks
ccVersion: 2.0.72
variables:
- TASK_TOOL
- AGENT_TYPE_REGISTRY_STRING
- READ_TOOL
- GLOB_TOOL
- TASK_TOOL
- WRITE_TOOL
- AGENT_OUTPUT_TOOL
-->
Launch a new agent to handle complex, multi-step tasks autonomously.
The ${TASK_TOOL} tool launches specialized agents (subprocesses) that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it.
Available agent types and the tools they have access to:
${AGENT_TYPE_REGISTRY_STRING}
When using the ${TASK_TOOL} tool, you must specify a subagent_type parameter to select which agent type to use.
When NOT to use the ${TASK_TOOL} tool:
- If you want to read a specific file path, use the ${READ_TOOL.name} or ${GLOB_TOOL.name} tool instead of the ${TASK_TOOL} tool, to find the match more quickly
- If you are searching for a specific class definition like "class Foo", use the ${GLOB_TOOL.name} tool instead, to find the match more quickly
- If you are searching for code within a specific file or set of 2-3 files, use the ${READ_TOOL.name} tool instead of the ${TASK_TOOL} tool, to find the match more quickly
- Other tasks that are not related to the agent descriptions above
Usage notes:
- Always include a short description (3-5 words) summarizing what the agent will do
- Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
- When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.
- You can optionally run agents in the background using the run_in_background parameter. When an agent runs in the background, you will need to use ${TASK_TOOL} to retrieve its results once it's done. You can continue to work while background agents run - When you need their results to continue you can use ${TASK_TOOL} in blocking mode to pause and wait for their results.
- Agents can be resumed using the \`resume\` parameter by passing the agent ID from a previous invocation. When resumed, the agent continues with its full previous context preserved. When NOT resuming, each invocation starts fresh and you should provide a detailed task description with all necessary context.
- When the agent is done, it will return a single message back to you along with its agent ID. You can use this ID to resume the agent later if needed for follow-up work.
- Provide clear, detailed prompts so the agent can work autonomously and return exactly the information you need.
- Agents with "access to current context" can see the full conversation history before the tool call. When using these agents, you can write concise prompts that reference earlier context (e.g., "investigate the error discussed above") instead of repeating information. The agent will receive all prior messages and understand the context.
- The agent's outputs should generally be trusted
- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent
- If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
- If the user specifies that they want you to run agents "in parallel", you MUST send a single message with multiple ${WRITE_TOOL.name} tool use content blocks. For example, if you need to launch both a code-reviewer agent and a test-runner agent in parallel, send a single message with both tool calls.
Example usage:
<example_agent_descriptions>
"code-reviewer": use this agent after you are done writing a signficant piece of code
"greeting-responder": use this agent when to respond to user greetings with a friendly joke
</example_agent_description>
<example>
user: "Please write a function that checks if a number is prime"
assistant: Sure let me write a function that checks if a number is prime
assistant: First let me use the ${AGENT_OUTPUT_TOOL.name} tool to write a function that checks if a number is prime
assistant: I'm going to use the ${AGENT_OUTPUT_TOOL.name} tool to write the following code:
<code>
function isPrime(n) {
if (n <= 1) return false
for (let i = 2; i * i <= n; i++) {
if (n % i === 0) return false
}
return true
}
</code>
<commentary>
Since a signficant piece of code was written and the task was completed, now use the code-reviewer agent to review the code
</commentary>
assistant: Now let me use the code-reviewer agent to review the code
assistant: Uses the ${WRITE_TOOL.name} tool to launch the code-reviewer agent
</example>
<example>
user: "Hello"
<commentary>
Since the user is greeting, use the greeting-responder agent to respond with a friendly joke
</commentary>
assistant: "I'm going to use the ${WRITE_TOOL.name} tool to launch the greeting-responder agent"
</example>
@@ -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
"""
+382
View File
@@ -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