Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

584 lines
23 KiB
Python

"""Terminal renderer for streamed investigation events."""
from __future__ import annotations
import time
from collections.abc import Callable, Iterator
from typing import Any
from rich.console import Console
from rich.text import Text
from core.domain.stream import StreamEvent
from platform.analytics.cli import capture_investigation_lifecycle_event
from platform.analytics.events import Event
from platform.observability.trace.redaction import format_json_preview
from surfaces.cli.ui.renderer.constants import (
_DIAGNOSE_NODE,
_HIDDEN_PROGRESS_NODES,
_NODE_END_KINDS,
_NODE_START_KINDS,
_TOKEN_STREAM_KIND,
_render_source,
)
from surfaces.cli.ui.renderer.diagnose import _DiagnoseStreamRenderer
from surfaces.cli.ui.renderer.formatting import (
_validity_score_percent,
investigation_llm_progress_hint,
)
from surfaces.cli.ui.renderer.reasoning import reasoning_text
from surfaces.cli.ui.renderer.terminal import _print_connection_banner, _print_info
from surfaces.cli.ui.renderer.tools import (
_tool_event_key,
_tool_input,
_tool_output,
)
from surfaces.interactive_shell.ui.output import (
ProgressTracker,
_fmt_timing,
_repl_progress_active,
get_output_format,
register_tool_detail_toggle,
)
from surfaces.shared.tool_labels import tool_short_label as _tool_short_label
from surfaces.shared.tool_labels import tool_source_label as _tool_source_label
from tools.registry import resolve_tool_display_name
class StreamRenderer:
"""Renders a stream of remote SSE events as live terminal progress.
Wraps ProgressTracker to show the same spinners and resolved-dot lines
that local investigations produce, driven by remote streaming events.
When receiving ``events``-mode events, the spinner subtext is updated
in real time with tool calls, LLM reasoning, and other decisions.
"""
def __init__(self, *, local: bool = False, display: bool = True) -> None:
self._tracker = ProgressTracker()
self._active_node: str | None = None
self._events_received: int = 0
self._node_names_seen: list[str] = []
self._final_state: dict[str, Any] = {}
self._stream_completed = False
self._local = local
self._display = display
# diagnose_root_cause streams the model's reasoning live as Markdown
# instead of into the compact spinner subtext. The helper owns the
# buffer + Live region + throttle state; the renderer only
# orchestrates lifecycle (active_node tracking, finish-on-end).
self._console = Console(highlight=False)
self._diagnose = _DiagnoseStreamRenderer(
self._console,
self._tracker,
local=self._local,
state_provider=lambda: self._final_state,
)
# Track tool call start times keyed by tool name for elapsed display
self._tool_start_times: dict[str, float] = {}
self._tool_inputs: dict[str, Any] = {}
self._tool_details_visible = False
self._tool_detail_records: list[dict[str, Any]] = []
self._printed_tool_detail_ids: set[int] = set()
self._tool_summary_counts: dict[str, dict[str, int]] = {}
self._tool_summary_order: list[tuple[str, str]] = []
self._prior_lap_tools: list[str] = []
self._toggle_unregister: Callable[[], None] | None = None
def _print_above_renderable(self, renderable: Any) -> None:
"""Print a rich renderable permanently above the active live region (even during diagnose)."""
if self._diagnose._live is not None and self._diagnose._live.is_started:
self._diagnose._live.console.print(renderable)
elif self._tracker.has_active_display:
self._tracker.print_above_renderable(renderable)
else:
self._console.print(renderable)
@property
def events_received(self) -> int:
return self._events_received
@property
def node_names_seen(self) -> list[str]:
return list(self._node_names_seen)
@property
def final_state(self) -> dict[str, Any]:
return dict(self._final_state)
@property
def stream_completed(self) -> bool:
return self._stream_completed
def _mark_node_seen(self, canonical: str) -> None:
if canonical not in self._node_names_seen:
self._node_names_seen.append(canonical)
def _start_toggle_watcher(self) -> None:
if get_output_format() != "rich" or _repl_progress_active():
return
# ProgressTracker already owns the single Ctrl+O stdin watcher.
# Register our handler so toggle_active_tool_details() routes here.
self._toggle_unregister = register_tool_detail_toggle(self._toggle_tool_details)
def _stop_toggle_watcher(self) -> None:
if self._toggle_unregister is not None:
self._toggle_unregister()
self._toggle_unregister = None
def _toggle_tool_details(self) -> None:
self._tool_details_visible = not self._tool_details_visible
if get_output_format() == "rich" and self._tracker.has_active_display:
self._sync_tool_detail_view(clear=True)
return
label = "shown" if self._tool_details_visible else "hidden"
self._print_above_renderable(Text(f" Tool details {label} (ctrl+o)", style="dim"))
if self._tool_details_visible:
self._flush_tool_details()
def _sync_tool_detail_view(self, *, clear: bool = False) -> None:
if get_output_format() == "rich" and self._tracker.has_active_display:
set_tool_detail_view = getattr(self._tracker, "set_tool_detail_view", None)
if callable(set_tool_detail_view):
set_tool_detail_view(
visible=self._tool_details_visible,
records=self._tool_detail_records,
summary=self._format_tool_summary(),
clear=clear,
)
return
display = getattr(self._tracker, "_display", None)
set_tool_details = getattr(display, "set_tool_details", None)
if callable(set_tool_details):
set_tool_details(
visible=self._tool_details_visible,
records=self._tool_detail_records,
summary=self._format_tool_summary(),
clear=clear,
)
def render_stream(self, events: Iterator[StreamEvent]) -> dict[str, Any]:
"""Consume a full event stream and render progress to the terminal.
Returns the accumulated final state dict.
"""
if not self._local and self._display:
_print_connection_banner()
if self._display:
self._start_toggle_watcher()
_interrupted = False
try:
for event in events:
self._handle_event(event)
except KeyboardInterrupt:
_interrupted = True
capture_investigation_lifecycle_event(
Event.INVESTIGATION_ABANDONED,
{
"stage": self._active_node or "unstarted",
"source": _render_source(local=self._local),
},
state=self._final_state,
)
raise
finally:
self._stop_toggle_watcher()
# Always stop the active spinner thread and flush whatever
# final state was accumulated, even if the stream raises
# (e.g. LLM quota exhausted). Otherwise the spinner keeps
# writing \r + erase-line escapes forever, and any partial
# report the user has been watching stream live would be
# silently discarded before the exception propagates.
self._finish_active_node()
self._tracker.stop()
if self._display and not _interrupted:
self._print_report()
return dict(self._final_state)
def _handle_event(self, event: StreamEvent) -> None:
self._events_received += 1
if event.event_type == "metadata":
return
if event.event_type == "end":
self._stream_completed = True
self._finish_active_node()
return
if event.event_type == "updates":
self._handle_update(event)
return
if event.event_type == "events":
self._handle_events_mode(event)
return
def _handle_update(self, event: StreamEvent) -> None:
node = event.node_name
if not node:
return
canonical = _canonical_node_name(node)
if canonical in _HIDDEN_PROGRESS_NODES:
self._mark_node_seen(canonical)
self._merge_state(event.data.get(node, event.data))
return
if canonical != self._active_node:
self._finish_active_node()
self._active_node = canonical
self._mark_node_seen(canonical)
self._tracker.start(canonical)
self._merge_state(event.data.get(node, event.data))
def _handle_events_mode(self, event: StreamEvent) -> None:
"""Process a fine-grained ``events``-mode SSE event.
Node lifecycle is inferred from ``on_chain_start`` /
``on_chain_end`` events whose pipeline node metadata matches a
graph-level node. Sub-node callbacks (tool calls, LLM
reasoning) update the active spinner's subtext in real time.
``diagnose_root_cause`` is special-cased: instead of feeding the
model's token deltas into a 60-char spinner subtext, the full
deltas are accumulated into a buffer and rendered live as Markdown
in a Rich ``Live`` region (matching the interactive-shell handlers).
"""
node = event.node_name
kind = event.kind
if not node:
return
canonical = _canonical_node_name(node)
if canonical in _HIDDEN_PROGRESS_NODES:
self._mark_node_seen(canonical)
if kind in _NODE_START_KINDS and self._is_graph_node_event(event):
self._merge_chain_start_input(event)
return
if kind in _NODE_END_KINDS and self._is_graph_node_event(event):
self._merge_chain_end_output(event)
return
if canonical == _DIAGNOSE_NODE:
if kind in _NODE_START_KINDS and self._is_graph_node_event(event):
self._merge_chain_start_input(event)
self._begin_diagnose(canonical)
return
if kind in _NODE_END_KINDS and self._is_graph_node_event(event):
self._merge_chain_end_output(event)
if self._active_node == canonical:
self._end_diagnose()
return
if kind == _TOKEN_STREAM_KIND and self._active_node == canonical:
self._diagnose.append_chunk(event)
return
return
if kind in _NODE_START_KINDS and self._is_graph_node_event(event):
self._merge_chain_start_input(event)
if canonical != self._active_node:
self._finish_active_node()
self._active_node = canonical
self._mark_node_seen(canonical)
self._tracker.start(canonical)
if canonical == "investigation_agent":
self._prior_lap_tools = []
# Prime the Live spinner subtext; hint line printed on first llm_start.
primed = investigation_llm_progress_hint(0)
self._tracker.update_subtext(canonical, f"{primed} ·", duration=300.0)
return
if kind in _NODE_END_KINDS and self._is_graph_node_event(event):
self._merge_chain_end_output(event)
if canonical == self._active_node:
self._finish_active_node()
return
if kind == "on_tool_start":
self._handle_tool_start(event)
return
if kind == "on_tool_end":
self._handle_tool_end(event)
return
if kind == "on_llm_start":
self._handle_llm_start(event)
return
if canonical == self._active_node:
text = reasoning_text(kind, event.data, canonical)
if text:
self._tracker.update_subtext(canonical, text)
def _handle_tool_start(self, event: StreamEvent) -> None:
data = event.data
name = data.get("name") or data.get("data", {}).get("name") or "tool"
event_key = _tool_event_key(data, name)
self._tool_start_times[event_key] = time.monotonic()
self._tool_inputs[event_key] = _tool_input(data)
self._record_tool_summary(name)
# Show "calling X..." briefly in spinner; aggregate summary shown on end.
if self._active_node:
current = resolve_tool_display_name(name)
self._tracker.update_subtext(self._active_node, f"calling {current}...", duration=15.0)
def _handle_tool_end(self, event: StreamEvent) -> None:
data = event.data
name = data.get("name") or data.get("data", {}).get("name") or "tool"
display = resolve_tool_display_name(name)
event_key = _tool_event_key(data, name)
start = self._tool_start_times.pop(event_key, None)
elapsed_ms = int((time.monotonic() - start) * 1000) if start is not None else None
elapsed_str = _fmt_timing(elapsed_ms) if elapsed_ms is not None else ""
self._update_tool_summary_subtext()
self._record_tool_detail(
display,
self._tool_inputs.pop(event_key, None),
_tool_output(data),
elapsed=elapsed_str,
)
if elapsed_ms is not None:
print_tool_call_line = getattr(self._tracker, "print_tool_call_line", None)
if callable(print_tool_call_line):
print_tool_call_line(name, elapsed_ms)
if self._active_node == "investigation_agent":
self._prior_lap_tools.append(display)
def _handle_llm_start(self, event: StreamEvent) -> None:
if self._active_node != "investigation_agent":
return
iteration = event.data.get("iteration", 0)
if not isinstance(iteration, int):
iteration = 0
base = investigation_llm_progress_hint(
iteration,
prior_tools=self._prior_lap_tools,
)
self._prior_lap_tools = []
dots = "·" * ((iteration % 3) + 1)
hint = f"{base} {dots}"
self._tracker.update_subtext(self._active_node, hint, duration=300.0)
self._tracker.print_status_hint(hint)
def _record_tool_summary(self, tool_name: str) -> None:
source = _tool_source_label(tool_name)
label = _tool_short_label(tool_name, source)
source_counts = self._tool_summary_counts.setdefault(source, {})
if label not in source_counts:
self._tool_summary_order.append((source, label))
source_counts[label] = source_counts.get(label, 0) + 1
self._sync_tool_detail_view()
def _update_tool_summary_subtext(self) -> None:
if not self._active_node:
return
summary = self._format_tool_summary()
if summary:
self._tracker.update_subtext(self._active_node, summary, duration=30.0)
def _format_tool_summary(self) -> str:
source_labels: dict[str, list[str]] = {}
for source, label in self._tool_summary_order:
count = self._tool_summary_counts.get(source, {}).get(label, 0)
if count <= 0:
continue
rendered = f"{label} x{count}" if count > 1 else label
source_labels.setdefault(source, []).append(rendered)
parts = [
f"{source}: {', '.join(labels[:4])}{', ...' if len(labels) > 4 else ''}"
for source, labels in source_labels.items()
]
summary = " | ".join(parts[:2])
return summary[:117] + "..." if len(summary) > 120 else summary
def _record_tool_detail(
self,
display: str,
tool_input: Any,
output: Any,
*,
elapsed: str = "",
) -> None:
if tool_input in ({}, None) and output in ({}, None, ""):
return
record = {
"display": display,
"input": tool_input,
"output": output,
"elapsed": elapsed,
}
self._tool_detail_records.append(record)
if self._tool_details_visible:
if get_output_format() == "rich" and self._tracker.has_active_display:
self._sync_tool_detail_view()
else:
self._print_tool_detail(record)
def _flush_tool_details(self) -> None:
for record in self._tool_detail_records:
if id(record) not in self._printed_tool_detail_ids:
self._print_tool_detail(record)
def _print_tool_detail(self, record: dict[str, Any]) -> None:
display = str(record.get("display") or "tool")
tool_input = record.get("input")
output = record.get("output")
body_parts: list[str] = []
if tool_input not in ({}, None):
body_parts.append(f"Input:\n{format_json_preview(tool_input, max_chars=1600)}")
if output not in ({}, None, ""):
body_parts.append(f"Output:\n{format_json_preview(output, max_chars=3000)}")
body = "\n\n".join(body_parts)
elapsed = str(record.get("elapsed") or "")
suffix = f" {elapsed}" if elapsed else ""
if get_output_format() == "rich":
detail = Text()
detail.append(f" Tool details: {display}{suffix}\n", style="bold")
for line in body.splitlines():
detail.append(f" {line}\n", style="dim")
self._print_above_renderable(detail)
self._printed_tool_detail_ids.add(id(record))
return
self._console.print(f" Tool details: {display}{suffix}", markup=False)
for line in body.splitlines():
self._console.print(f" {line}", markup=False)
self._printed_tool_detail_ids.add(id(record))
def _begin_diagnose(self, canonical: str) -> None:
"""Mark diagnose as the active node and let the helper open its Live region.
Closes any previous spinner-driven node (e.g. ``investigate``)
first so the helper takes over stdout cleanly.
"""
if self._active_node and self._active_node != canonical:
self._finish_active_node()
self._active_node = canonical
self._mark_node_seen(canonical)
self._diagnose.start()
def _end_diagnose(self) -> None:
"""Close the diagnose helper's Live region and clear ``_active_node``."""
self._diagnose.finish(self._build_node_message(_DIAGNOSE_NODE))
self._active_node = None
@staticmethod
def _is_graph_node_event(event: StreamEvent) -> bool:
"""True when the event is a top-level graph node transition.
Top-level graph node chains are tagged with ``graph:step:<N>``.
Sub-chains inside a node (tool executors, LLM calls) lack this tag.
"""
name = str(event.data.get("name", ""))
tags = event.tags
if any(t.startswith("graph:step:") for t in tags):
return True
if any(t.startswith("tracing:") for t in tags):
return False
return bool(name == event.node_name)
def _finish_active_node(self) -> None:
if self._active_node is None:
return
# Diagnose owns its own Rich.Live region — route cleanup through
# _end_diagnose so the Live closes even on mid-stream exceptions.
if self._active_node == _DIAGNOSE_NODE:
self._end_diagnose()
return
node = self._active_node
message = self._build_node_message(node)
self._tracker.complete(node, message=message)
self._active_node = None
def _merge_state(self, update: Any) -> None:
if isinstance(update, dict):
self._final_state.update(update)
def _merge_chain_start_input(self, event: StreamEvent) -> None:
"""Pull the ``input`` payload from a chain-start event into ``_final_state``."""
data = event.data.get("data", {})
input_payload = data.get("input", {})
if isinstance(input_payload, dict):
self._merge_state(input_payload)
def _merge_chain_end_output(self, event: StreamEvent) -> None:
"""Pull the ``output`` payload from a chain-end event into ``_final_state``.
Both the diagnose-streaming branch and the default-spinner branch
unwrap ``event.data["data"]["output"]`` the same way; sharing one
helper keeps the unwrapping shape in one place.
"""
output = event.data.get("data", {}).get("output", {})
if isinstance(output, dict):
self._merge_state(output)
def _build_node_message(self, node: str) -> str | None:
if node == "plan_actions":
actions = self._final_state.get("planned_actions", [])
if actions:
if get_output_format() == "rich":
return None
return f"Planned actions: {actions}"
if node == "resolve_integrations":
integrations = self._final_state.get("resolved_integrations", {})
if integrations:
names = list(integrations.keys())
return f"Resolved: {names}"
if node in {"diagnose", "diagnose_root_cause"}:
pct = _validity_score_percent(self._final_state.get("validity_score"))
if pct:
return f"validity:{pct}"
return None
def _print_report(self) -> None:
from surfaces.interactive_shell.ui.output import stop_display
stop_display()
slack_message = self._final_state.get("slack_message") or self._final_state.get(
"report", ""
)
if not slack_message:
if self._final_state.get("is_noise"):
_print_info("Alert classified as noise — no investigation needed.")
elif self._events_received == 0:
_print_info("No events received from the remote agent.")
else:
# Stream finished but no report was published — e.g. the pipeline
# raised after diagnosis, or publish_findings produced no message.
# Never go silently blank: surface whatever root cause we captured,
# then a clear notice so the user knows the run ended (and the UI
# did not just swallow the report).
root_cause = (self._final_state.get("root_cause") or "").strip()
if root_cause:
_print_info(f"Root cause: {root_cause}")
_print_info(
"Investigation finished without a full report. "
"Re-run, or check the logs if this persists."
)
return
from tools.investigation.reporting.renderers.terminal import (
render_report as _render,
)
_render(slack_message)
def _canonical_node_name(name: str) -> str:
"""Map node names to the canonical names used by ProgressTracker."""
mapping = {
"diagnose_root_cause": "diagnose_root_cause",
"diagnose": "diagnose_root_cause",
"publish_findings": "publish_findings",
"publish": "publish_findings",
"investigation_agent": "investigation_agent",
}
return mapping.get(name, name)