Files
EvoScientist-Multi/EvoScientist/stream/display.py
T
jfilipiuk 3cda9894c7 feat: agent-teams part C - expert selection UX (depends on part B) (#371)
* feat: bias main-agent delegation toward configurable.active_teams

* feat: add /experts and /expert TUI commands for expert-skill summoning

* feat: align expert-selection wording with WebUI (invite/dismiss)

* chore: clear active_teams on /new, cleanup active_team.py comment

* fix: use local append_to_system_message in ActiveTeamMiddleware

* fix: prevent configurable_extra from overriding thread_id

* fix: cache expert-skill lookup for /expert completions

* fix: suppress /expert completions past first arg and on exact match

* fix: invalidate /expert completion cache on skill install/uninstall

* fix: refuse /expert invites for non-dispatchable expert skills

* fix: fire /expert cache invalidation on every install_skill / uninstall_skill path

* fix: propagate active_teams to Rich CLI and serve dispatch surfaces

* fix: keep invited experts across channel shutdown

* fix: match /expert completions case-insensitively
2026-08-07 17:07:01 +01:00

1961 lines
72 KiB
Python

"""Rich display functions for streaming CLI output.
Contains all rendering logic: tool call lines, sub-agent sections,
todo panels, streaming display layout, and final results display.
Also provides the shared console and formatter globals.
"""
import asyncio
import concurrent.futures
import inspect
import logging
import os
import re
import threading
from collections.abc import AsyncIterator, Callable, Iterator
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, TypeVar
from rich.console import Group # type: ignore[import-untyped]
from rich.live import Live # type: ignore[import-untyped]
from rich.markdown import Markdown # type: ignore[import-untyped]
from rich.panel import Panel # type: ignore[import-untyped]
from rich.spinner import Spinner # type: ignore[import-untyped]
from rich.text import Text # type: ignore[import-untyped]
from ..cancellation import bind_cancel_event
from ..gateway import GraphGateway, GraphRunInput, GraphTarget, RunRequest
from ..paths import resolve_virtual_path
from ..runtime import AsyncRuntime, RuntimeHandle
from .console import console
from .diff_format import build_edit_diff
from .formatter import ToolResultFormatter
from .state import (
StreamState,
SubAgentState,
_build_todo_stats,
_parse_todo_items,
)
from .utils import (
DisplayLimits,
ToolStatus,
format_tool_compact,
format_tool_compact_with_result,
is_success,
)
if TYPE_CHECKING:
from langgraph.graph.state import CompiledStateGraph
# ---------------------------------------------------------------------------
# Shared globals
# ---------------------------------------------------------------------------
# Media file extensions that should trigger on_file_write callback
_MEDIA_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".pdf"}
_T = TypeVar("_T")
_QuestionRunner = Callable[[Any], Any]
class _StreamPromptCancelled(Exception):
"""Internal control flow for an owned terminal prompt cancellation."""
def _update_final_live_frame(
live: Any,
final_display: Any,
stream_handle: RuntimeHandle[Any] | None,
) -> None:
"""Render the final frame unless this owned stream was cancelled."""
if stream_handle is not None and stream_handle.cancelled():
return
live.update(final_display)
live.refresh()
def _graph_target_for_local_agent(
agent: "CompiledStateGraph",
metadata: dict[str, object] | None = None,
) -> GraphTarget:
workspace = None
if metadata is not None:
raw_workspace = metadata.get("workspace_dir")
if isinstance(raw_workspace, str) and raw_workspace:
workspace = raw_workspace
return GraphTarget(local_graph=agent, workspace_dir=workspace)
# LLM output sometimes omits the CommonMark-required space after `#` (e.g.
# "###文件系统"), which makes Rich render the line as raw text. The lookahead
# `(?=[^ \t#\r\n])` requires a real non-excluded next char, so the helper is
# idempotent and leaves bare `#` at EOS / CRLF boundaries alone.
_HEADING_FIX_RE = re.compile(r"^(#{1,6})(?=[^ \t#\r\n])", flags=re.MULTILINE)
def _fix_markdown_heading_spacing(text: str) -> str:
"""Insert a space after `#`+ ATX heading markers missing one.
Apply to a display copy only — never write the result back into the
streaming buffer. Known limitation: `###define` at column zero inside
a fenced code block still gets a space (context-free regex).
"""
return _HEADING_FIX_RE.sub(r"\1 ", text)
def _split_response_for_display(
response_text: str,
narrated_response_end: int,
) -> tuple[str, str]:
"""Split cumulative response text into narrated prefix and answer suffix."""
boundary = max(0, min(len(response_text), narrated_response_end))
return response_text[:boundary], response_text[boundary:]
def _clean_response_text(text: str) -> str:
"""Trim a streamed response copy for display."""
clean = text.strip()
while clean.endswith("\n...") or clean.rstrip() == "...":
clean = clean.rstrip().removesuffix("...").rstrip()
return clean
def _response_markdown_for_display(
text: str,
*,
response_markdown: Any = None,
full_response_text: str = "",
) -> Any | None:
"""Build Markdown for the answer text, reusing the full-response cache if valid."""
clean = _clean_response_text(text)
if not clean:
return None
if response_markdown is not None and text == full_response_text:
return response_markdown
return Markdown(_fix_markdown_heading_spacing(clean))
formatter = ToolResultFormatter()
# Stream-cancel events keyed by logical stream scope. Channel messages pass a
# per-message scope so `/stop` only affects that message's run; scope-less
# callers retain the legacy process-wide default event.
_DEFAULT_STREAM_CANCEL_SCOPE = "__default__"
# Serve signal handlers may request cancellation while the main thread is in a
# scoped cancellation lookup. Re-entrancy prevents the Python signal callback
# from deadlocking if it interrupts one of these short registry sections.
_stream_cancel_lock = threading.RLock()
_stream_cancel_events: dict[str, threading.Event] = {
_DEFAULT_STREAM_CANCEL_SCOPE: threading.Event()
}
# The flag remains useful for cancellation requested before a stream starts and
# at synchronous HITL boundaries. Active Rich streams additionally register
# their owned-runtime handle so a request can interrupt a blocked ``__anext__``
# immediately instead of waiting for the model to emit another event.
_stream_cancel_handles: dict[str, set[RuntimeHandle[Any]]] = {}
# Backward-compat alias used by older tests and direct imports.
_stream_cancel_event = _stream_cancel_events[_DEFAULT_STREAM_CANCEL_SCOPE]
def _stream_cancel_scope_key(cancel_scope: str | None) -> str:
return cancel_scope or _DEFAULT_STREAM_CANCEL_SCOPE
def _get_stream_cancel_event(
cancel_scope: str | None,
*,
create: bool = False,
) -> threading.Event | None:
scope_key = _stream_cancel_scope_key(cancel_scope)
with _stream_cancel_lock:
event = _stream_cancel_events.get(scope_key)
if event is None and create:
event = threading.Event()
_stream_cancel_events[scope_key] = event
return event
def request_stream_cancel(cancel_scope: str | None = None) -> bool:
"""Signal a stream and directly cancel any active owned coroutine."""
scope_key = _stream_cancel_scope_key(cancel_scope)
with _stream_cancel_lock:
event = _stream_cancel_events.get(scope_key)
if event is None:
event = threading.Event()
_stream_cancel_events[scope_key] = event
already_requested = event.is_set()
event.set()
handles = tuple(_stream_cancel_handles.get(scope_key, ()))
# Future.cancel() is thread-safe. Do it outside the registry lock because
# cancellation callbacks may settle quickly and unregister the handle.
for handle in handles:
handle.cancel()
# Sync tools may remain active in an executor after their awaiting graph
# task is cancelled, so stop their owned subprocesses explicitly.
from ..backends import cancel_active_shell_processes
cancel_active_shell_processes(event)
return not already_requested
def _register_stream_cancel_handle(
cancel_scope: str | None,
handle: RuntimeHandle[Any],
) -> None:
"""Register an active owned task, honoring a pre-start stop request."""
scope_key = _stream_cancel_scope_key(cancel_scope)
with _stream_cancel_lock:
handles = _stream_cancel_handles.setdefault(scope_key, set())
handles.add(handle)
event = _stream_cancel_events.get(scope_key)
cancel_now = event is not None and event.is_set()
if cancel_now:
handle.cancel()
def _unregister_stream_cancel_handle(
cancel_scope: str | None,
handle: RuntimeHandle[Any],
) -> None:
scope_key = _stream_cancel_scope_key(cancel_scope)
with _stream_cancel_lock:
handles = _stream_cancel_handles.get(scope_key)
if handles is None:
return
handles.discard(handle)
if not handles:
_stream_cancel_handles.pop(scope_key, None)
def _run_owned_questionary_prompt(
question: Any,
*,
runtime: AsyncRuntime,
cancel_scope: str | None,
) -> Any:
"""Run a terminal prompt as owned async work so stop can cancel it."""
prompt_handle: RuntimeHandle[Any] | None = None
def _register(handle: RuntimeHandle[Any]) -> None:
nonlocal prompt_handle
prompt_handle = handle
_register_stream_cancel_handle(cancel_scope, handle)
try:
return runtime.run_sync(question.ask_async, on_submitted=_register)
except concurrent.futures.CancelledError as exc:
if is_stream_cancel_requested(cancel_scope):
raise _StreamPromptCancelled from exc
raise
finally:
if prompt_handle is not None:
_unregister_stream_cancel_handle(cancel_scope, prompt_handle)
def is_stream_cancel_requested(cancel_scope: str | None = None) -> bool:
event = _get_stream_cancel_event(cancel_scope)
return event.is_set() if event is not None else False
def clear_stream_cancel(cancel_scope: str | None = None) -> None:
"""Clear a scope's stop signal without dropping the scope entry."""
event = _get_stream_cancel_event(cancel_scope)
if event is not None:
event.clear()
@contextmanager
def bind_stream_cancel(cancel_scope: str | None = None) -> Iterator[None]:
"""Bind a stream's stop event for cancellation-aware blocking tools."""
event = _get_stream_cancel_event(cancel_scope, create=True)
assert event is not None
with bind_cancel_event(event):
yield
async def iter_with_stream_cancel(
events: AsyncIterator[_T],
cancel_scope: str | None = None,
) -> AsyncIterator[_T]:
"""Iterate graph events with the matching blocking-tool cancel context."""
iterator = aiter(events)
try:
while True:
try:
# ContextVar tokens cannot safely straddle ``yield``: async
# generator finalization may run in a different task/context.
# The cancellation binding is only needed while requesting the
# next graph event, which includes any nested tool execution.
with bind_stream_cancel(cancel_scope):
event = await anext(iterator)
except StopAsyncIteration:
return
yield event
finally:
aclose = getattr(iterator, "aclose", None)
if aclose is not None:
with bind_stream_cancel(cancel_scope):
await aclose()
def discard_stream_cancel(cancel_scope: str | None = None) -> None:
"""Drop a scope's stop signal after the owning request is fully done."""
scope_key = _stream_cancel_scope_key(cancel_scope)
with _stream_cancel_lock:
if scope_key == _DEFAULT_STREAM_CANCEL_SCOPE:
_stream_cancel_events[scope_key].clear()
else:
_stream_cancel_events.pop(scope_key, None)
def build_stopped_response_text(previous_text: str | None) -> tuple[str, str]:
"""Normalize a cancelled response and return `(trimmed_previous, final_text)`."""
marker = "[Stopped.]"
current = (previous_text or "").rstrip()
if not current:
final_text = marker
elif current.endswith(marker):
final_text = current
else:
final_text = f"{current}\n{marker}"
return current, final_text
# ---------------------------------------------------------------------------
# Todo formatting
# ---------------------------------------------------------------------------
def _format_single_todo(item: dict) -> Text:
"""Format a single todo item with status symbol."""
status = str(item.get("status", "todo")).lower()
content_text = str(item.get("content", item.get("task", item.get("title", ""))))
if status in ("done", "completed", "complete"):
symbol = "\u2713"
label = "done "
style = "green dim"
elif status in ("active", "in_progress", "in-progress", "working"):
symbol = "\u25cf"
label = "active"
style = "yellow"
else:
symbol = "\u25cb"
label = "todo "
style = "dim"
line = Text()
line.append(f" {symbol} ", style=style)
line.append(label, style=style)
line.append(" ", style="dim")
# Truncate long content
if len(content_text) > 60:
content_text = content_text[:57] + "\u2026"
line.append(content_text, style=style)
return line
# ---------------------------------------------------------------------------
# Tool result formatting
# ---------------------------------------------------------------------------
def format_tool_result_compact(
_name: str,
content: str,
max_lines: int = 5,
tool_args: dict | None = None,
) -> list:
"""Format tool result as tree output.
Special handling for write_todos: shows formatted checklist with status symbols.
Special handling for edit_file: shows color-coded unified diff.
"""
elements = []
if not content.strip():
elements.append(Text(" \u2514 (empty)", style="dim"))
return elements
# Special handling for edit_file: show diff
if _name == "edit_file" and tool_args and is_success(content):
old_str = tool_args.get("old_string", "")
new_str = tool_args.get("new_string", "")
path = tool_args.get("path", tool_args.get("file_path", ""))
if old_str and new_str and old_str != new_str:
diff_markup = build_edit_diff(path, old_str, new_str)
if diff_markup:
elements.append(Text.from_markup(diff_markup))
return elements
# Special handling for write_todos
if _name == "write_todos":
items = _parse_todo_items(content)
if items:
stats = _build_todo_stats(items)
stats_line = Text()
stats_line.append(" \u2514 ", style="dim")
stats_line.append(stats, style="dim")
elements.append(stats_line)
elements.append(Text("", style="dim")) # blank line
max_preview = 4
for item in items[:max_preview]:
elements.append(_format_single_todo(item))
remaining = len(items) - max_preview
if remaining > 0:
elements.append(Text(f" ... {remaining} more", style="dim italic"))
return elements
lines = content.strip().split("\n")
total_lines = len(lines)
display_lines = lines[:max_lines]
for i, line in enumerate(display_lines):
prefix = "\u2514" if i == 0 else " "
if len(line) > 80:
line = line[:77] + "\u2026"
style = "dim" if is_success(content) else "red dim"
elements.append(Text(f" {prefix} {line}", style=style))
remaining = total_lines - max_lines
if remaining > 0:
elements.append(Text(f" ... +{remaining} lines", style="dim italic"))
return elements
# ---------------------------------------------------------------------------
# Tool call line rendering
# ---------------------------------------------------------------------------
def _render_tool_call_line(tc: dict, tr: dict | None) -> Text:
"""Render a single tool call line with status indicator."""
is_task = tc.get("name", "").lower() == "task"
if tr is not None:
content = tr.get("content", "")
if is_success(content):
style = "bold green"
indicator = "\u2713" if is_task else ToolStatus.SUCCESS.value
else:
style = "bold red"
indicator = "\u2717" if is_task else ToolStatus.ERROR.value
else:
style = "bold yellow" if not is_task else "bold cyan"
indicator = "\u25b6" if is_task else ToolStatus.RUNNING.value
tool_compact = format_tool_compact_with_result(
tc["name"],
tc.get("args"),
tr.get("content", "") if tr is not None else "",
)
tool_text = Text()
tool_text.append(f"{indicator} ", style=style)
tool_text.append(tool_compact, style=style)
return tool_text
def _tool_result_for_call(
tool_results: list,
tool_call: dict,
) -> dict | None:
"""Match root tool results by DeepAgents tool_call_id."""
tool_id = tool_call["id"]
return next(
(result for result in tool_results if result["id"] == tool_id),
None,
)
# ---------------------------------------------------------------------------
# Sub-agent section rendering
# ---------------------------------------------------------------------------
def _render_subagent_section(sa: "SubAgentState", compact: bool = False) -> list:
"""Render a sub-agent's activity as a bordered section.
Args:
sa: Sub-agent state to render
compact: If True, render minimal 1-line summary (completed sub-agents)
Header uses "Cooking with {name}" style matching task tool format.
Active sub-agents show bordered tool list; completed ones collapse to 1 line.
"""
elements = []
BORDER = "dim cyan" if sa.is_active else "dim"
# Filter out tool calls with empty names
valid_calls = [tc for tc in sa.tool_calls if tc.get("name")]
# Split into completed and pending
completed = []
pending = []
for tc in valid_calls:
tr = sa.get_result_for(tc)
if tr is not None:
completed.append((tc, tr))
else:
pending.append(tc)
# Build display name
display_name = f"Cooking with {sa.name}"
if sa.description:
desc = sa.description.split("\n")[0].strip()
desc = desc[:50] + "\u2026" if len(desc) > 50 else desc
display_name += f" \u2014 {desc}"
# --- Compact mode: 1-line summary for completed sub-agents ---
if compact:
line = Text()
if not sa.is_active:
line.append("\u2713 ", style="green")
line.append(display_name, style="green dim")
total = len(valid_calls)
line.append(f" ({total} tools)", style="dim")
else:
line.append("\u25b6 ", style="cyan")
line.append(display_name, style="bold cyan")
elements.append(line)
return elements
# --- Full mode: bordered section for Live streaming ---
MAX_SA_VISIBLE = 3 # max completed tools shown
MAX_SA_RUNNING = 2 # max running tools shown
# Header
header = Text()
header.append("\u250c ", style=BORDER)
if sa.is_active:
header.append(f"\u25b6 {display_name}", style="bold cyan")
else:
header.append(f"\u2713 {display_name}", style="bold green")
elements.append(header)
# Completed tools — collapse older ones into a summary
slots = max(0, MAX_SA_VISIBLE - len(pending))
hidden = (
completed[:-slots]
if slots and len(completed) > slots
else (completed if not slots else [])
)
visible = completed[-slots:] if slots else []
if hidden:
ok = sum(1 for _, tr in hidden if tr.get("success", True))
fail = len(hidden) - ok
summary = Text("\u2502 ", style=BORDER)
summary.append(f"\u2713 {ok} completed", style="dim green")
if fail > 0:
summary.append(f" | {fail} failed", style="dim red")
elements.append(summary)
for tc, tr in visible:
tc_line = Text("\u2502 ", style=BORDER)
tc_name = format_tool_compact(tc["name"], tc.get("args"))
if tr.get("success", True):
tc_line.append(f"\u2713 {tc_name}", style="green")
else:
tc_line.append(f"\u2717 {tc_name}", style="red")
content = tr.get("content", "")
first_line = content.strip().split("\n")[0][:70]
if first_line:
err_line = Text("\u2502 ", style=BORDER)
err_line.append(f"\u2514 {first_line}", style="red dim")
elements.append(tc_line)
elements.append(err_line)
continue
elements.append(tc_line)
# Pending/running tools — limit visible
hidden_running = len(pending) - MAX_SA_RUNNING
if hidden_running > 0:
run_summary = Text("\u2502 ", style=BORDER)
run_summary.append(
f"\u25cf {hidden_running} more running...", style="dim yellow"
)
elements.append(run_summary)
pending = pending[-MAX_SA_RUNNING:]
for tc in pending:
tc_line = Text("\u2502 ", style=BORDER)
tc_name = format_tool_compact(tc["name"], tc.get("args"))
tc_line.append(f"\u25cf {tc_name}", style="bold yellow")
elements.append(tc_line)
spinner_line = Text("\u2502 ", style=BORDER)
spinner_line.append("\u21bb running...", style="yellow dim")
elements.append(spinner_line)
# Footer
if not sa.is_active:
total = len(valid_calls)
footer = Text(f"\u2514 done ({total} tools)", style="dim green")
elements.append(footer)
elif valid_calls:
footer = Text("\u2514 running...", style="dim cyan")
elements.append(footer)
return elements
# ---------------------------------------------------------------------------
# Todo panel
# ---------------------------------------------------------------------------
def _render_todo_panel(todo_items: list[dict]) -> Panel:
"""Render a bordered Task List panel from todo items.
Matches the style: cyan border, status icons per item.
"""
lines = Text()
for i, item in enumerate(todo_items):
if i > 0:
lines.append("\n")
status = str(item.get("status", "todo")).lower()
content_text = str(item.get("content", item.get("task", item.get("title", ""))))
if status in ("done", "completed", "complete"):
symbol = "\u2713" # checkmark
style = "green dim"
elif status in ("active", "in_progress", "in-progress", "working"):
symbol = "\u23f3" # hourglass
style = "yellow"
else:
symbol = "\u25a1" # empty square
style = "dim"
lines.append(f"{symbol} ", style=style)
lines.append(content_text, style=style)
return Panel(
lines,
title="Task List",
title_align="center",
border_style="cyan",
padding=(0, 1),
)
# ---------------------------------------------------------------------------
# Streaming display layout
# ---------------------------------------------------------------------------
def create_streaming_display(
thinking_text: str = "",
response_text: str = "",
latest_text: str = "",
tool_calls: list | None = None,
tool_results: list | None = None,
is_thinking: bool = False,
is_responding: bool = False,
is_waiting: bool = False,
is_processing: bool = False,
show_thinking: bool = True,
subagents: list | None = None,
todo_items: list | None = None,
is_final: bool = False,
final_show_thinking: bool = False,
final_thinking_max_length: int = DisplayLimits.THINKING_FINAL,
response_markdown: Any = None,
narrated_response_end: int = 0,
narration_segments: list[tuple[int, str]] | None = None,
total_input_tokens: int = 0,
total_output_tokens: int = 0,
summarization_text: str = "",
is_summarizing: bool = False,
selected_tools: list | None = None,
status_footer: Any | None = None,
) -> Any:
"""Create Rich display layout for streaming output.
Returns:
Rich Group for Live display
"""
elements = []
tool_calls = tool_calls or []
tool_results = tool_results or []
subagents = subagents or []
# Initial waiting state
if is_waiting and not thinking_text and not response_text and not tool_calls:
elements.append(Spinner("dots", text=" Thinking...", style="cyan"))
if status_footer is not None:
elements.append(status_footer)
return Group(*elements)
# Thinking panel
# ``final_show_thinking`` controls the final-frame layout, but it must
# never override the caller's user-level visibility setting.
_show_thinking = show_thinking and (final_show_thinking if is_final else True)
if _show_thinking and thinking_text:
thinking_title = "Thinking"
display_thinking = thinking_text.rstrip()
if is_final:
# Final frame: middle-elision truncation
if len(display_thinking) > final_thinking_max_length:
half = final_thinking_max_length // 2
display_thinking = (
display_thinking[:half]
+ "\n\n... (truncated) ...\n\n"
+ display_thinking[-half:]
)
else:
if is_thinking:
thinking_title += " ..."
if len(display_thinking) > DisplayLimits.THINKING_STREAM:
display_thinking = (
"..." + display_thinking[-DisplayLimits.THINKING_STREAM :]
)
elements.append(
Panel(
Text(display_thinking, style="dim"),
title=thinking_title,
border_style="blue",
padding=(0, 1),
)
)
# Selected tools panel (from LLMToolSelectorMiddleware)
if selected_tools:
tools_str = ", ".join(selected_tools)
elements.append(
Panel(
Text(tools_str, style="cyan"),
title=f"Adaptive Selected Tools ({len(selected_tools)})",
border_style="#2d7d46",
padding=(0, 1),
)
)
# Summarization panel (context was compressed by LangGraph middleware)
if is_summarizing and not summarization_text:
elements.append(
Panel(
Text("Summarizing...", style="dim italic"),
title="Context Summarizing...",
border_style="#f59e0b",
padding=(0, 1),
)
)
elif summarization_text:
summary_display = summarization_text.rstrip()
n = len(summary_display)
char_label = f"{n / 1000:.1f}k chars" if n >= 1000 else f"{n:,} chars"
if n > 300:
summary_display = summary_display[:300] + " ..."
title = (
f"Context Summarizing... ({char_label})"
if is_summarizing
else f"Context Summarized ({char_label})"
)
elements.append(
Panel(
Text(summary_display, style="dim italic"),
title=title,
border_style="#f59e0b",
padding=(0, 1),
)
)
# Response text handling: keep the final answer behind pending tool calls.
_n_tools = len(tool_calls)
_n_done = min(len(tool_results), _n_tools)
has_pending_tools = _n_tools > _n_done
any_active_subagent = any(sa.is_active for sa in subagents)
is_processing_blocking = is_processing
all_done = (
not has_pending_tools and not any_active_subagent and not is_processing_blocking
)
_, answer_text = _split_response_for_display(
response_text,
narrated_response_end,
)
narration_by_tool: dict[int, list[str]] = {}
for tool_index, text in narration_segments or []:
if text.strip():
narration_by_tool.setdefault(tool_index, []).append(text)
def _append_narration_before_tool(tool_index: int) -> None:
for text in narration_by_tool.get(tool_index, []):
narration_markdown = _response_markdown_for_display(text)
if narration_markdown is not None:
elements.append(Text("")) # blank separator
elements.append(narration_markdown)
def _find_task_subagent(tc: dict, shown_sa_ids: set[str]) -> SubAgentState | None:
tool_id = tc["id"]
for sa in subagents:
if sa.instance_id in shown_sa_ids:
continue
if sa.parent_tool_call_id == tool_id:
return sa
return None
def _append_task_entry(
tool_index: int,
tc: dict,
tr: dict | None,
*,
shown_sa_ids: set[str],
compact: bool,
) -> None:
_append_narration_before_tool(tool_index)
elements.append(_render_tool_call_line(tc, tr))
matched_sa = _find_task_subagent(tc, shown_sa_ids)
if matched_sa is not None:
shown_sa_ids.add(matched_sa.instance_id)
elements.extend(_render_subagent_section(matched_sa, compact=compact))
# Tool calls and results paired display
# Collapse older completed tools to prevent overflow in Live mode
# Task tool calls are ALWAYS visible (they represent sub-agent delegations)
MAX_VISIBLE_TOOLS = 4
MAX_VISIBLE_RUNNING = 3
shown_sa_ids: set[str] = set()
if tool_calls:
# Split into categories
completed_regular = [] # completed non-task tools
task_tools = [] # task tools (always visible)
running_regular = [] # running non-task tools
for i, tc in enumerate(tool_calls):
tr = _tool_result_for_call(tool_results, tc)
has_result = tr is not None
is_task = tc.get("name") == "task"
if is_task:
task_tools.append((i, tc, tr))
elif has_result:
completed_regular.append((i, tc, tr))
else:
running_regular.append((i, tc, None))
if is_final:
# Final frame: show ALL tools expanded, no spinners, no collapsing
for tool_index, tc, tr in sorted(
completed_regular + running_regular + task_tools,
key=lambda item: item[0],
):
if tc.get("name") == "task":
_append_task_entry(
tool_index,
tc,
tr,
shown_sa_ids=shown_sa_ids,
compact=True,
)
continue
_append_narration_before_tool(tool_index)
elements.append(_render_tool_call_line(tc, tr))
content = tr.get("content", "") if tr else ""
if tr and (not is_success(content) or tc.get("name") == "edit_file"):
result_elements = format_tool_result_compact(
tr["name"],
content,
max_lines=10,
tool_args=tc.get("args"),
)
elements.extend(result_elements)
# Render any sub-agents not already shown via task tool calls
for sa in subagents:
if sa.instance_id not in shown_sa_ids and (
sa.tool_calls or sa.is_active
):
elements.extend(_render_subagent_section(sa, compact=True))
else:
# Streaming mode: collapse older tools, show spinners
# --- Completed regular tools (collapsible) ---
slots = max(0, MAX_VISIBLE_TOOLS - len(running_regular))
hidden = (
completed_regular[:-slots]
if slots and len(completed_regular) > slots
else (completed_regular if not slots else [])
)
visible = completed_regular[-slots:] if slots else []
if hidden:
for tool_index, _, _ in hidden:
_append_narration_before_tool(tool_index)
ok = sum(1 for _, _, tr in hidden if is_success(tr.get("content", "")))
fail = len(hidden) - ok
summary = Text()
summary.append(f"\u2713 {ok} completed", style="dim green")
if fail > 0:
summary.append(f" | {fail} failed", style="dim red")
elements.append(summary)
# --- Running regular tools (limit visible) ---
hidden_running = len(running_regular) - MAX_VISIBLE_RUNNING
if hidden_running > 0:
hidden_running_tools = running_regular[:-MAX_VISIBLE_RUNNING]
for tool_index, _, _ in hidden_running_tools:
_append_narration_before_tool(tool_index)
summary = Text()
summary.append(
f"\u25cf {hidden_running} more running...", style="dim yellow"
)
elements.append(summary)
running_regular = running_regular[-MAX_VISIBLE_RUNNING:]
for tool_index, tc, tr in sorted(
visible + running_regular + task_tools,
key=lambda item: item[0],
):
if tc.get("name") == "task":
matched_sa = _find_task_subagent(tc, shown_sa_ids)
_append_task_entry(
tool_index,
tc,
tr,
shown_sa_ids=shown_sa_ids,
compact=not matched_sa.is_active if matched_sa else True,
)
continue
_append_narration_before_tool(tool_index)
elements.append(_render_tool_call_line(tc, tr))
if tr is None:
elements.append(Spinner("dots", text=" Running...", style="yellow"))
continue
content = tr.get("content", "")
if not is_success(content) or tc.get("name") == "edit_file":
result_elements = format_tool_result_compact(
tr["name"],
content,
max_lines=5,
tool_args=tc.get("args"),
)
elements.extend(result_elements)
# Remaining sub-agent sections are rendered below.
if is_final:
# Final frame: render todo panel + response (tools/subagents handled above).
# Skip narration, spinners — but KEEP response so it persists on screen
# when Live exits (transient=False).
todo_items = todo_items or []
if todo_items:
elements.append(Text("")) # blank separator
elements.append(_render_todo_panel(todo_items))
answer_markdown = _response_markdown_for_display(
answer_text,
response_markdown=response_markdown,
full_response_text=response_text,
)
if answer_markdown is not None:
elements.append(Text("")) # blank separator
elements.append(answer_markdown)
# Token usage stats (right-aligned)
if total_input_tokens or total_output_tokens:
stats = Text(justify="right")
stats.append("[", style="dim italic")
stats.append("Usage: ", style="dim italic")
stats.append(f"{total_input_tokens:,}", style="cyan italic")
stats.append(" in · ", style="dim italic")
stats.append(f"{total_output_tokens:,}", style="green italic")
stats.append(" out", style="dim italic")
stats.append("]", style="dim italic")
elements.append(stats)
else:
# Task List panel (persistent, updates on write_todos / read_todos)
todo_items = todo_items or []
if todo_items:
elements.append(Text("")) # blank separator
elements.append(_render_todo_panel(todo_items))
# Sub-agent activity sections
# Active: full bordered view; Completed: compact 1-line summary
for sa in subagents:
if sa.instance_id not in shown_sa_ids and (sa.tool_calls or sa.is_active):
elements.extend(_render_subagent_section(sa, compact=not sa.is_active))
# Processing state after tool execution
if is_processing and not is_thinking and not is_responding:
# Check if any sub-agent is active
any_active = any(sa.is_active for sa in subagents)
if not any_active:
elements.append(
Spinner("dots", text=" Analyzing results...", style="cyan")
)
# Stream response in real-time as tokens arrive (all tools done)
if response_text and all_done:
answer_markdown = _response_markdown_for_display(
answer_text,
response_markdown=response_markdown,
full_response_text=response_text,
)
if answer_markdown is not None:
elements.append(Text("")) # blank separator
elements.append(answer_markdown)
if not elements:
elements.append(Spinner("dots", text=" Processing...", style="cyan"))
if status_footer is not None:
elements.append(status_footer)
return Group(*elements)
def resolve_final_status_footer(
interactive: bool,
status_footer_builder: Callable[[], Any] | None,
) -> Any | None:
"""Resolve the footer to keep in the last Live frame.
Interactive CLI sessions redraw prompt_toolkit's own bottom toolbar as soon
as Rich Live exits, so keeping the Rich footer in that final frame causes a
duplicate status bar.
"""
if interactive:
return None
return status_footer_builder() if status_footer_builder else None
# ---------------------------------------------------------------------------
# Final results display
# ---------------------------------------------------------------------------
def display_final_results(
state: StreamState,
thinking_max_length: int = DisplayLimits.THINKING_FINAL,
show_thinking: bool = True,
show_tools: bool = True,
) -> None:
"""Display final results after streaming completes."""
if show_thinking and state.thinking_text:
display_thinking = state.thinking_text.rstrip()
if len(display_thinking) > thinking_max_length:
half = thinking_max_length // 2
display_thinking = (
display_thinking[:half]
+ "\n\n... (truncated) ...\n\n"
+ display_thinking[-half:]
)
console.print(
Panel(
Text(display_thinking, style="dim"),
title="Thinking",
border_style="blue",
)
)
if state.summarization_text:
summary_display = state.summarization_text.rstrip()
if len(summary_display) > 500:
summary_display = summary_display[:500] + " ..."
console.print(
Panel(
Text(summary_display, style="dim italic"),
title="Context Summarized",
border_style="#f59e0b",
)
)
if show_tools and state.tool_calls:
shown_sa_ids: set[str] = set()
for tc in state.tool_calls:
tr = _tool_result_for_call(state.tool_results, tc)
has_result = tr is not None
content = tr.get("content", "") if tr is not None else ""
tool_name = tc.get("name", "")
is_task = tool_name.lower() == "task"
# Task tools: show delegation line + compact sub-agent summary
if is_task:
console.print(_render_tool_call_line(tc, tr))
matched_sa = None
for sa in state.subagents:
if sa.parent_tool_call_id == tc["id"]:
matched_sa = sa
break
if matched_sa:
shown_sa_ids.add(matched_sa.instance_id)
for elem in _render_subagent_section(matched_sa, compact=True):
console.print(elem)
continue
# Regular tools: show tool call line + result
console.print(_render_tool_call_line(tc, tr))
if has_result and tr is not None:
result_elements = format_tool_result_compact(
tr["name"],
content,
max_lines=10,
tool_args=tc.get("args"),
)
for elem in result_elements:
console.print(elem)
# Render any sub-agents not already shown via task tool calls
for sa in state.subagents:
if sa.instance_id not in shown_sa_ids and (sa.tool_calls or sa.is_active):
for elem in _render_subagent_section(sa, compact=True):
console.print(elem)
console.print()
# Task List panel in final output
if state.todo_items:
console.print(_render_todo_panel(state.todo_items))
console.print()
if state.response_text:
# Strip trailing standalone "..." lines
clean_response = state.response_text.strip()
while clean_response.endswith("\n...") or clean_response.rstrip() == "...":
clean_response = clean_response.rstrip().removesuffix("...").rstrip()
console.print()
console.print(
Markdown(
_fix_markdown_heading_spacing(clean_response or state.response_text)
)
)
# Token usage stats (right-aligned)
if state.total_input_tokens or state.total_output_tokens:
stats = Text(justify="right")
stats.append("[", style="dim italic")
stats.append("Usage: ", style="dim italic")
stats.append(f"{state.total_input_tokens:,}", style="cyan italic")
stats.append(" in · ", style="dim italic")
stats.append(f"{state.total_output_tokens:,}", style="green italic")
stats.append(" out", style="dim italic")
stats.append("]", style="dim italic")
console.print(stats)
# ---------------------------------------------------------------------------
# HITL (Human-in-the-Loop) approval helpers
# ---------------------------------------------------------------------------
_logger = logging.getLogger(__name__)
_MAX_HITL_ITERATIONS = 50
_session_auto_approve = False
def _resolve_hitl_approval(
interrupt_data: dict,
prompt_fn: Callable[[list], list[dict] | None] | None = None,
*,
question_runner: _QuestionRunner | None = None,
) -> list[dict] | None:
"""Resolve HITL approval for an interrupt.
Returns list of decisions if approved, None if rejected.
Auto-approves based on config and session state.
Args:
interrupt_data: The interrupt event data.
prompt_fn: Optional custom prompt function (e.g. channel-based).
If provided and manual approval is needed, this is called
instead of the default CLI ``input()`` prompt.
"""
global _session_auto_approve
from ..backends import ActionDecision, resolve_action_decision
from ..config.settings import (
HITL_ALWAYS_PROMPT_TOOLS,
HITL_SHELL_TOOLS,
load_config,
)
action_requests = interrupt_data.get("action_requests", [])
if not action_requests:
return [{"type": "approve"}]
# Session "approve all" is an explicit human opt-in → blanket-approve
# everything (dangerous set included), unlike unattended auto_approve below.
if _session_auto_approve:
return [{"type": "approve"} for _ in action_requests]
cfg = load_config()
auto_approve = cfg.auto_approve
allow_list = (
[s.strip() for s in cfg.shell_allow_list.split(",") if s.strip()]
if cfg.shell_allow_list
else []
)
decisions: list[dict] = []
needs_prompt = False
for req in action_requests:
if not isinstance(req, dict):
# Malformed request on an approval gate — never silently approve;
# surface it for a human decision (or fail loud in the prompt path).
needs_prompt = True
break
name = req.get("name", "")
args = req.get("args", {})
if name in HITL_ALWAYS_PROMPT_TOOLS:
needs_prompt = True
break
if name not in HITL_SHELL_TOOLS:
decisions.append({"type": "approve"})
continue
command = args.get("command", "") if isinstance(args, dict) else ""
verdict = resolve_action_decision(
command,
auto_approve=auto_approve,
dangerous_mode=cfg.dangerous_mode,
allow_list=allow_list,
)
if verdict.decision is ActionDecision.APPROVE:
decisions.append({"type": "approve"})
elif verdict.decision is ActionDecision.REJECT:
decisions.append({"type": "reject", "message": verdict.reason})
else:
needs_prompt = True
break
if needs_prompt:
if prompt_fn is not None:
return prompt_fn(action_requests)
return _prompt_hitl_approval(action_requests, question_runner=question_runner)
return decisions
def _prompt_hitl_approval(
action_requests: list,
*,
question_runner: _QuestionRunner | None = None,
) -> list[dict] | None:
"""Display approval prompt and get user decision.
Returns list of decisions if approved, None if rejected.
Uses ``questionary.select()`` for arrow-key navigation, matching the
style used by ``_resolve_ask_user_prompt``. Imports are lazy so the
auto-approve / shell-allow-list fast paths in ``_resolve_hitl_approval``
don't pay for them.
"""
global _session_auto_approve
import questionary # type: ignore[import-untyped]
from ..cli.widgets.thread_selector import PICKER_STYLE as _PICKER_STYLE
console.print()
panel_text = Text()
for i, req in enumerate(action_requests):
name = req.get("name", "")
args = req.get("args", {})
desc = format_tool_compact(name, args if isinstance(args, dict) else {})
if panel_text.plain:
panel_text.append("\n")
panel_text.append(f" {i + 1}. {desc}", style="yellow")
console.print(
Panel(
panel_text,
title="Approval Required",
border_style="yellow",
padding=(0, 1),
)
)
n = len(action_requests)
if n <= 1:
approve_label = "Approve"
reject_label = "Reject"
else:
approve_label = f"Approve all {n}"
reject_label = f"Reject all {n}"
auto_label = "Approve all (session)"
try:
question = questionary.select(
"Approval required",
choices=[approve_label, reject_label, auto_label],
style=_PICKER_STYLE,
)
selected = (
question_runner(question) if question_runner is not None else question.ask()
)
except (EOFError, KeyboardInterrupt):
console.print("[dim] Rejected.[/dim]")
return None
if selected is None: # Ctrl+C inside questionary
console.print("[dim] Rejected.[/dim]")
return None
if selected == approve_label:
return [{"type": "approve"} for _ in action_requests]
if selected == auto_label:
_session_auto_approve = True
return [{"type": "approve"} for _ in action_requests]
console.print("[dim] Rejected.[/dim]")
return None
def _resolve_ask_user_prompt(
ask_user_data: dict,
*,
question_runner: _QuestionRunner | None = None,
) -> dict:
"""Interactive console Q&A for ask_user events.
Presents multiple-choice questions with arrow-key navigation via
``questionary.select()`` and free-text questions via
``questionary.text()`` with required-field validation. Matches the
questionary style used throughout the rest of the CLI.
"""
import questionary # type: ignore[import-untyped]
from ..cli.widgets.thread_selector import PICKER_STYLE as _PICKER_STYLE
questions = ask_user_data.get("questions", [])
if not questions:
return {"answers": [], "status": "answered"}
total = len(questions)
console.print()
console.print(
Panel(
Text("Quick check-in from EvoScientist", style="bold"),
border_style="cyan",
padding=(0, 1),
)
)
console.print()
answers: list[str] = []
try:
for i, q in enumerate(questions):
q_text = q.get("question", "")
q_type = q.get("type", "text")
required = q.get("required", True)
optional_suffix = " (optional)" if not required else ""
prompt_text = f"({i + 1}/{total}) {q_text}{optional_suffix}"
def _make_validator(is_required: bool):
def _validate(v: str) -> bool | str:
if is_required and not v.strip():
return "This field is required."
return True
return _validate
if q_type == "multiple_choice":
choices = q.get("choices", [])
choice_labels = [c.get("value", str(c)) for c in choices]
skip_label = "Skip"
if not required:
choice_labels.append(skip_label)
other_label = "Other (type your answer)"
choice_labels.append(other_label)
question = questionary.select(
prompt_text,
choices=choice_labels,
style=_PICKER_STYLE,
)
selected = (
question_runner(question)
if question_runner is not None
else question.ask()
)
if selected is None: # Ctrl+C
raise KeyboardInterrupt
if selected == skip_label:
answers.append("")
console.print()
continue
if selected == other_label:
question = questionary.text(
"Your answer:",
validate=_make_validator(required),
style=_PICKER_STYLE,
)
selected = (
question_runner(question)
if question_runner is not None
else question.ask()
)
if selected is None:
raise KeyboardInterrupt
answers.append(selected)
else:
question = questionary.text(
prompt_text,
validate=_make_validator(required),
style=_PICKER_STYLE,
)
answer = (
question_runner(question)
if question_runner is not None
else question.ask()
)
if answer is None: # Ctrl+C
raise KeyboardInterrupt
answers.append(answer)
console.print()
except (EOFError, KeyboardInterrupt):
console.print("[dim] Cancelled.[/dim]")
return {"status": "cancelled"}
return {"answers": answers, "status": "answered"}
def _run_streaming(
agent: "CompiledStateGraph",
message: GraphRunInput,
thread_id: str,
show_thinking: bool,
interactive: bool,
on_thinking: Callable[[str], None] | None = None,
on_todo: Callable[[list[dict]], None] | None = None,
on_file_write: Callable[[str], None] | None = None,
on_stream_event: Callable[[str, Any], Any] | None = None,
status_footer_builder: Callable[[], Any] | None = None,
metadata: dict[str, object] | None = None,
configurable_extra: dict[str, Any] | None = None,
hitl_prompt_fn: Callable[[list], list[dict] | None] | None = None,
ask_user_prompt_fn: Callable[[dict], dict] | None = None,
cancel_scope: str | None = None,
*,
gateway: GraphGateway,
runtime: AsyncRuntime | None = None,
_state: StreamState | None = None,
_hitl_depth: int = 0,
_media_sent: set[str] | None = None,
_sent_thinking_text: str | None = None,
) -> str:
"""Run async streaming and render with Rich Live display.
Bridges the async stream_agent_events() into synchronous Rich Live rendering.
Args:
agent: Compiled agent graph
message: User message
thread_id: Thread ID
show_thinking: Whether to show thinking panel
interactive: If True, use simplified final display (no panel)
on_thinking: Optional sync callback receiving full thinking text.
Called when thinking ends (transitions to tool/text) and
accumulated thinking >= 200 chars. Uses content-based
deduplication across resume/HITL cycles so the same thinking
is not replayed, but genuinely new thinking is still sent.
on_todo: Optional sync callback receiving todo items list.
Called once when write_todos tool_call is detected.
on_file_write: Optional sync callback receiving the real filesystem path
when the agent writes a media file (image/pdf) via write_file.
metadata: Optional metadata dict forwarded to ``stream_agent_events``
for LangGraph checkpoint persistence.
gateway: Graph/thread gateway supplied by the active runtime.
Returns:
The final response text.
"""
if runtime is None:
with AsyncRuntime(thread_name="evosci-stream-runtime") as owned_runtime:
return _run_streaming(
agent=agent,
message=message,
thread_id=thread_id,
show_thinking=show_thinking,
interactive=interactive,
on_thinking=on_thinking,
on_todo=on_todo,
on_file_write=on_file_write,
on_stream_event=on_stream_event,
status_footer_builder=status_footer_builder,
metadata=metadata,
hitl_prompt_fn=hitl_prompt_fn,
ask_user_prompt_fn=ask_user_prompt_fn,
cancel_scope=cancel_scope,
gateway=gateway,
runtime=owned_runtime,
_state=_state,
_hitl_depth=_hitl_depth,
_media_sent=_media_sent,
_sent_thinking_text=_sent_thinking_text,
)
# Scope-less callers keep the legacy single-event semantics. Scoped
# callers use unique per-request scopes, so pre-start `/stop` must
# remain armed until this run consumes it.
if _state is None and cancel_scope is None:
clear_stream_cancel()
state = _state if _state is not None else StreamState()
_todo_sent = False
if _media_sent is None:
_media_sent = set()
_MIN_THINKING_LEN = 200
def _stopped_response() -> str:
_, final_text = build_stopped_response_text(state.response_text)
state.response_text = final_text
return final_text
async def _consume() -> None:
nonlocal _sent_thinking_text, _todo_sent
event_stream = gateway.stream_events(
RunRequest(
message=message,
thread_id=thread_id,
metadata=metadata,
configurable_extra=configurable_extra,
target=_graph_target_for_local_agent(agent, metadata),
)
)
try:
async for event in event_stream:
if is_stream_cancel_requested(cancel_scope):
_stopped_response()
return
event_type = state.handle_event(event)
# Relay thinking to channel when transitioning away from
# thinking phase. Uses content comparison so that replayed
# thinking after resume is skipped, but genuinely new
# thinking is still delivered.
if (
on_thinking
and event_type != "thinking"
and state.thinking_text
and len(state.thinking_text) >= _MIN_THINKING_LEN
):
current = state.thinking_text.rstrip()
if current != _sent_thinking_text:
on_thinking(current)
_sent_thinking_text = current
# Send todo list to channel on first write_todos tool_call
if (
on_todo
and not _todo_sent
and event_type == "tool_call"
and event.get("name") == "write_todos"
and state.todo_items
):
on_todo(state.todo_items)
_todo_sent = True
# Send media file to channel when write_file succeeds
if (
on_file_write
and event_type == "tool_result"
and event.get("name") == "write_file"
and event.get("success")
):
wf_path = ""
for tc in reversed(state.tool_calls):
if tc.get("name") == "write_file":
p = tc.get("args", {}).get("path", "")
if p and p not in _media_sent:
wf_path = p
break
if wf_path:
ext = os.path.splitext(wf_path)[1].lower()
if ext in _MEDIA_EXTENSIONS:
real_path = str(resolve_virtual_path(wf_path))
if os.path.isfile(real_path):
_media_sent.add(wf_path)
on_file_write(real_path)
# Send media file to channel when read_file returns an image
if (
on_file_write
and event_type == "tool_result"
and event.get("name") == "read_file"
and event.get("success")
):
rf_path = ""
for tc in reversed(state.tool_calls):
if tc.get("name") == "read_file":
p = tc.get("args", {}).get("file_path", "") or tc.get(
"args", {}
).get("path", "")
if p and p not in _media_sent:
rf_path = p
break
if rf_path:
ext = os.path.splitext(rf_path)[1].lower()
if ext in _MEDIA_EXTENSIONS:
real_path = rf_path
if not os.path.isfile(real_path):
real_path = str(resolve_virtual_path(rf_path))
if os.path.isfile(real_path):
_media_sent.add(rf_path)
on_file_write(real_path)
if on_stream_event is not None:
callback_result = on_stream_event(event_type, state)
if inspect.isawaitable(callback_result):
await callback_result
live.update(
create_streaming_display(
**state.get_display_args(),
show_thinking=show_thinking,
response_markdown=state.get_response_markdown(),
status_footer=(
status_footer_builder() if status_footer_builder else None
),
)
)
finally:
aclose = getattr(event_stream, "aclose", None)
if aclose is not None:
await aclose()
try:
if is_stream_cancel_requested(cancel_scope):
return _stopped_response()
with Live(
console=console,
auto_refresh=False,
transient=False,
vertical_overflow="visible",
) as live:
live.update(
create_streaming_display(
is_waiting=True,
status_footer=(
status_footer_builder() if status_footer_builder else None
),
)
)
async def _run_with_refresh() -> None:
async def _periodic_refresh() -> None:
try:
while True:
await asyncio.sleep(0.05)
live.refresh()
except asyncio.CancelledError:
pass
refresh_task = asyncio.ensure_future(_periodic_refresh())
try:
with bind_stream_cancel(cancel_scope):
await _consume()
finally:
refresh_task.cancel()
try:
await refresh_task
except asyncio.CancelledError:
pass
# Render clean final frame before Live exits (no spinners, expanded tools)
if (
state.pending_interrupt is not None
or state.pending_ask_user is not None
):
# Interrupted: render current state (not final) so it
# looks continuous when prompt appears.
final_display = create_streaming_display(
**state.get_display_args(),
show_thinking=show_thinking,
response_markdown=state.get_response_markdown(),
status_footer=resolve_final_status_footer(
interactive, status_footer_builder
),
)
elif interactive:
final_display = create_streaming_display(
**state.get_display_args(),
show_thinking=show_thinking,
is_final=True,
final_show_thinking=False,
response_markdown=state.get_response_markdown(),
status_footer=resolve_final_status_footer(
interactive, status_footer_builder
),
)
else:
final_display = create_streaming_display(
**state.get_display_args(),
show_thinking=show_thinking,
is_final=True,
final_show_thinking=True,
final_thinking_max_length=DisplayLimits.THINKING_FINAL,
response_markdown=state.get_response_markdown(),
status_footer=resolve_final_status_footer(
interactive, status_footer_builder
),
)
_update_final_live_frame(live, final_display, stream_handle)
stream_handle: RuntimeHandle[None] | None = None
def _register(handle: RuntimeHandle[None]) -> None:
nonlocal stream_handle
stream_handle = handle
_register_stream_cancel_handle(cancel_scope, handle)
try:
runtime.run_sync(_run_with_refresh, on_submitted=_register)
except concurrent.futures.CancelledError:
if not is_stream_cancel_requested(cancel_scope):
raise
_stopped_response()
finally:
if stream_handle is not None:
_unregister_stream_cancel_handle(cancel_scope, stream_handle)
# Flush any remaining thinking that wasn't sent during streaming.
if on_thinking and state.thinking_text:
current = state.thinking_text.rstrip()
if len(current) >= _MIN_THINKING_LEN and current != _sent_thinking_text:
on_thinking(current)
_sent_thinking_text = current
# ask_user: check before HITL (ask_user uses the same resume loop)
if state.pending_ask_user is not None and _hitl_depth < _MAX_HITL_ITERATIONS:
if is_stream_cancel_requested(cancel_scope):
return _stopped_response()
if ask_user_prompt_fn is not None:
result = ask_user_prompt_fn(state.pending_ask_user)
else:
try:
result = _resolve_ask_user_prompt(
state.pending_ask_user,
question_runner=lambda question: _run_owned_questionary_prompt(
question,
runtime=runtime,
cancel_scope=cancel_scope,
),
)
except _StreamPromptCancelled:
return _stopped_response()
from langgraph.types import Command # type: ignore[import-untyped]
state.pending_ask_user = None
state.thinking_text = "" # reset accumulation for fresh round
if is_stream_cancel_requested(cancel_scope):
return _stopped_response()
return _run_streaming(
agent=agent,
message=Command(resume=result),
thread_id=thread_id,
show_thinking=show_thinking,
interactive=interactive,
on_thinking=on_thinking,
on_todo=on_todo,
on_file_write=on_file_write,
on_stream_event=on_stream_event,
status_footer_builder=status_footer_builder,
metadata=metadata,
configurable_extra=configurable_extra,
hitl_prompt_fn=hitl_prompt_fn,
ask_user_prompt_fn=ask_user_prompt_fn,
cancel_scope=cancel_scope,
gateway=gateway,
runtime=runtime,
_state=state,
_hitl_depth=_hitl_depth + 1,
_media_sent=_media_sent,
_sent_thinking_text=_sent_thinking_text,
)
# HITL: check for pending interrupt and handle approval
if state.pending_interrupt is not None and _hitl_depth < _MAX_HITL_ITERATIONS:
if is_stream_cancel_requested(cancel_scope):
return _stopped_response()
try:
decisions = _resolve_hitl_approval(
state.pending_interrupt,
prompt_fn=hitl_prompt_fn,
question_runner=(
None
if hitl_prompt_fn is not None
else lambda question: _run_owned_questionary_prompt(
question,
runtime=runtime,
cancel_scope=cancel_scope,
)
),
)
except _StreamPromptCancelled:
return _stopped_response()
if is_stream_cancel_requested(cancel_scope):
return _stopped_response()
if decisions is not None:
from ..backends import build_hitl_resume
interrupt_id = state.pending_interrupt.get("interrupt_id")
state.pending_interrupt = None
state.thinking_text = "" # reset accumulation for fresh round
if is_stream_cancel_requested(cancel_scope):
return _stopped_response()
return _run_streaming(
agent=agent,
message=build_hitl_resume(interrupt_id, decisions),
thread_id=thread_id,
show_thinking=show_thinking,
interactive=interactive,
on_thinking=on_thinking,
on_todo=on_todo,
on_file_write=on_file_write,
on_stream_event=on_stream_event,
status_footer_builder=status_footer_builder,
metadata=metadata,
hitl_prompt_fn=hitl_prompt_fn,
ask_user_prompt_fn=ask_user_prompt_fn,
cancel_scope=cancel_scope,
gateway=gateway,
runtime=runtime,
_state=state,
_hitl_depth=_hitl_depth + 1,
_media_sent=_media_sent,
_sent_thinking_text=_sent_thinking_text,
)
elif state.pending_interrupt is not None:
_logger.warning(
"HITL loop reached max iterations (%d), stopping",
_MAX_HITL_ITERATIONS,
)
# Everything (tools, thinking, todos, response) is already on screen
# from Live's final frame (transient=False). No need to re-print.
return (state.response_text or "").strip()
finally:
discard_stream_cancel(cancel_scope)
# ---------------------------------------------------------------------------
# Thread-safe static streaming (for background channels)
# ---------------------------------------------------------------------------
async def _astream_to_console(
agent: "CompiledStateGraph",
message: str,
thread_id: str,
show_thinking: bool = True,
*,
gateway: GraphGateway,
) -> str:
"""Stream agent events to console using static prints (thread-safe, no Live).
Used by the background iMessage channel to show streaming output in the CLI
without conflicting with prompt_toolkit's terminal handling in the main thread.
Rich console.print() is thread-safe (internal lock), unlike Live which is not.
Args:
agent: Compiled agent graph
message: User message
thread_id: Thread ID for conversation persistence
show_thinking: Whether to display thinking panel
Returns:
The final response text.
"""
state = StreamState()
async for event in gateway.stream_events(
RunRequest(
message=message,
thread_id=thread_id,
target=_graph_target_for_local_agent(agent),
)
):
etype = state.handle_event(event)
# Only show subagent starts as real-time progress.
# Full results rendered by display_final_results() after streaming.
if etype == "subagent_start":
name = event["name"]
desc = event.get("description", "")
line = Text()
line.append("\u25b6 ", style="cyan bold")
line.append(f"Cooking with {name}", style="cyan bold")
if desc:
short = desc[:50] + "\u2026" if len(desc) > 50 else desc
line.append(f" \u2014 {short}", style="dim")
console.print(line)
# Final output (streaming layout: tools → Task List → subagents → response)
# Thinking
if show_thinking and state.thinking_text:
dt = state.thinking_text.rstrip()
if len(dt) > 500:
dt = dt[:250] + "\n\u2026truncated\u2026\n" + dt[-250:]
console.print(
Panel(Text(dt, style="dim"), title="Thinking", border_style="blue")
)
# Summarization
if state.summarization_text:
st = state.summarization_text.rstrip()
if len(st) > 500:
st = st[:500] + " ..."
console.print(
Panel(
Text(st, style="dim italic"),
title="Context Summarized",
border_style="#f59e0b",
)
)
# 1) Regular (non-task) tools — above Task List
for tc in state.tool_calls:
if tc.get("name", "").lower() == "task":
continue
tr = _tool_result_for_call(state.tool_results, tc)
console.print(_render_tool_call_line(tc, tr))
if tr and not is_success(tr.get("content", "")):
for elem in format_tool_result_compact(tr["name"], tr.get("content", "")):
console.print(elem)
# 2) Task List panel — middle
if state.todo_items:
console.print(_render_todo_panel(state.todo_items))
console.print()
# 3) Subagent sections (compact) — below Task List
for sa in state.subagents:
if sa.tool_calls or not sa.is_active:
for elem in _render_subagent_section(sa, compact=True):
console.print(elem)
# 4) Response
if state.response_text:
clean = state.response_text.strip()
while clean.endswith("\n...") or clean.rstrip() == "...":
clean = clean.rstrip().removesuffix("...").rstrip()
console.print()
console.print(
Markdown(_fix_markdown_heading_spacing(clean or state.response_text))
)
console.print()
return (state.response_text or "").strip()