Added streaming
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,19 +322,32 @@ async def call_agent_async(
|
|||||||
agent.add_user(user_message)
|
agent.add_user(user_message)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await agent.client.call_async(
|
if stream:
|
||||||
system=agent.system_prompt,
|
response = await ui.stream_agent_output(
|
||||||
messages=agent.messages_for_call(),
|
agent.name,
|
||||||
model=agent.model,
|
agent.client.call_async_streaming(
|
||||||
temperature=agent.temperature,
|
system=agent.system_prompt,
|
||||||
max_tokens=agent.max_tokens,
|
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(
|
||||||
|
system=agent.system_prompt,
|
||||||
|
messages=agent.messages_for_call(),
|
||||||
|
model=agent.model,
|
||||||
|
temperature=agent.temperature,
|
||||||
|
max_tokens=agent.max_tokens,
|
||||||
|
)
|
||||||
except NotImplementedError as e:
|
except NotImplementedError as e:
|
||||||
raise RuntimeError(str(e)) from e
|
raise RuntimeError(str(e)) from e
|
||||||
|
|
||||||
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,10 +577,9 @@ 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, stream=True
|
||||||
self.orchestrator, synthesis_prompt, dry_run=self.dry_run
|
)
|
||||||
)
|
|
||||||
|
|
||||||
dispatched_leads = sorted({t.assigned_to for t in completed_tasks})
|
dispatched_leads = sorted({t.assigned_to for t in completed_tasks})
|
||||||
self.session_log.append(
|
self.session_log.append(
|
||||||
@@ -549,10 +593,9 @@ 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
|
)
|
||||||
)
|
|
||||||
self.session_log.append(f"Audit complete for {task_id}.")
|
self.session_log.append(f"Audit complete for {task_id}.")
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
ui.print_warning(f"Audit skipped — {e}")
|
ui.print_warning(f"Audit skipped — {e}")
|
||||||
@@ -561,10 +604,9 @@ 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
|
)
|
||||||
)
|
|
||||||
if not self.dry_run:
|
if not self.dry_run:
|
||||||
save_standing_brief(updated_brief)
|
save_standing_brief(updated_brief)
|
||||||
ui.print_session_footer(self.directive_count)
|
ui.print_session_footer(self.directive_count)
|
||||||
@@ -645,17 +687,26 @@ 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,
|
)
|
||||||
)
|
|
||||||
ui.print_audit_memo(memo, task_id=session.last_task_id)
|
ui.print_audit_memo(memo, task_id=session.last_task_id)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -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
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user