chore: release api v1.0.0
Build and Push API / release (push) Successful in 4s
Build and Push API / build (push) Successful in 2m26s

- Event-based streaming for task agent
- Retry logic when LLM responds without calling tools
- Hardened prompts to enforce tool use
- Working directory context in all agent prompts
- Project paused: local LLMs not capable enough for agentic use

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 07:54:33 +01:00
co-authored by Claude Opus 4.5
parent 6ea520519c
commit d385f47395
13 changed files with 1061 additions and 110 deletions
+60 -8
View File
@@ -26,6 +26,41 @@ class PermissionMode(str, Enum):
auto_accept = "auto_accept"
class StreamEventType(str, Enum):
"""Event types for structured agent streaming."""
tool_start = "tool_start"
tool_done = "tool_done"
thinking = "thinking"
response = "response"
error = "error"
done = "done"
chunk = "chunk" # Legacy text chunk
@dataclass
class StreamEvent:
"""
Structured streaming event from agent execution.
Different event types carry different data:
- tool_start: tool, args
- tool_done: tool, result_summary
- thinking: message
- response: text
- error: error_message
- done: mode
- chunk: text (legacy)
"""
event: StreamEventType
tool: str | None = None
args: dict | None = None
result_summary: str | None = None
message: str | None = None
text: str | None = None
error_message: str | None = None
mode: str | None = None
@dataclass
class AgentResponse:
"""Response from agent execution."""
@@ -202,7 +237,7 @@ class WebberClient:
prompt: str,
working_dir: str = ".",
mode: PermissionMode = PermissionMode.default,
) -> AsyncIterator[str]:
) -> AsyncIterator[StreamEvent]:
"""
Run an agent with streaming response.
@@ -213,7 +248,7 @@ class WebberClient:
mode: Permission mode controlling tool access
Yields:
Text chunks as they arrive
StreamEvent objects as they arrive
"""
# Use a fresh client for streaming with longer timeout
async with httpx.AsyncClient(
@@ -235,13 +270,30 @@ class WebberClient:
if line.startswith("data: "):
try:
data = json.loads(line[6:])
event = data.get("event")
if event == "chunk":
yield data.get("data", "")
elif event == "error":
raise Exception(data.get("data", "Unknown error"))
elif event == "done":
event_type = data.get("event")
# Parse event type
try:
evt_type = StreamEventType(event_type)
except ValueError:
continue # Unknown event type
# Build StreamEvent from response data
yield StreamEvent(
event=evt_type,
tool=data.get("tool"),
args=data.get("args"),
result_summary=data.get("result_summary"),
message=data.get("message"),
text=data.get("text") or data.get("data"), # 'data' for legacy chunk
error_message=data.get("error_message") or data.get("data"),
mode=data.get("mode"),
)
# Stop on done or error
if evt_type in (StreamEventType.done, StreamEventType.error):
break
except json.JSONDecodeError:
continue
+61 -10
View File
@@ -21,7 +21,7 @@ from rich.markdown import Markdown
from rich.panel import Panel
from rich.prompt import Confirm
from webber_cli.client import WebberClient, PermissionMode
from webber_cli.client import WebberClient, PermissionMode, StreamEvent, StreamEventType
from webber_cli.config import get_config, init_config, get_config_path, CONFIG_FILE
from webber_cli.theme import get_console, get_theme
@@ -471,15 +471,56 @@ async def _chat_loop(
console.print()
if stream:
# Stream response in real-time, buffering for persistence
# Stream response with structured events
response_chunks: list[str] = []
current_tool: str | None = None
try:
async for chunk in client.run_agent_stream(
async for event in client.run_agent_stream(
agent_type, user_input, working_dir, current_mode
):
sys.stdout.write(chunk)
sys.stdout.flush()
response_chunks.append(chunk)
if event.event == StreamEventType.thinking:
# Show thinking status
console.print(f"[dim]{event.message or 'Thinking...'}[/]")
elif event.event == StreamEventType.tool_start:
# Show tool starting
current_tool = event.tool
args_display = ""
if event.args:
# Format key args for display
key_args = []
for k, v in list(event.args.items())[:2]:
v_str = str(v)[:40] + "..." if len(str(v)) > 40 else str(v)
key_args.append(f"{k}={v_str}")
args_display = f" ({', '.join(key_args)})"
console.print(f"[info]→ {event.tool}[/]{args_display}", end="")
elif event.event == StreamEventType.tool_done:
# Show tool completed
result = event.result_summary or "done"
console.print(f" [success]✓[/] [dim]{result}[/]")
current_tool = None
elif event.event == StreamEventType.response:
# Stream response text
if event.text:
sys.stdout.write(event.text)
sys.stdout.flush()
response_chunks.append(event.text)
elif event.event == StreamEventType.chunk:
# Legacy text chunk (for other agents)
if event.text:
sys.stdout.write(event.text)
sys.stdout.flush()
response_chunks.append(event.text)
elif event.event == StreamEventType.error:
console.print(f"\n[error]Error:[/] {event.error_message}")
elif event.event == StreamEventType.done:
pass # Stream complete
console.print() # Newline after streaming
# Save messages to conversation if we have a session
@@ -672,13 +713,23 @@ async def _explore(
console.print()
if stream:
# Stream response in real-time
# Stream response with structured events
try:
async for chunk in client.run_agent_stream(
async for event in client.run_agent_stream(
"task", query, working_dir, mode
):
sys.stdout.write(chunk)
sys.stdout.flush()
if event.event == StreamEventType.thinking:
console.print(f"[dim]{event.message or 'Thinking...'}[/]")
elif event.event == StreamEventType.tool_start:
console.print(f"[info]→ {event.tool}[/]", end="")
elif event.event == StreamEventType.tool_done:
console.print(f" [success]✓[/] [dim]{event.result_summary or 'done'}[/]")
elif event.event in (StreamEventType.response, StreamEventType.chunk):
if event.text:
sys.stdout.write(event.text)
sys.stdout.flush()
elif event.event == StreamEventType.error:
console.print(f"\n[error]Error:[/] {event.error_message}")
console.print() # Newline after streaming
except Exception as e:
console.print(f"\n[error]Stream error:[/] {e}")