Added streaming

This commit is contained in:
vh
2026-04-02 22:47:30 -07:00
parent f8fb8aebdb
commit a282d853a6
6 changed files with 400 additions and 33 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+70 -11
View File
@@ -44,6 +44,13 @@ class ProviderClient:
temperature: float, max_tokens: int) -> str: temperature: float, max_tokens: int) -> str:
return self.call(system, messages, model, temperature, max_tokens) return self.call(system, messages, model, temperature, max_tokens)
async def call_async_streaming(self, system: str, messages: list[dict], model: str,
temperature: float, max_tokens: int):
"""Async generator yielding ('thinking'|'content', text) chunks.
Default: emits the full response as a single 'content' chunk."""
response = await self.call_async(system, messages, model, temperature, max_tokens)
yield ('content', response)
# --- OpenAI and any OpenAI-compatible endpoint --- # --- OpenAI and any OpenAI-compatible endpoint ---
# Covers AIPA_PROVIDER / {AGENT}_PROVIDER = openai or openai_compatible. # Covers AIPA_PROVIDER / {AGENT}_PROVIDER = openai or openai_compatible.
@@ -71,6 +78,29 @@ class OpenAIClient(ProviderClient):
self.call_async(system, messages, model, temperature, max_tokens) self.call_async(system, messages, model, temperature, max_tokens)
) )
async def call_async_streaming(self, system, messages, model, temperature, max_tokens):
"""Stream via the OpenAI-compatible API, yielding thinking and content chunks.
Thinking tokens arrive in delta.reasoning_content (Qwen3, DeepSeek R1 style);
regular tokens arrive in delta.content."""
full_messages = [{"role": "system", "content": system}] + messages
stream = await self.client.chat.completions.create(
model=model,
temperature=temperature,
max_tokens=max_tokens,
messages=full_messages,
extra_body=self.extra_body or None,
stream=True,
)
async for chunk in stream:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
thinking = getattr(delta, 'reasoning_content', None)
if thinking:
yield ('thinking', thinking)
if delta.content:
yield ('content', delta.content)
# --- Anthropic --- # --- Anthropic ---
# Uncomment when ready to use. Install: pip install anthropic # Uncomment when ready to use. Install: pip install anthropic
@@ -283,6 +313,8 @@ async def call_agent_async(
agent: AgentState, agent: AgentState,
user_message: str, user_message: str,
dry_run: bool = False, dry_run: bool = False,
stream: bool = True,
stream_mode: str = "deliverable",
) -> str: ) -> str:
if dry_run: if dry_run:
return f"[DRY RUN] Would call {agent.name} ({config.agent_provider_name(agent.name)}) with: {user_message[:100]}..." return f"[DRY RUN] Would call {agent.name} ({config.agent_provider_name(agent.name)}) with: {user_message[:100]}..."
@@ -290,6 +322,19 @@ async def call_agent_async(
agent.add_user(user_message) agent.add_user(user_message)
try: try:
if stream:
response = await ui.stream_agent_output(
agent.name,
agent.client.call_async_streaming(
system=agent.system_prompt,
messages=agent.messages_for_call(),
model=agent.model,
temperature=agent.temperature,
max_tokens=agent.max_tokens,
),
mode=stream_mode,
)
else:
response = await agent.client.call_async( response = await agent.client.call_async(
system=agent.system_prompt, system=agent.system_prompt,
messages=agent.messages_for_call(), messages=agent.messages_for_call(),
@@ -302,7 +347,7 @@ async def call_agent_async(
agent.add_assistant(response) agent.add_assistant(response)
if config.DEBUG_PRINT_AGENT_OUTPUTS: if config.DEBUG_PRINT_AGENT_OUTPUTS and not stream:
ui.print_agent_panel(agent.name, response) ui.print_agent_panel(agent.name, response)
return response return response
@@ -357,7 +402,7 @@ async def dispatch_to_lead(
if session_id: if session_id:
task_store.update_task(task, session_id) task_store.update_task(task, session_id)
try: try:
output = await call_agent_async(lead, task.brief, dry_run=dry_run) output = await call_agent_async(lead, task.brief, dry_run=dry_run, stream=False)
task.output = output task.output = output
task.status = "complete" task.status = "complete"
except Exception as e: except Exception as e:
@@ -424,7 +469,7 @@ async def run_auditor(
f"{deliverable}" f"{deliverable}"
) )
auditor.history = [] # Auditor is stateless — fresh call each time auditor.history = [] # Auditor is stateless — fresh call each time
return await call_agent_async(auditor, audit_request, dry_run=dry_run) return await call_agent_async(auditor, audit_request, dry_run=dry_run, stream_mode="audit")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -446,7 +491,7 @@ async def update_standing_brief(
f"log entry at the top of Section 9. Archive anything older than " f"log entry at the top of Section 9. Archive anything older than "
f"{config.SESSION_LOG_RETENTION} sessions." f"{config.SESSION_LOG_RETENTION} sessions."
) )
return await call_agent_async(orchestrator, update_request, dry_run=dry_run) return await call_agent_async(orchestrator, update_request, dry_run=dry_run, stream_mode="background")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -502,7 +547,7 @@ class Session:
f"Directive: {directive}" f"Directive: {directive}"
) )
cos_response = await call_agent_async( cos_response = await call_agent_async(
self.orchestrator, cos_prompt, dry_run=self.dry_run self.orchestrator, cos_prompt, dry_run=self.dry_run, stream=True
) )
# ── Step 2: Parse any task briefs the orchestrator issued ─────────── # ── Step 2: Parse any task briefs the orchestrator issued ───────────
@@ -532,9 +577,8 @@ class Session:
f"Please synthesize these into a final deliverable for the Principal.\n\n" f"Please synthesize these into a final deliverable for the Principal.\n\n"
f"{lead_outputs_text}" f"{lead_outputs_text}"
) )
with ui.synthesis_progress():
deliverable = await call_agent_async( deliverable = await call_agent_async(
self.orchestrator, synthesis_prompt, dry_run=self.dry_run self.orchestrator, synthesis_prompt, dry_run=self.dry_run, stream=True
) )
dispatched_leads = sorted({t.assigned_to for t in completed_tasks}) dispatched_leads = sorted({t.assigned_to for t in completed_tasks})
@@ -549,7 +593,6 @@ class Session:
audit_memo = "" audit_memo = ""
if self.auto_audit: if self.auto_audit:
try: try:
with ui.audit_progress():
audit_memo = await run_auditor( audit_memo = await run_auditor(
self.auditor, deliverable, task_id, dry_run=self.dry_run self.auditor, deliverable, task_id, dry_run=self.dry_run
) )
@@ -561,7 +604,6 @@ class Session:
async def close(self): async def close(self):
summary = "\n".join(self.session_log) summary = "\n".join(self.session_log)
with ui.brief_update_progress():
updated_brief = await update_standing_brief( updated_brief = await update_standing_brief(
self.orchestrator, summary, dry_run=self.dry_run self.orchestrator, summary, dry_run=self.dry_run
) )
@@ -645,13 +687,22 @@ async def cmd_history(session: Session, args: str) -> bool:
return True return True
@command(["/thinking"], "Show the thinking output from the last response")
async def cmd_thinking(session: Session, args: str) -> bool:
last = ui.get_last_thinking()
if not last:
ui.print_system("No thinking output recorded — thinking mode may not be active.")
else:
ui.print_thinking_expansion(last)
return True
@command(["/audit"], "Re-run Vera's audit on the last deliverable") @command(["/audit"], "Re-run Vera's audit on the last deliverable")
async def cmd_audit(session: Session, args: str) -> bool: async def cmd_audit(session: Session, args: str) -> bool:
if not session.last_deliverable: if not session.last_deliverable:
ui.print_warning("No deliverable yet — issue a directive first.") ui.print_warning("No deliverable yet — issue a directive first.")
return True return True
ui.print_system("Running audit on last deliverable…") ui.print_system("Running audit on last deliverable…")
with ui.audit_progress():
memo = await run_auditor( memo = await run_auditor(
session.auditor, session.last_deliverable, session.auditor, session.last_deliverable,
session.last_task_id, dry_run=session.dry_run, session.last_task_id, dry_run=session.dry_run,
@@ -731,7 +782,15 @@ async def direct_agent_session(agent_name: str, dry_run: bool = False):
if not user_input or user_input.lower() in ("/quit", "/exit", "/q"): if not user_input or user_input.lower() in ("/quit", "/exit", "/q"):
break break
response = await call_agent_async(agent, user_input, dry_run=dry_run) if user_input.lower() == "/thinking":
last = ui.get_last_thinking()
if not last:
ui.print_system("No thinking output recorded — thinking mode may not be active.")
else:
ui.print_thinking_expansion(last)
continue
response = await call_agent_async(agent, user_input, dry_run=dry_run, stream=True)
ui.print_agent_panel(agent_name, response) ui.print_agent_panel(agent_name, response)
ui.print_session_footer(0) ui.print_session_footer(0)
+311 -3
View File
@@ -23,8 +23,9 @@ from datetime import datetime
import config as _config import config as _config
from rich.console import Console from rich.console import Console, Group
from rich.columns import Columns from rich.columns import Columns
from rich.live import Live
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.panel import Panel from rich.panel import Panel
from rich.progress import ( from rich.progress import (
@@ -75,6 +76,10 @@ def _build_theme() -> Theme:
console = Console(theme=_build_theme(), highlight=False) console = Console(theme=_build_theme(), highlight=False)
_last_thinking: str = "" # Stored from most recent streamed response
_last_stream_rendered: bool = False # True when stream_agent_output rendered the final panel
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Agent visual config # Agent visual config
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -268,7 +273,13 @@ def print_agent_panel(agent_name: str, content: str, task_id: str = ""):
""" """
Render an agent's output in a styled panel. Render an agent's output in a styled panel.
Content is treated as Markdown. Content is treated as Markdown.
Skips printing if stream_agent_output already rendered this response.
""" """
global _last_stream_rendered
if _last_stream_rendered:
_last_stream_rendered = False
return
color = _agent_color(agent_name) color = _agent_color(agent_name)
title_text = _agent_display_title(agent_name) title_text = _agent_display_title(agent_name)
subtitle = f"[dim]{task_id}[/dim]" if task_id else "" subtitle = f"[dim]{task_id}[/dim]" if task_id else ""
@@ -286,15 +297,51 @@ def print_agent_panel(agent_name: str, content: str, task_id: str = ""):
def print_deliverable(content: str, task_id: str = ""): def print_deliverable(content: str, task_id: str = ""):
"""Orchestrator's synthesized deliverable.""" """
print_agent_panel(_config.ORCHESTRATOR_AGENT, content, task_id) Orchestrator's synthesized deliverable.
Skips printing if stream_agent_output already rendered this response.
"""
global _last_stream_rendered
if _last_stream_rendered:
_last_stream_rendered = False
return
_print_deliverable_panel(content, task_id)
def _print_deliverable_panel(content: str, task_id: str = ""):
"""Internal: always render the deliverable panel unconditionally."""
agent_name = _config.ORCHESTRATOR_AGENT
color = _agent_color(agent_name)
title_text = _agent_display_title(agent_name)
subtitle = f"[dim]{task_id}[/dim]" if task_id else ""
panel = Panel(
Markdown(content),
title=f"[{color}] {title_text} [/{color}]",
subtitle=subtitle,
border_style=color,
padding=(1, 2),
box=box.ROUNDED,
)
console.print()
console.print(panel)
def print_audit_memo(content: str, task_id: str = ""): def print_audit_memo(content: str, task_id: str = ""):
""" """
Auditor's memo. Verdict line drives the border colour. Auditor's memo. Verdict line drives the border colour.
PASS = green, PASS WITH NOTES = yellow, FLAG = yellow, REJECT = red. PASS = green, PASS WITH NOTES = yellow, FLAG = yellow, REJECT = red.
Skips printing if stream_agent_output already rendered this response.
""" """
global _last_stream_rendered
if _last_stream_rendered:
_last_stream_rendered = False
return
_print_audit_memo_panel(content, task_id)
def _print_audit_memo_panel(content: str, task_id: str = ""):
"""Internal: always render the audit memo panel with verdict-based border."""
aud_name = _config.AUDITOR_AGENT aud_name = _config.AUDITOR_AGENT
aud_title = _config.agent_title(aud_name) aud_title = _config.agent_title(aud_name)
aud_color = _agent_color(aud_name) aud_color = _agent_color(aud_name)
@@ -390,6 +437,267 @@ def brief_update_progress():
progress.add_task("", total=None) progress.add_task("", total=None)
yield yield
# ---------------------------------------------------------------------------
# Streaming output with thinking support
# ---------------------------------------------------------------------------
def _thinking_panel_title(agent_name: str, mode: str) -> str:
"""Return the thinking panel title, tinted by agent identity."""
if mode == "audit":
color = _agent_color(agent_name)
return f"[{color}]Vera thinking…[/{color}]"
color = _agent_color(agent_name)
return f"[{color} dim]Thinking…[/{color} dim]"
def _build_thinking_renderable(
thinking_text: str,
active: bool,
agent_name: str,
mode: str,
window: int = 300,
) -> object:
"""
Build the thinking panel (active) or collapsed summary line (done).
Active: dim panel with a rolling window of the last `window` chars,
plus an overflow indicator when earlier content has scrolled off.
Collapsed: single dim line — ' ▸ Thinking (N chars, L lines)'.
"""
if not thinking_text:
return None
if active:
overflow = len(thinking_text) - window
if overflow > 0:
preview = thinking_text[-window:]
header = Text(f" +{overflow} chars earlier\n", style="dim italic")
body = Text(preview, style="dim")
content = Group(header, body)
else:
content = Text(thinking_text, style="dim")
return Panel(
content,
title=_thinking_panel_title(agent_name, mode),
border_style="dim",
padding=(0, 1),
box=box.SIMPLE,
)
else:
lines = thinking_text.count("\n") + 1
return Text(
f" ▸ Thinking [{len(thinking_text):,} chars · {lines} lines]",
style="dim",
)
async def stream_agent_output(
agent_name: str,
stream_gen,
mode: str = "deliverable",
) -> str:
"""
Consume an async generator of ('thinking'|'content', text) chunks and
render them live in the terminal.
mode values
-----------
"deliverable" Normal agent response — thinking panel + full styled panel.
The Live display is NOT transient: the final Markdown panel
is rendered inside the Live block, so there is no erase/
reprint flash. Sets _last_stream_rendered = True so that
the caller's print_deliverable / print_agent_panel becomes
a no-op.
"audit" Like "deliverable" but uses Vera's yellow identity cues
during the thinking phase. Sets _last_stream_rendered so
print_audit_memo is a no-op (the verdict-border panel is
rendered here instead).
"background" Housekeeping call (e.g. standing-brief update). A compact
transient progress line is shown; no full panel is printed.
_last_stream_rendered is set so the caller's print_* is a
no-op.
In all modes the full content string is returned and _last_thinking is
updated so /thinking works.
Thinking tokens stream inside a dim panel. When the first content token
arrives the panel collapses to a single dim summary line and content begins
streaming in the agent's normal styled panel.
The full thinking text is stored in _last_thinking and can be retrieved
with get_last_thinking() (exposed to the /thinking command).
Returns the full content string.
"""
global _last_thinking, _last_stream_rendered
color = _agent_color(agent_name)
title_text = _agent_display_title(agent_name)
# ── Background mode: compact transient progress line ────────────────────
if mode == "background":
thinking_parts: list[str] = []
content_parts: list[str] = []
with Progress(
SpinnerColumn(style="dim"),
TextColumn("[dim]Updating standing brief…[/dim]"),
TimeElapsedColumn(),
console=console,
transient=True,
) as prog:
prog.add_task("", total=None)
async for chunk_type, text in stream_gen:
if chunk_type == "thinking":
thinking_parts.append(text)
elif chunk_type == "content":
content_parts.append(text)
_last_thinking = "".join(thinking_parts)
_last_stream_rendered = True
return "".join(content_parts)
# ── Deliverable / audit modes ────────────────────────────────────────────
thinking_parts: list[str] = []
content_parts: list[str] = []
phase = "start" # "start" | "thinking" | "content"
def _build_final_panel(content_text: str) -> Panel:
"""Render the agent's finished output as a polished Markdown panel."""
if mode == "audit":
# Re-derive verdict border from completed content
aud_name = _config.AUDITOR_AGENT
aud_title = _config.agent_title(aud_name)
aud_color = _agent_color(aud_name)
verdict_style = "dim"
for line in content_text.splitlines():
upper = line.upper()
if "VERDICT:" in upper:
if "REJECT" in upper:
verdict_style = "reject"
elif "FLAG" in upper:
verdict_style = "flag"
elif "PASS WITH NOTES" in upper:
verdict_style = "warning"
elif "PASS" in upper:
verdict_style = "pass"
break
return Panel(
Markdown(content_text),
title=f"[{aud_color}] {aud_name.capitalize()} — {aud_title} [/{aud_color}]",
border_style=verdict_style if verdict_style != "dim" else aud_color,
padding=(1, 2),
box=box.ROUNDED,
)
else:
return Panel(
Markdown(content_text),
title=f"[{color}] {title_text} [/{color}]",
border_style=color,
padding=(1, 2),
box=box.ROUNDED,
)
def _renderable(final: bool = False):
"""
Build the live renderable for the current stream state.
final=True: replace plain Text content with Markdown and apply the
verdict-aware border so the Live display ends in its final polished
form. This avoids a transient erase + reprint flash.
"""
thinking_text = "".join(thinking_parts)
content_text = "".join(content_parts)
items: list = []
# ── Agent identity line (shown while only thinking, not yet content) ──
if phase in ("start", "thinking") and not content_text:
items.append(Text(
f" {title_text}",
style=f"dim {color}",
))
# ── Thinking panel / collapsed summary ──────────────────────────────
thinking_active = (phase == "thinking")
think_renderable = _build_thinking_renderable(
thinking_text, thinking_active, agent_name, mode
)
if think_renderable is not None:
items.append(think_renderable)
# ── Content panel ────────────────────────────────────────────────────
if content_text or phase == "content":
if final and content_text:
items.append(_build_final_panel(content_text))
else:
items.append(Panel(
Text(content_text),
title=f"[{color}] {title_text} [/{color}]",
border_style=color,
padding=(1, 2),
box=box.ROUNDED,
))
if not items:
# Nothing yet — show a faint connecting indicator
return Text(
f" [{_agent_color(agent_name)} dim]{title_text} …[/{_agent_color(agent_name)} dim]",
style="dim",
)
return Group(*items) if len(items) > 1 else items[0]
console.print()
# transient=False: the Live display persists — we render the final panel
# inside it before stopping, so there is no erase/reprint cycle.
with Live(
_renderable(),
console=console,
refresh_per_second=15,
transient=False,
vertical_overflow="visible",
) as live:
async for chunk_type, text in stream_gen:
if chunk_type == "thinking":
if phase == "start":
phase = "thinking"
thinking_parts.append(text)
elif chunk_type == "content":
if phase in ("start", "thinking"):
phase = "content"
content_parts.append(text)
live.update(_renderable())
# Final update: replace plain Text with formatted Markdown panel
# (and apply verdict border for audit mode) before the Live context
# exits and the display freezes in place.
live.update(_renderable(final=True))
_last_thinking = "".join(thinking_parts)
_last_stream_rendered = True
return "".join(content_parts)
def get_last_thinking() -> str:
"""Return the thinking text captured from the most recent streamed response."""
return _last_thinking
def print_thinking_expansion(thinking_text: str):
"""Render the stored thinking output in a dim panel."""
panel = Panel(
Text(thinking_text, style="dim"),
title="[dim] Thinking — last response [/dim]",
border_style="dim",
padding=(1, 2),
box=box.ROUNDED,
)
console.print()
console.print(panel)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Dry-run callout # Dry-run callout
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------