feat: add streaming responses to API and CLI

Add real-time streaming support for agent responses using Server-Sent
Events (SSE). Responses now appear as they're generated instead of
waiting for completion.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 12:58:54 +01:00
co-authored by Claude Opus 4.5
parent d0fa5b38a7
commit f6256363a2
5 changed files with 225 additions and 25 deletions
+48
View File
@@ -3,7 +3,9 @@ Webber API client.
Communicates with the Webber API backend for agent execution.
"""
import json
import httpx
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any
@@ -132,6 +134,52 @@ class WebberClient:
error=data.get("error"),
)
async def run_agent_stream(
self,
agent_type: str,
prompt: str,
working_dir: str = ".",
) -> AsyncIterator[str]:
"""
Run an agent with streaming response.
Args:
agent_type: Type of agent (e.g., "explore")
prompt: User prompt/query
working_dir: Working directory for the agent
Yields:
Text chunks as they arrive
"""
# Use a fresh client for streaming with longer timeout
async with httpx.AsyncClient(
base_url=self.base_url,
timeout=httpx.Timeout(300.0, connect=10.0),
) as client:
async with client.stream(
"POST",
"/agents/stream",
json={
"agent_type": agent_type,
"prompt": prompt,
"working_dir": working_dir,
},
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
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":
break
except json.JSONDecodeError:
continue
async def __aenter__(self) -> "WebberClient":
"""Async context manager entry."""
return self
+72 -25
View File
@@ -9,9 +9,11 @@ Usage:
"""
import asyncio
import os
import sys
from pathlib import Path
import typer
from rich.live import Live
from rich.markdown import Markdown
from rich.panel import Panel
@@ -74,6 +76,12 @@ def chat(
"--agent",
help="Agent to use",
),
stream: bool = typer.Option(
True,
"--stream/--no-stream",
"-s",
help="Stream responses in real-time",
),
) -> None:
"""
Start interactive chat session.
@@ -87,12 +95,14 @@ def chat(
raise typer.Exit(1)
try:
asyncio.run(_chat_loop(api_url, agent, working_dir))
asyncio.run(_chat_loop(api_url, agent, working_dir, stream))
except KeyboardInterrupt:
console.print("\n[dim]Goodbye![/]")
async def _chat_loop(api_url: str, agent_type: str, working_dir: str) -> None:
async def _chat_loop(
api_url: str, agent_type: str, working_dir: str, stream: bool = True
) -> None:
"""Interactive chat loop."""
theme = get_theme()
@@ -118,6 +128,8 @@ async def _chat_loop(api_url: str, agent_type: str, working_dir: str) -> None:
console.print(f"[title]Webber CLI[/] [dim]→ {api_url}[/]")
console.print(f"[dim]Working in:[/] [path]{working_dir}[/]")
console.print(f"[dim]Agent:[/] {agent_info.name} - {agent_info.description}")
mode = "streaming" if stream else "batch"
console.print(f"[dim]Mode:[/] {mode}")
console.print()
console.print("[dim]Type 'exit' to quit, 'clear' to clear screen.[/]")
console.print()
@@ -148,15 +160,31 @@ async def _chat_loop(api_url: str, agent_type: str, working_dir: str) -> None:
console.print(f"[error]Directory not found:[/] {new_dir}")
continue
# Call the API
with console.status("[info]Thinking...[/]", spinner=theme.spinner):
result = await client.run_agent(agent_type, user_input, working_dir)
console.print()
if result.success:
console.print(Markdown(result.response))
if stream:
# Stream response in real-time
full_response = ""
try:
async for chunk in client.run_agent_stream(
agent_type, user_input, working_dir
):
sys.stdout.write(chunk)
sys.stdout.flush()
full_response += chunk
console.print() # Newline after streaming
except Exception as e:
console.print(f"\n[error]Stream error:[/] {e}")
else:
console.print(f"[error]Error:[/] {result.error}")
# Batch mode with spinner
with console.status("[info]Thinking...[/]", spinner=theme.spinner):
result = await client.run_agent(agent_type, user_input, working_dir)
if result.success:
console.print(Markdown(result.response))
else:
console.print(f"[error]Error:[/] {result.error}")
console.print()
except KeyboardInterrupt:
@@ -181,6 +209,12 @@ def explore(
"-a",
help="Webber API URL",
),
stream: bool = typer.Option(
True,
"--stream/--no-stream",
"-s",
help="Stream responses in real-time",
),
) -> None:
"""
One-shot codebase exploration.
@@ -193,10 +227,12 @@ def explore(
console.print(f"[error]Error:[/] Directory not found: {working_dir}")
raise typer.Exit(1)
asyncio.run(_explore(api_url, query, working_dir))
asyncio.run(_explore(api_url, query, working_dir, stream))
async def _explore(api_url: str, query: str, working_dir: str) -> None:
async def _explore(
api_url: str, query: str, working_dir: str, stream: bool = True
) -> None:
"""Execute exploration query."""
theme = get_theme()
@@ -211,21 +247,32 @@ async def _explore(api_url: str, query: str, working_dir: str) -> None:
console.print(f"[dim]Query:[/] {query}")
console.print()
with console.status("[info]Searching...[/]", spinner=theme.spinner):
result = await client.run_agent("explore", query, working_dir)
if result.success:
console.print(Panel(
Markdown(result.response),
title="[success]Findings[/]",
border_style=theme.colors.border_success,
))
if stream:
# Stream response in real-time
try:
async for chunk in client.run_agent_stream("explore", query, working_dir):
sys.stdout.write(chunk)
sys.stdout.flush()
console.print() # Newline after streaming
except Exception as e:
console.print(f"\n[error]Stream error:[/] {e}")
else:
console.print(Panel(
f"[error]{result.error}[/]",
title="[error]Error[/]",
border_style=theme.colors.border_error,
))
# Batch mode with spinner
with console.status("[info]Searching...[/]", spinner=theme.spinner):
result = await client.run_agent("explore", query, working_dir)
if result.success:
console.print(Panel(
Markdown(result.response),
title="[success]Findings[/]",
border_style=theme.colors.border_success,
))
else:
console.print(Panel(
f"[error]{result.error}[/]",
title="[error]Error[/]",
border_style=theme.colors.border_error,
))
@app.command()