8 Commits
Author SHA1 Message Date
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
21 changed files with 1544 additions and 144 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ on:
- 'api/v*'
env:
IMAGE_NAME: git.schweitz.internal/jpmschweitzer/webber-api
IMAGE_NAME: git.schweitz.net/jpmschweitzer/webber-api
jobs:
release:
@@ -44,7 +44,7 @@ jobs:
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.schweitz.internal
registry: git.schweitz.net
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
+10
View File
@@ -108,9 +108,19 @@ cd webber-cli
- **Tab completion** for commands and file paths
- **Command history** persisted to `~/.webber_history`
- **Session persistence** - conversations saved and resumable
- **Config file** - persistent settings via `~/.webber/config.toml`
- **Runtime mode switching** via `mode plan|default|auto_accept`
- **Directory navigation** via `cd <path>`
**Configuration:**
```bash
# Show current config
.venv/bin/webber-cli config
# Initialize config file with defaults
.venv/bin/webber-cli config --init
```
**Note:** The API server must be running for CLI commands to work.
---
+4 -1
View File
@@ -18,9 +18,12 @@ OLLAMA_AGENT_MODEL=mistral-nemo-large:latest
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
+29
View File
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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
+1 -1
View File
@@ -136,7 +136,7 @@ Last updated: 2026-01-14
|---------|----------|-------------|------------|
| **Notebook editing** | Tools | Jupyter cell manipulation | Medium |
| **MCP support** | Infrastructure | Model Context Protocol | High |
| **Config file** | CLI | `~/.webber/config.toml` | Low |
| ~~**Config file**~~ | CLI | ✅ `~/.webber/config.toml` with `config` command | Low |
| **IDE integration** | CLI | VS Code extension | High |
| **Parallel agents** | Orchestration | Concurrent agent execution | High |
| **Agent memory** | Orchestration | Shared context between agents | Medium |
+3 -1
View File
@@ -207,7 +207,9 @@ All settings via environment variables or `.env`:
| OLLAMA_URL | http://192.168.86.149:11434 | Ollama API URL |
| OLLAMA_AGENT_MODEL | mistral-nemo-large:latest | Agent reasoning model |
| OLLAMA_EMBED_MODEL | nomic-embed-text:latest | Embedding model |
| TATLOCK_API_URL | http://192.168.86.149:8000 | Tatlock auth service |
| TATLOCK_API_URL | http://tatlock:8000 | Tatlock auth service |
| SEARXNG_URL | http://searxng:8080 | SearXNG web search instance |
| SEARXNG_TIMEOUT | 10 | SearXNG request timeout (seconds) |
| TOOL_TIMEOUT_SECONDS | 120 | Tool execution timeout |
| SANDBOX_ENABLED | true | Enable sandboxed execution |
| ALLOWED_PATHS | [] | Paths accessible to tools |
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "webber-api"
version = "0.4.2"
version = "1.0.1"
description = "Webber API - Multi-Agent AI Development Server"
authors = [
{name = "jpmschweitzer"}
+20 -4
View File
@@ -79,6 +79,14 @@ class ExploreAgentImpl(BaseAgent):
from src.domains.agents.explore.tools import register_explore_tools
register_explore_tools(agent)
def _build_prompt_with_context(self, prompt: str, working_dir: str) -> str:
"""Build the prompt with working directory context."""
return f"""Working directory: {working_dir}
Use paths within this working directory for file operations.
User request: {prompt}"""
@logged()
async def run(
self,
@@ -98,16 +106,20 @@ class ExploreAgentImpl(BaseAgent):
Returns:
Agent response with findings
"""
effective_working_dir = working_dir or os.getcwd()
ctx = ExploreContext(
working_dir=working_dir or os.getcwd(),
working_dir=effective_working_dir,
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
timeout_seconds=self._settings.tool_timeout_seconds,
)
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
async with trace_span("explore_agent_run"):
try:
# Use run() not run_stream() - Ollama has bugs with streaming + tools
result = await self.agent.run(prompt, deps=ctx)
result = await self.agent.run(full_prompt, deps=ctx)
return result.output
except Exception as e:
logger.exception(f"Explore agent error: {e}")
@@ -126,15 +138,19 @@ class ExploreAgentImpl(BaseAgent):
Yields text chunks as they become available.
"""
effective_working_dir = working_dir or os.getcwd()
ctx = ExploreContext(
working_dir=working_dir or os.getcwd(),
working_dir=effective_working_dir,
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
timeout_seconds=self._settings.tool_timeout_seconds,
)
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
async with trace_span("explore_agent_stream"):
try:
async with self.agent.run_stream(prompt, deps=ctx) as result:
async with self.agent.run_stream(full_prompt, deps=ctx) as result:
async for chunk in result.stream_text():
yield chunk
except Exception as e:
+20 -4
View File
@@ -82,6 +82,14 @@ class PlanAgentImpl(BaseAgent):
from src.domains.agents.plan.tools import register_plan_tools
register_plan_tools(agent)
def _build_prompt_with_context(self, prompt: str, working_dir: str) -> str:
"""Build the prompt with working directory context."""
return f"""Working directory: {working_dir}
Use paths within this working directory for file operations.
User request: {prompt}"""
@logged()
async def run(
self,
@@ -101,16 +109,20 @@ class PlanAgentImpl(BaseAgent):
Returns:
Implementation plan with steps and critical files
"""
effective_working_dir = working_dir or os.getcwd()
ctx = PlanContext(
working_dir=working_dir or os.getcwd(),
working_dir=effective_working_dir,
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
timeout_seconds=self._settings.tool_timeout_seconds,
)
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
async with trace_span("plan_agent_run"):
try:
# Use run() not run_stream() - Ollama has bugs with streaming + tools
result = await self.agent.run(prompt, deps=ctx)
result = await self.agent.run(full_prompt, deps=ctx)
return result.output
except Exception as e:
logger.exception(f"Plan agent error: {e}")
@@ -128,15 +140,19 @@ class PlanAgentImpl(BaseAgent):
Yields text chunks as they become available.
"""
effective_working_dir = working_dir or os.getcwd()
ctx = PlanContext(
working_dir=working_dir or os.getcwd(),
working_dir=effective_working_dir,
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
timeout_seconds=self._settings.tool_timeout_seconds,
)
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
async with trace_span("plan_agent_stream"):
try:
async with self.agent.run_stream(prompt, deps=ctx) as result:
async with self.agent.run_stream(full_prompt, deps=ctx) as result:
async for chunk in result.stream_text():
yield chunk
except Exception as e:
+38 -16
View File
@@ -5,6 +5,9 @@ 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
@@ -22,7 +25,15 @@ from src.domains.agents.schemas import (
AgentInfo,
AgentListResponse,
PermissionMode,
StreamEvent,
)
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)
from src.shared.logging import logged, get_logger
logger = get_logger(__name__)
@@ -56,18 +67,21 @@ async def run_agent(request: AgentRunRequest) -> AgentRunResponse:
detail=f"Unknown agent type: {request.agent_type}"
)
# Convert mode string to enum (use_enum_values=True in schema)
mode = _get_mode(request.mode)
try:
# Run the agent with mode
response = await agent.run(
request.prompt,
working_dir=request.working_dir,
mode=request.mode,
mode=mode,
)
return AgentRunResponse(
response=response,
agent_type=request.agent_type,
mode=request.mode,
mode=request.mode, # Keep original for response
success=True,
)
@@ -88,13 +102,16 @@ async def stream_agent(request: AgentRunRequest) -> StreamingResponse:
"""
Run an agent with streaming response.
Returns Server-Sent Events (SSE) with text chunks.
Returns Server-Sent Events (SSE) with structured events.
Permission mode controls which tools are available.
Event types:
- "chunk": Text chunk from the agent
- "done": Stream complete
- "error": Error occurred
Event types (from StreamEvent):
- tool_start: Tool execution beginning
- tool_done: Tool execution complete
- thinking: Agent status update
- response: Final response text chunk
- error: Error occurred
- done: Stream complete
"""
agent = get_agent(request.agent_type)
if not agent:
@@ -103,23 +120,28 @@ async def stream_agent(request: AgentRunRequest) -> StreamingResponse:
detail=f"Unknown agent type: {request.agent_type}"
)
# Convert mode string to enum (use_enum_values=True in schema)
mode = _get_mode(request.mode)
async def generate():
try:
async for chunk in agent.run_stream(
async for event in agent.run_stream(
request.prompt,
working_dir=request.working_dir,
mode=request.mode,
mode=mode,
):
# SSE format: data: {json}\n\n
event = {"event": "chunk", "data": chunk}
yield f"data: {json.dumps(event)}\n\n"
# Signal completion
yield f"data: {json.dumps({'event': 'done', 'mode': request.mode.value})}\n\n"
# Handle both StreamEvent objects and legacy string chunks
if isinstance(event, StreamEvent):
# New structured event format
event_data = event.model_dump(exclude_none=True)
yield f"data: {json.dumps(event_data)}\n\n"
else:
# Legacy string chunk (for explore/plan agents)
yield f"data: {json.dumps({'event': 'chunk', 'data': event})}\n\n"
except Exception as e:
logger.exception(f"Stream error: {e}")
error_event = {"event": "error", "data": str(e)}
error_event = {"event": "error", "error_message": str(e)}
yield f"data: {json.dumps(error_event)}\n\n"
return StreamingResponse(
+33
View File
@@ -127,3 +127,36 @@ class AgentInfo(BaseSchema):
class AgentListResponse(BaseSchema):
"""List of available agents."""
agents: list[AgentInfo]
# Streaming event types for event-based streaming
class StreamEventType(str, Enum):
"""
Event types for structured agent streaming.
Instead of streaming raw text (which gets garbled during tool calls),
we emit structured events that the CLI can render appropriately.
"""
tool_start = "tool_start" # Tool execution starting
tool_done = "tool_done" # Tool execution complete
thinking = "thinking" # Agent reasoning status
response = "response" # Final response text chunk
error = "error" # Error occurred
done = "done" # Stream complete
class StreamEvent(BaseSchema):
"""
Structured streaming event from agent execution.
Events are emitted instead of raw text to provide clean
progress feedback during multi-tool agent loops.
"""
event: StreamEventType
tool: str | None = None # Tool name (for tool_start/tool_done)
args: dict | None = None # Tool arguments (for tool_start)
result_summary: str | None = None # Brief result (for tool_done)
message: str | None = None # Status message (for thinking)
text: str | None = None # Response text (for response)
error_message: str | None = None # Error details (for error)
mode: str | None = None # Permission mode (for done)
+219 -39
View File
@@ -5,7 +5,9 @@ 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 os
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
@@ -15,7 +17,7 @@ from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIModel
from src.domains.agents.base import BaseAgent, AgentContext, register_agent
from src.domains.agents.schemas import PermissionMode
from src.domains.agents.schemas import PermissionMode, StreamEvent, StreamEventType
from src.domains.agents.task.prompts import TASK_SYSTEM_PROMPT, TASK_PLAN_MODE_PROMPT
from src.ollama.provider import get_ollama_provider
from src.shared.config import get_settings
@@ -30,11 +32,33 @@ class TaskContext(AgentContext):
Context for task agent tools.
Passed to all tool functions via RunContext.
Extends base AgentContext with permission mode.
Extends base AgentContext with permission mode and event queue.
"""
mode: PermissionMode = PermissionMode.default
# Prep for approval flow - tools can check this
pending_approvals: list[str] = field(default_factory=list)
# Event queue for streaming events from tools
event_queue: asyncio.Queue | None = field(default=None, repr=False)
# Track tool calls for retry logic
tools_called: int = 0
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):
@@ -102,14 +126,41 @@ class TaskAgentImpl(BaseAgent):
def _register_tools(self, agent: Agent[TaskContext, str], mode: PermissionMode) -> None:
"""Register tools with the agent based on permission mode."""
from src.domains.agents.task.tools import register_task_tools, register_readonly_tools
from src.domains.agents.task.tools_streaming import (
register_task_tools_streaming,
register_readonly_tools_streaming,
)
if mode == PermissionMode.plan:
# Plan mode: read-only tools only
register_readonly_tools(agent)
register_readonly_tools_streaming(agent)
else:
# Default and auto_accept: all tools
register_task_tools(agent)
register_task_tools_streaming(agent)
# Maximum retries when no tools are called
MAX_NO_TOOL_RETRIES = 2
def _build_prompt_with_context(self, prompt: str, working_dir: str) -> str:
"""Build the prompt with working directory context."""
return f"""Working directory: {working_dir}
When using file tools, use paths relative to or within this working directory.
For example, to read a file at {working_dir}/README.md, use file_path="{working_dir}/README.md".
User request: {prompt}"""
def _build_retry_prompt(self, prompt: str, working_dir: str) -> str:
"""Build a stronger prompt for retry after no tool calls."""
return f"""Working directory: {working_dir}
IMPORTANT: Your previous response was REJECTED because you did not call any tools.
You MUST call a tool (like glob_files, bash_readonly, or read_file) BEFORE responding.
DO NOT answer from memory. DO NOT fabricate information.
Call a tool NOW to gather real information, then respond based on the results.
User request: {prompt}"""
@logged()
async def run(
@@ -132,23 +183,47 @@ class TaskAgentImpl(BaseAgent):
Returns:
Consolidated task summary with results
"""
ctx = TaskContext(
working_dir=working_dir or os.getcwd(),
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
timeout_seconds=self._settings.tool_timeout_seconds,
mode=mode,
)
effective_working_dir = working_dir or os.getcwd()
# Get agent configured for this mode
agent = self._get_agent_for_mode(mode)
async with trace_span("task_agent_run", mode=mode.value):
try:
result = await agent.run(prompt, deps=ctx)
return result.output
except Exception as e:
logger.exception(f"Task agent error: {e}")
raise
async with trace_span("task_agent_run"):
retries = 0
while retries <= self.MAX_NO_TOOL_RETRIES:
# Create fresh context for each attempt
ctx = TaskContext(
working_dir=effective_working_dir,
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
timeout_seconds=self._settings.tool_timeout_seconds,
mode=mode,
)
# Build prompt - use retry prompt if this is a retry
if retries == 0:
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
else:
full_prompt = self._build_retry_prompt(prompt, effective_working_dir)
logger.warning(f"Retry {retries}/{self.MAX_NO_TOOL_RETRIES}: No tools called, retrying with stronger prompt")
try:
result = await agent.run(full_prompt, deps=ctx)
# Check if tools were called
if ctx.tools_called == 0 and retries < self.MAX_NO_TOOL_RETRIES:
retries += 1
continue
if ctx.tools_called == 0:
logger.warning("Agent responded without calling tools after all retries")
return result.output
except Exception as e:
logger.exception(f"Task agent error: {e}")
raise
# Should not reach here, but just in case
return result.output
async def run_stream(
self,
@@ -157,9 +232,12 @@ class TaskAgentImpl(BaseAgent):
allowed_paths: list[str] | None = None,
mode: PermissionMode = PermissionMode.default,
**kwargs: Any
) -> AsyncIterator[str]:
) -> AsyncIterator[StreamEvent]:
"""
Run the task agent with streaming output.
Run the task agent with structured event streaming.
Instead of streaming raw text (which gets garbled during tool calls),
yields structured events that clients can render appropriately.
Args:
prompt: Task description
@@ -168,26 +246,128 @@ class TaskAgentImpl(BaseAgent):
mode: Permission mode controlling tool access
Yields:
Text chunks as they become available.
StreamEvent objects for tool progress and final response.
Event types:
- tool_start: Tool execution beginning
- tool_done: Tool execution complete with summary
- thinking: Agent status update
- response: Final response text
- error: Error occurred
- done: Stream complete
"""
ctx = TaskContext(
working_dir=working_dir or os.getcwd(),
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
timeout_seconds=self._settings.tool_timeout_seconds,
mode=mode,
)
effective_working_dir = working_dir or os.getcwd()
# Get agent configured for this mode
agent = self._get_agent_for_mode(mode)
async with trace_span("task_agent_stream", mode=mode.value):
try:
async with agent.run_stream(prompt, deps=ctx) as result:
async for chunk in result.stream_text():
yield chunk
except Exception as e:
logger.exception(f"Task agent stream error: {e}")
raise
async with trace_span("task_agent_stream"):
# Emit initial thinking event
yield StreamEvent(
event=StreamEventType.thinking,
message="Starting task execution..."
)
retries = 0
response = ""
while retries <= self.MAX_NO_TOOL_RETRIES:
# Create fresh event queue and context for each attempt
event_queue: asyncio.Queue[StreamEvent] = asyncio.Queue()
ctx = TaskContext(
working_dir=effective_working_dir,
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
timeout_seconds=self._settings.tool_timeout_seconds,
mode=mode,
event_queue=event_queue,
)
# Build prompt - use retry prompt if this is a retry
if retries == 0:
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
else:
full_prompt = self._build_retry_prompt(prompt, effective_working_dir)
yield StreamEvent(
event=StreamEventType.thinking,
message=f"Retrying (attempt {retries + 1})..."
)
# Run agent in background task so we can yield events
async def run_agent() -> 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 asyncio.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()
try:
await agent_task
except asyncio.CancelledError:
pass
return
# Yield response in chunks for streaming feel
chunk_size = 100
for i in range(0, len(response), chunk_size):
chunk = response[i:i + chunk_size]
yield StreamEvent(
event=StreamEventType.response,
text=chunk
)
# Small delay for streaming effect
await asyncio.sleep(0.01)
# Signal completion
yield StreamEvent(
event=StreamEventType.done,
mode=mode.value
)
# Create and register the singleton instance
@@ -208,7 +388,7 @@ async def task_stream(
prompt: str,
working_dir: str | None = None,
**kwargs: Any
) -> AsyncIterator[str]:
"""Run task execution with streaming."""
async for chunk in task_agent.run_stream(prompt, working_dir=working_dir, **kwargs):
yield chunk
) -> AsyncIterator[StreamEvent]:
"""Run task execution with event streaming."""
async for event in task_agent.run_stream(prompt, working_dir=working_dir, **kwargs):
yield event
+36 -20
View File
@@ -9,6 +9,11 @@ The Task agent is a full orchestrator that can:
TASK_PLAN_MODE_PROMPT = """You are a codebase analysis and planning agent in READ-ONLY mode.
CRITICAL RULE: You MUST call a tool BEFORE responding to ANY request.
- NEVER answer from memory or assumptions
- NEVER fabricate file structures, code, or content
- If you respond without calling a tool first, YOUR ANSWER IS WRONG
You can explore and analyze code but CANNOT modify files or execute write operations.
AVAILABLE TOOLS (read-only):
@@ -24,12 +29,10 @@ Shell:
Orchestration:
- spawn_agent: Launch sub-agents for focused tasks (explore, plan only)
WORKFLOW:
1. Understand the request
2. Explore the codebase to gather context
3. Analyze code structure and patterns
4. Create detailed implementation plans
5. Return findings with actionable recommendations
MANDATORY WORKFLOW:
1. FIRST: Call a tool to gather real information
2. THEN: Analyze the actual tool results
3. FINALLY: Respond based only on what tools returned
TOOL CALL EXAMPLES:
@@ -42,22 +45,25 @@ To search for a function:
To check git status:
Call bash_readonly with command="git status"
To list directory contents:
Call bash_readonly with command="ls -la"
To get deeper analysis:
Call spawn_agent with agent_type="explore" and prompt="find authentication code"
RULES:
- ALWAYS use tools first, then analyze results
- Never guess file contents - read them first
- ALWAYS call a tool FIRST - no exceptions
- Never guess or fabricate - only report what tools return
- Be thorough in exploration
- Provide specific file paths and line numbers in findings
- Provide specific file paths and line numbers from tool results
OUTPUT FORMAT:
Structure your response with:
### Analysis
- What was found
- What was found (from tool results)
- Key patterns identified
- Relevant files
- Relevant files (actual paths from tools)
### Recommendations
- Suggested approach
@@ -67,6 +73,11 @@ Structure your response with:
TASK_SYSTEM_PROMPT = """You are an autonomous task execution agent.
CRITICAL RULE: You MUST call a tool BEFORE responding to ANY request.
- NEVER answer from memory or assumptions
- NEVER fabricate file structures, code, or content
- If you respond without calling a tool first, YOUR ANSWER IS WRONG
You have access to ALL tools including file editing, writing, and bash execution.
You can also spawn sub-agents to help with complex tasks.
@@ -89,16 +100,21 @@ External:
Orchestration:
- spawn_agent: Launch sub-agents for focused tasks
WORKFLOW:
1. Understand the task requirements
2. Break down into sub-tasks if complex
3. Use spawn_agent for research (explore) or planning (plan)
4. Execute implementation steps using write tools
5. Validate changes (run tests if applicable)
6. Return consolidated summary
MANDATORY WORKFLOW:
1. FIRST: Call a tool to gather real information
2. THEN: Analyze the actual tool results
3. Execute implementation using write tools if needed
4. Validate changes (run tests if applicable)
5. FINALLY: Return summary based only on what tools returned
TOOL CALL EXAMPLES:
To list directory contents:
Call bash_readonly with command="ls -la"
To find all Python files:
Call glob_files with pattern="**/*.py"
To spawn an Explore agent for research:
Call spawn_agent with agent_type="explore" and prompt="find all config files"
@@ -124,8 +140,8 @@ GIT DISCIPLINE:
- Run tests before committing
RULES:
- ALWAYS use tools first, then analyze results
- Never guess file contents - read them first
- ALWAYS call a tool FIRST - no exceptions
- Never guess or fabricate - only report what tools return
- Prefer edit_file over write_file for existing files
- Use spawn_agent to keep context focused
- Validate changes by running tests when applicable
@@ -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.read import ReadFileTool
from src.domains.tools.file.glob import GlobFilesTool
from src.domains.tools.file.edit import EditFileTool
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)
+2 -2
View File
@@ -68,11 +68,11 @@ class Settings(BaseSettings):
ollama_embed_model: str = "nomic-embed-text:latest"
# Auth - Tatlock integration
tatlock_api_url: str | None = "http://192.168.86.149:8000"
tatlock_api_url: str | None = "http://tatlock:8000"
internal_api_key: str | None = None
# Web search - SearXNG (use SEARXNG_URL env var to override)
searxng_url: str = "http://192.168.86.149:8087"
searxng_url: str = "http://searxng:8080"
searxng_timeout: int = 10
# Tool execution
+185
View File
@@ -1,7 +1,13 @@
"""
Tests for agent REST API endpoints.
Includes integration tests that verify real code paths work correctly
without over-mocking (only LLM calls are mocked).
"""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from src.domains.agents.schemas import PermissionMode
class TestAgentListEndpoint:
@@ -160,3 +166,182 @@ class TestAgentStreamEndpoint:
)
# Unknown agent returns 400, not streaming
assert response.status_code == 400
class TestPermissionModeIntegration:
"""
Integration tests for permission mode handling.
These tests verify that mode strings are correctly converted to enums
and that the full request->router->agent flow works for each mode.
"""
@pytest.mark.anyio
async def test_run_with_default_mode(self, auth_client):
"""Test running agent with default mode passes through correctly."""
# Mock the agent.run method to avoid LLM calls
mock_result = MagicMock()
mock_result.output = "Test response"
with patch("src.domains.agents.task.agent.TaskAgentImpl._get_agent_for_mode") as mock_get_agent:
mock_agent = MagicMock()
mock_agent.run = AsyncMock(return_value=mock_result)
mock_get_agent.return_value = mock_agent
response = await auth_client.post(
"/agents/run",
json={
"prompt": "test prompt",
"agent_type": "task",
"working_dir": ".",
"mode": "default"
}
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["mode"] == "default"
# Verify mode was converted to enum and passed correctly
mock_get_agent.assert_called_once_with(PermissionMode.default)
@pytest.mark.anyio
async def test_run_with_plan_mode(self, auth_client):
"""Test running agent with plan mode passes through correctly."""
mock_result = MagicMock()
mock_result.output = "Plan response"
with patch("src.domains.agents.task.agent.TaskAgentImpl._get_agent_for_mode") as mock_get_agent:
mock_agent = MagicMock()
mock_agent.run = AsyncMock(return_value=mock_result)
mock_get_agent.return_value = mock_agent
response = await auth_client.post(
"/agents/run",
json={
"prompt": "test prompt",
"agent_type": "task",
"working_dir": ".",
"mode": "plan"
}
)
assert response.status_code == 200
data = response.json()
assert data["mode"] == "plan"
mock_get_agent.assert_called_once_with(PermissionMode.plan)
@pytest.mark.anyio
async def test_run_with_auto_accept_mode(self, auth_client):
"""Test running agent with auto_accept mode passes through correctly."""
mock_result = MagicMock()
mock_result.output = "Auto accept response"
with patch("src.domains.agents.task.agent.TaskAgentImpl._get_agent_for_mode") as mock_get_agent:
mock_agent = MagicMock()
mock_agent.run = AsyncMock(return_value=mock_result)
mock_get_agent.return_value = mock_agent
response = await auth_client.post(
"/agents/run",
json={
"prompt": "test prompt",
"agent_type": "task",
"working_dir": ".",
"mode": "auto_accept"
}
)
assert response.status_code == 200
data = response.json()
assert data["mode"] == "auto_accept"
mock_get_agent.assert_called_once_with(PermissionMode.auto_accept)
@pytest.mark.anyio
async def test_run_with_invalid_mode(self, auth_client):
"""Test running agent with invalid mode returns validation error."""
response = await auth_client.post(
"/agents/run",
json={
"prompt": "test prompt",
"agent_type": "task",
"working_dir": ".",
"mode": "invalid_mode"
}
)
assert response.status_code == 422
@pytest.mark.anyio
async def test_stream_with_plan_mode(self, auth_client):
"""Test streaming agent with plan mode passes through correctly."""
from src.domains.agents.schemas import StreamEvent, StreamEventType
async def mock_event_stream(*args, **kwargs):
"""Mock event-based stream."""
yield StreamEvent(event=StreamEventType.thinking, message="Starting...")
yield StreamEvent(event=StreamEventType.response, text="chunk1")
yield StreamEvent(event=StreamEventType.response, text="chunk2")
yield StreamEvent(event=StreamEventType.done, mode="plan")
with patch("src.domains.agents.task.agent.TaskAgentImpl.run_stream") as mock_run_stream:
mock_run_stream.return_value = mock_event_stream()
response = await auth_client.post(
"/agents/stream",
json={
"prompt": "test prompt",
"agent_type": "task",
"working_dir": ".",
"mode": "plan"
}
)
assert response.status_code == 200
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
mock_run_stream.assert_called_once()
class TestAgentMethodSignatures:
"""
Tests to verify agent method signatures match expected interfaces.
These catch issues like passing invalid kwargs to methods.
"""
def test_task_agent_run_accepts_mode(self):
"""Verify TaskAgentImpl.run() accepts mode parameter."""
from src.domains.agents.task.agent import TaskAgentImpl
import inspect
sig = inspect.signature(TaskAgentImpl.run)
params = list(sig.parameters.keys())
assert "mode" in params
# Verify mode has correct type annotation
mode_param = sig.parameters["mode"]
assert mode_param.default == PermissionMode.default
def test_task_agent_run_stream_accepts_mode(self):
"""Verify TaskAgentImpl.run_stream() accepts mode parameter."""
from src.domains.agents.task.agent import TaskAgentImpl
import inspect
sig = inspect.signature(TaskAgentImpl.run_stream)
params = list(sig.parameters.keys())
assert "mode" in params
mode_param = sig.parameters["mode"]
assert mode_param.default == PermissionMode.default
def test_trace_span_signature(self):
"""Verify trace_span only accepts expected parameters."""
from src.shared.logging import trace_span
import inspect
sig = inspect.signature(trace_span.__init__)
params = list(sig.parameters.keys())
# Should only have self, name, logger - not mode or other extras
assert params == ["self", "name", "logger"]
+1 -1
View File
@@ -12,7 +12,7 @@ class TestWebSearchTool:
@pytest.fixture
def tool(self):
return WebSearchTool(searxng_url="http://localhost:8087", timeout=5)
return WebSearchTool(searxng_url="http://searxng:8080", timeout=5)
@pytest.fixture
def mock_search_response(self):
+60 -8
View File
@@ -26,6 +26,41 @@ class PermissionMode(str, Enum):
auto_accept = "auto_accept"
class StreamEventType(str, Enum):
"""Event types for structured agent streaming."""
tool_start = "tool_start"
tool_done = "tool_done"
thinking = "thinking"
response = "response"
error = "error"
done = "done"
chunk = "chunk" # Legacy text chunk
@dataclass
class StreamEvent:
"""
Structured streaming event from agent execution.
Different event types carry different data:
- tool_start: tool, args
- tool_done: tool, result_summary
- thinking: message
- response: text
- error: error_message
- done: mode
- chunk: text (legacy)
"""
event: StreamEventType
tool: str | None = None
args: dict | None = None
result_summary: str | None = None
message: str | None = None
text: str | None = None
error_message: str | None = None
mode: str | None = None
@dataclass
class AgentResponse:
"""Response from agent execution."""
@@ -202,7 +237,7 @@ class WebberClient:
prompt: str,
working_dir: str = ".",
mode: PermissionMode = PermissionMode.default,
) -> AsyncIterator[str]:
) -> AsyncIterator[StreamEvent]:
"""
Run an agent with streaming response.
@@ -213,7 +248,7 @@ class WebberClient:
mode: Permission mode controlling tool access
Yields:
Text chunks as they arrive
StreamEvent objects as they arrive
"""
# Use a fresh client for streaming with longer timeout
async with httpx.AsyncClient(
@@ -235,13 +270,30 @@ class WebberClient:
if line.startswith("data: "):
try:
data = json.loads(line[6:])
event = data.get("event")
if event == "chunk":
yield data.get("data", "")
elif event == "error":
raise Exception(data.get("data", "Unknown error"))
elif event == "done":
event_type = data.get("event")
# Parse event type
try:
evt_type = StreamEventType(event_type)
except ValueError:
continue # Unknown event type
# Build StreamEvent from response data
yield StreamEvent(
event=evt_type,
tool=data.get("tool"),
args=data.get("args"),
result_summary=data.get("result_summary"),
message=data.get("message"),
text=data.get("text") or data.get("data"), # 'data' for legacy chunk
error_message=data.get("error_message") or data.get("data"),
mode=data.get("mode"),
)
# Stop on done or error
if evt_type in (StreamEventType.done, StreamEventType.error):
break
except json.JSONDecodeError:
continue
+152
View File
@@ -0,0 +1,152 @@
"""
Configuration management for Webber CLI.
Loads settings from ~/.webber/config.toml with environment variable overrides.
"""
import os
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# Config directory and file paths
CONFIG_DIR = Path.home() / ".webber"
CONFIG_FILE = CONFIG_DIR / "config.toml"
# Default configuration template
DEFAULT_CONFIG = """\
# Webber CLI Configuration
# https://github.com/jpmschweitzer/webber
[api]
# API server URL (dev: 8095, prod: 8086)
url = "http://localhost:8095"
# API key for authentication (optional for dev mode)
# key = "your-api-key"
[cli]
# Default permission mode: default, plan, auto_accept
mode = "default"
# Enable streaming by default
stream = true
[history]
# Command history file location
file = "~/.webber_history"
"""
@dataclass
class ApiConfig:
"""API connection settings."""
url: str = "http://localhost:8095"
key: str | None = None
@dataclass
class CliConfig:
"""CLI behavior settings."""
mode: str = "default"
stream: bool = True
@dataclass
class HistoryConfig:
"""History settings."""
file: str = "~/.webber_history"
@dataclass
class Config:
"""Complete configuration."""
api: ApiConfig = field(default_factory=ApiConfig)
cli: CliConfig = field(default_factory=CliConfig)
history: HistoryConfig = field(default_factory=HistoryConfig)
def load_config() -> Config:
"""
Load configuration from file with environment variable overrides.
Priority (highest to lowest):
1. Environment variables (WEBBER_API_URL, WEBBER_API_KEY, WEBBER_MODE)
2. Config file (~/.webber/config.toml)
3. Built-in defaults
Returns:
Config object with merged settings
"""
config = Config()
# Load from file if exists
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, "rb") as f:
data = tomllib.load(f)
config = _parse_config(data)
except Exception:
# If config file is invalid, use defaults
pass
# Apply environment variable overrides
if url := os.environ.get("WEBBER_API_URL"):
config.api.url = url
if key := os.environ.get("WEBBER_API_KEY"):
config.api.key = key
if mode := os.environ.get("WEBBER_MODE"):
config.cli.mode = mode
return config
def _parse_config(data: dict[str, Any]) -> Config:
"""Parse config dict into Config object."""
config = Config()
if api := data.get("api"):
config.api.url = api.get("url", config.api.url)
config.api.key = api.get("key", config.api.key)
if cli := data.get("cli"):
config.cli.mode = cli.get("mode", config.cli.mode)
config.cli.stream = cli.get("stream", config.cli.stream)
if history := data.get("history"):
config.history.file = history.get("file", config.history.file)
return config
def init_config() -> Path:
"""
Initialize config directory and file with defaults.
Returns:
Path to the created config file
"""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
if not CONFIG_FILE.exists():
CONFIG_FILE.write_text(DEFAULT_CONFIG)
return CONFIG_FILE
def get_config_path() -> Path | None:
"""Get path to config file if it exists."""
return CONFIG_FILE if CONFIG_FILE.exists() else None
# Module-level cached config
_config: Config | None = None
def get_config() -> Config:
"""Get cached config, loading if necessary."""
global _config
if _config is None:
_config = load_config()
return _config
+171 -44
View File
@@ -8,7 +8,6 @@ Usage:
webber-cli status
"""
import asyncio
import os
import sys
from pathlib import Path
@@ -22,14 +21,17 @@ from rich.markdown import Markdown
from rich.panel import Panel
from rich.prompt import Confirm
from webber_cli.client import WebberClient, PermissionMode
from webber_cli.client import WebberClient, PermissionMode, StreamEvent, StreamEventType
from webber_cli.config import get_config, init_config, get_config_path, CONFIG_FILE
from webber_cli.theme import get_console, get_theme
# === Prompt Toolkit Setup ===
# History file location
HISTORY_FILE = Path.home() / ".webber_history"
def _get_history_file() -> Path:
"""Get history file path from config."""
config = get_config()
return Path(config.history.file).expanduser()
# Built-in commands for completion
BUILTIN_COMMANDS = [
@@ -87,7 +89,7 @@ PROMPT_STYLE = Style.from_dict({
def create_prompt_session(working_dir: str) -> PromptSession:
"""Create a configured prompt session with history and completion."""
return PromptSession(
history=FileHistory(str(HISTORY_FILE)),
history=FileHistory(str(_get_history_file())),
auto_suggest=AutoSuggestFromHistory(),
completer=WebberCompleter(working_dir),
style=PROMPT_STYLE,
@@ -97,18 +99,22 @@ def create_prompt_session(working_dir: str) -> PromptSession:
app = typer.Typer(
name="webber-cli",
help="CLI client for the Webber API",
no_args_is_help=True,
no_args_is_help=False,
invoke_without_command=True,
add_completion=False,
)
console = get_console()
# Default API URL (can be overridden via env or option)
# Development port is 8095, production is 8086
DEFAULT_API_URL = os.environ.get("WEBBER_API_URL", "http://localhost:8095")
# Default API key for conversation API (dev mode accepts any non-empty key)
DEFAULT_API_KEY = os.environ.get("WEBBER_API_KEY", "webber-cli-dev-key")
def _get_api_url() -> str:
"""Get API URL from config (with env override already applied)."""
return get_config().api.url
def _get_api_key() -> str:
"""Get API key from config (with env override already applied)."""
return get_config().api.key or "webber-cli-dev-key"
def version_callback(value: bool) -> None:
@@ -136,8 +142,9 @@ def _confirm_auto_accept() -> bool:
)
@app.callback()
@app.callback(invoke_without_command=True)
def main(
ctx: typer.Context,
version: bool = typer.Option(
False,
"--version",
@@ -148,16 +155,24 @@ def main(
),
) -> None:
"""Webber CLI - Talk to the Webber API."""
pass
# Default to chat command if no subcommand given
if ctx.invoked_subcommand is None:
chat(
directory=".",
api_url=None,
mode=None,
resume=None,
stream=None,
)
@app.command()
def sessions(
api_url: str = typer.Option(
DEFAULT_API_URL,
None,
"--api",
"-a",
help="Webber API URL",
help="Webber API URL (default from config)",
),
limit: int = typer.Option(
20,
@@ -171,12 +186,13 @@ def sessions(
Shows recent sessions that can be resumed with 'chat --resume <id>'.
"""
asyncio.run(_list_sessions(api_url, limit))
url = api_url or _get_api_url()
asyncio.run(_list_sessions(url, limit))
async def _list_sessions(api_url: str, limit: int) -> None:
"""List conversation sessions."""
async with WebberClient(api_url, api_key=DEFAULT_API_KEY) as client:
async with WebberClient(api_url, api_key=_get_api_key()) as client:
# Check API health
if not await client.health_check():
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}")
@@ -223,16 +239,16 @@ def chat(
help="Working directory for the agent",
),
api_url: str = typer.Option(
DEFAULT_API_URL,
None,
"--api",
"-a",
help="Webber API URL",
help="Webber API URL (default from config)",
),
mode: str = typer.Option(
"default",
None,
"--mode",
"-m",
help="Permission mode: default, plan (read-only), auto_accept (no prompts)",
help="Permission mode: default, plan, auto_accept (default from config)",
),
resume: str = typer.Option(
None,
@@ -241,10 +257,10 @@ def chat(
help="Resume a previous session by ID (use 'sessions' to list)",
),
stream: bool = typer.Option(
True,
None,
"--stream/--no-stream",
"-s",
help="Stream responses in real-time",
help="Stream responses in real-time (default from config)",
),
) -> None:
"""
@@ -263,6 +279,12 @@ def chat(
Use --resume to continue a previous session.
"""
# Resolve config defaults
config = get_config()
url = api_url or config.api.url
mode_str = mode or config.cli.mode
use_stream = stream if stream is not None else config.cli.stream
working_dir = str(Path(directory).resolve())
if not Path(working_dir).exists():
@@ -271,9 +293,9 @@ def chat(
# Parse and validate mode
try:
permission_mode = PermissionMode(mode)
permission_mode = PermissionMode(mode_str)
except ValueError:
console.print(f"[error]Error:[/] Invalid mode: {mode}")
console.print(f"[error]Error:[/] Invalid mode: {mode_str}")
console.print("[dim]Valid modes: default, plan, auto_accept[/]")
raise typer.Exit(1)
@@ -284,7 +306,7 @@ def chat(
permission_mode = PermissionMode.default
try:
asyncio.run(_chat_loop(api_url, working_dir, permission_mode, stream, resume))
asyncio.run(_chat_loop(url, working_dir, permission_mode, use_stream, resume))
except KeyboardInterrupt:
console.print("\n[dim]Goodbye![/]")
@@ -302,7 +324,7 @@ async def _chat_loop(
conversation_id: str | None = None
conversation_title: str | None = None
async with WebberClient(api_url, api_key=DEFAULT_API_KEY) as client:
async with WebberClient(api_url, api_key=_get_api_key()) as client:
# Check API health
if not await client.health_check():
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}")
@@ -449,15 +471,56 @@ async def _chat_loop(
console.print()
if stream:
# Stream response in real-time, buffering for persistence
# Stream response with structured events
response_chunks: list[str] = []
current_tool: str | None = None
try:
async for chunk in client.run_agent_stream(
async for event in client.run_agent_stream(
agent_type, user_input, working_dir, current_mode
):
sys.stdout.write(chunk)
sys.stdout.flush()
response_chunks.append(chunk)
if event.event == StreamEventType.thinking:
# Show thinking status
console.print(f"[dim]{event.message or 'Thinking...'}[/]")
elif event.event == StreamEventType.tool_start:
# Show tool starting
current_tool = event.tool
args_display = ""
if event.args:
# Format key args for display
key_args = []
for k, v in list(event.args.items())[:2]:
v_str = str(v)[:40] + "..." if len(str(v)) > 40 else str(v)
key_args.append(f"{k}={v_str}")
args_display = f" ({', '.join(key_args)})"
console.print(f"[info]→ {event.tool}[/]{args_display}", end="")
elif event.event == StreamEventType.tool_done:
# Show tool completed
result = event.result_summary or "done"
console.print(f" [success]✓[/] [dim]{result}[/]")
current_tool = None
elif event.event == StreamEventType.response:
# Stream response text
if event.text:
sys.stdout.write(event.text)
sys.stdout.flush()
response_chunks.append(event.text)
elif event.event == StreamEventType.chunk:
# Legacy text chunk (for other agents)
if event.text:
sys.stdout.write(event.text)
sys.stdout.flush()
response_chunks.append(event.text)
elif event.event == StreamEventType.error:
console.print(f"\n[error]Error:[/] {event.error_message}")
elif event.event == StreamEventType.done:
pass # Stream complete
console.print() # Newline after streaming
# Save messages to conversation if we have a session
@@ -510,14 +573,15 @@ async def _chat_loop(
@app.command()
def status(
api_url: str = typer.Option(
DEFAULT_API_URL,
None,
"--api",
"-a",
help="Webber API URL",
help="Webber API URL (default from config)",
),
) -> None:
"""Check API status and list available agents."""
asyncio.run(_status(api_url))
url = api_url or _get_api_url()
asyncio.run(_status(url))
async def _status(api_url: str) -> None:
@@ -536,6 +600,54 @@ async def _status(api_url: str) -> None:
console.print("[error]Status:[/] Cannot connect")
@app.command()
def config(
init: bool = typer.Option(
False,
"--init",
"-i",
help="Initialize config file with defaults",
),
) -> None:
"""
Show or initialize configuration.
Without --init, displays current config and source.
With --init, creates ~/.webber/config.toml with defaults.
"""
if init:
path = init_config()
console.print(f"[success]Config initialized:[/] {path}")
console.print("[dim]Edit this file to customize settings.[/]")
return
# Show current config
cfg = get_config()
config_path = get_config_path()
console.print("[title]Webber Configuration[/]\n")
if config_path:
console.print(f"[dim]Config file:[/] {config_path}")
else:
console.print(f"[dim]Config file:[/] [warning]Not found[/] (using defaults)")
console.print(f"[dim]Run 'webber-cli config --init' to create {CONFIG_FILE}[/]")
console.print()
console.print("[info]API Settings[/]")
console.print(f" url: {cfg.api.url}")
console.print(f" key: {'***' if cfg.api.key else '[dim]not set[/]'}")
console.print()
console.print("[info]CLI Settings[/]")
console.print(f" mode: {cfg.cli.mode}")
console.print(f" stream: {cfg.cli.stream}")
console.print()
console.print("[info]History[/]")
console.print(f" file: {cfg.history.file}")
# Keep 'explore' as an alias for 'chat --mode plan' for backwards compatibility
@app.command(hidden=True)
def explore(
@@ -547,16 +659,16 @@ def explore(
help="Working directory",
),
api_url: str = typer.Option(
DEFAULT_API_URL,
None,
"--api",
"-a",
help="Webber API URL",
help="Webber API URL (default from config)",
),
stream: bool = typer.Option(
True,
None,
"--stream/--no-stream",
"-s",
help="Stream responses in real-time",
help="Stream responses in real-time (default from config)",
),
) -> None:
"""
@@ -564,6 +676,11 @@ def explore(
Runs the Task agent in plan (read-only) mode for a single query.
"""
# Resolve config defaults
config = get_config()
url = api_url or config.api.url
use_stream = stream if stream is not None else config.cli.stream
console.print("[dim]Note: 'explore' is deprecated. Use 'chat --mode plan' for interactive mode.[/]")
console.print()
@@ -573,7 +690,7 @@ def explore(
console.print(f"[error]Error:[/] Directory not found: {working_dir}")
raise typer.Exit(1)
asyncio.run(_explore(api_url, query, working_dir, stream))
asyncio.run(_explore(url, query, working_dir, use_stream))
async def _explore(
@@ -596,13 +713,23 @@ async def _explore(
console.print()
if stream:
# Stream response in real-time
# Stream response with structured events
try:
async for chunk in client.run_agent_stream(
async for event in client.run_agent_stream(
"task", query, working_dir, mode
):
sys.stdout.write(chunk)
sys.stdout.flush()
if event.event == StreamEventType.thinking:
console.print(f"[dim]{event.message or 'Thinking...'}[/]")
elif event.event == StreamEventType.tool_start:
console.print(f"[info]→ {event.tool}[/]", end="")
elif event.event == StreamEventType.tool_done:
console.print(f" [success]✓[/] [dim]{event.result_summary or 'done'}[/]")
elif event.event in (StreamEventType.response, StreamEventType.chunk):
if event.text:
sys.stdout.write(event.text)
sys.stdout.flush()
elif event.event == StreamEventType.error:
console.print(f"\n[error]Error:[/] {event.error_message}")
console.print() # Newline after streaming
except Exception as e:
console.print(f"\n[error]Stream error:[/] {e}")
+1
View File
@@ -0,0 +1 @@
This directory contains an application to analyse for the webber coding agent to see what it reports this directory is for. It is to test the llm.