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