glaip-sdk 0.0.20__py3-none-any.whl → 0.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- glaip_sdk/_version.py +1 -3
- glaip_sdk/branding.py +2 -6
- glaip_sdk/cli/agent_config.py +2 -6
- glaip_sdk/cli/auth.py +11 -30
- glaip_sdk/cli/commands/agents.py +64 -107
- glaip_sdk/cli/commands/configure.py +12 -36
- glaip_sdk/cli/commands/mcps.py +25 -63
- glaip_sdk/cli/commands/models.py +2 -4
- glaip_sdk/cli/commands/tools.py +22 -35
- glaip_sdk/cli/commands/update.py +3 -8
- glaip_sdk/cli/config.py +1 -3
- glaip_sdk/cli/display.py +10 -13
- glaip_sdk/cli/io.py +8 -14
- glaip_sdk/cli/main.py +10 -30
- glaip_sdk/cli/mcp_validators.py +5 -15
- glaip_sdk/cli/pager.py +3 -9
- glaip_sdk/cli/parsers/json_input.py +11 -22
- glaip_sdk/cli/resolution.py +3 -9
- glaip_sdk/cli/rich_helpers.py +1 -3
- glaip_sdk/cli/slash/agent_session.py +5 -10
- glaip_sdk/cli/slash/prompt.py +3 -10
- glaip_sdk/cli/slash/session.py +46 -98
- glaip_sdk/cli/transcript/cache.py +6 -19
- glaip_sdk/cli/transcript/capture.py +45 -20
- glaip_sdk/cli/transcript/launcher.py +1 -3
- glaip_sdk/cli/transcript/viewer.py +224 -47
- glaip_sdk/cli/update_notifier.py +165 -21
- glaip_sdk/cli/utils.py +33 -91
- glaip_sdk/cli/validators.py +11 -12
- glaip_sdk/client/_agent_payloads.py +10 -30
- glaip_sdk/client/agents.py +33 -63
- glaip_sdk/client/base.py +77 -35
- glaip_sdk/client/mcps.py +1 -3
- glaip_sdk/client/run_rendering.py +121 -26
- glaip_sdk/client/tools.py +8 -24
- glaip_sdk/client/validators.py +20 -48
- glaip_sdk/exceptions.py +1 -3
- glaip_sdk/icons.py +9 -3
- glaip_sdk/models.py +14 -33
- glaip_sdk/payload_schemas/agent.py +1 -3
- glaip_sdk/utils/agent_config.py +4 -14
- glaip_sdk/utils/client_utils.py +7 -21
- glaip_sdk/utils/display.py +2 -6
- glaip_sdk/utils/general.py +1 -3
- glaip_sdk/utils/import_export.py +3 -9
- glaip_sdk/utils/rendering/formatting.py +52 -12
- glaip_sdk/utils/rendering/models.py +17 -8
- glaip_sdk/utils/rendering/renderer/__init__.py +1 -5
- glaip_sdk/utils/rendering/renderer/base.py +1181 -328
- glaip_sdk/utils/rendering/renderer/config.py +4 -10
- glaip_sdk/utils/rendering/renderer/debug.py +4 -14
- glaip_sdk/utils/rendering/renderer/panels.py +1 -3
- glaip_sdk/utils/rendering/renderer/progress.py +3 -11
- glaip_sdk/utils/rendering/renderer/stream.py +9 -42
- glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
- glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
- glaip_sdk/utils/rendering/step_tree_state.py +100 -0
- glaip_sdk/utils/rendering/steps.py +899 -25
- glaip_sdk/utils/resource_refs.py +4 -13
- glaip_sdk/utils/serialization.py +14 -46
- glaip_sdk/utils/validation.py +4 -4
- {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.1.3.dist-info}/METADATA +12 -1
- glaip_sdk-0.1.3.dist-info/RECORD +83 -0
- glaip_sdk-0.0.20.dist-info/RECORD +0 -80
- {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.1.3.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.1.3.dist-info}/entry_points.txt +0 -0
|
@@ -8,25 +8,31 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import json
|
|
10
10
|
import logging
|
|
11
|
+
from collections.abc import Iterable
|
|
11
12
|
from dataclasses import dataclass, field
|
|
12
13
|
from datetime import datetime, timezone
|
|
13
14
|
from time import monotonic
|
|
14
15
|
from typing import Any
|
|
15
16
|
|
|
16
|
-
from rich.align import Align
|
|
17
17
|
from rich.console import Console as RichConsole
|
|
18
18
|
from rich.console import Group
|
|
19
19
|
from rich.live import Live
|
|
20
20
|
from rich.markdown import Markdown
|
|
21
|
+
from rich.measure import Measurement
|
|
21
22
|
from rich.spinner import Spinner
|
|
22
23
|
from rich.text import Text
|
|
23
24
|
|
|
24
25
|
from glaip_sdk.icons import ICON_AGENT, ICON_AGENT_STEP, ICON_DELEGATE, ICON_TOOL_STEP
|
|
25
26
|
from glaip_sdk.rich_components import AIPPanel
|
|
26
27
|
from glaip_sdk.utils.rendering.formatting import (
|
|
28
|
+
build_connector_prefix,
|
|
27
29
|
format_main_title,
|
|
28
30
|
get_spinner_char,
|
|
31
|
+
glyph_for_status,
|
|
29
32
|
is_step_finished,
|
|
33
|
+
normalise_display_label,
|
|
34
|
+
pretty_args,
|
|
35
|
+
redact_sensitive,
|
|
30
36
|
)
|
|
31
37
|
from glaip_sdk.utils.rendering.models import RunStats, Step
|
|
32
38
|
from glaip_sdk.utils.rendering.renderer.config import RendererConfig
|
|
@@ -44,13 +50,33 @@ from glaip_sdk.utils.rendering.renderer.progress import (
|
|
|
44
50
|
is_delegation_tool,
|
|
45
51
|
)
|
|
46
52
|
from glaip_sdk.utils.rendering.renderer.stream import StreamProcessor
|
|
47
|
-
from glaip_sdk.utils.rendering.
|
|
53
|
+
from glaip_sdk.utils.rendering.renderer.summary_window import clamp_step_nodes
|
|
54
|
+
from glaip_sdk.utils.rendering.steps import UNKNOWN_STEP_DETAIL, StepManager
|
|
55
|
+
|
|
56
|
+
DEFAULT_RENDERER_THEME = "dark"
|
|
57
|
+
_NO_STEPS_TEXT = Text("No steps yet", style="dim")
|
|
48
58
|
|
|
49
59
|
# Configure logger
|
|
50
60
|
logger = logging.getLogger("glaip_sdk.run_renderer")
|
|
51
61
|
|
|
52
62
|
# Constants
|
|
53
63
|
LESS_THAN_1MS = "[<1ms]"
|
|
64
|
+
FINISHED_STATUS_HINTS = {
|
|
65
|
+
"finished",
|
|
66
|
+
"success",
|
|
67
|
+
"succeeded",
|
|
68
|
+
"completed",
|
|
69
|
+
"failed",
|
|
70
|
+
"stopped",
|
|
71
|
+
"error",
|
|
72
|
+
}
|
|
73
|
+
RUNNING_STATUS_HINTS = {"running", "started", "pending", "working"}
|
|
74
|
+
ARGS_VALUE_MAX_LEN = 160
|
|
75
|
+
STATUS_ICON_STYLES = {
|
|
76
|
+
"success": "green",
|
|
77
|
+
"failed": "red",
|
|
78
|
+
"warning": "yellow",
|
|
79
|
+
}
|
|
54
80
|
|
|
55
81
|
|
|
56
82
|
def _coerce_received_at(value: Any) -> datetime | None:
|
|
@@ -72,6 +98,16 @@ def _coerce_received_at(value: Any) -> datetime | None:
|
|
|
72
98
|
return None
|
|
73
99
|
|
|
74
100
|
|
|
101
|
+
def _truncate_display(text: str | None, limit: int = 160) -> str:
|
|
102
|
+
"""Return text capped at the given character limit with ellipsis."""
|
|
103
|
+
if not text:
|
|
104
|
+
return ""
|
|
105
|
+
stripped = str(text).strip()
|
|
106
|
+
if len(stripped) <= limit:
|
|
107
|
+
return stripped
|
|
108
|
+
return stripped[: limit - 1] + "…"
|
|
109
|
+
|
|
110
|
+
|
|
75
111
|
@dataclass
|
|
76
112
|
class RendererState:
|
|
77
113
|
"""Internal state for the renderer."""
|
|
@@ -96,6 +132,43 @@ class RendererState:
|
|
|
96
132
|
self.buffer = []
|
|
97
133
|
|
|
98
134
|
|
|
135
|
+
@dataclass
|
|
136
|
+
class ThinkingScopeState:
|
|
137
|
+
"""Runtime bookkeeping for deterministic thinking spans."""
|
|
138
|
+
|
|
139
|
+
anchor_id: str
|
|
140
|
+
task_id: str | None
|
|
141
|
+
context_id: str | None
|
|
142
|
+
anchor_started_at: float | None = None
|
|
143
|
+
anchor_finished_at: float | None = None
|
|
144
|
+
idle_started_at: float | None = None
|
|
145
|
+
idle_started_monotonic: float | None = None
|
|
146
|
+
active_thinking_id: str | None = None
|
|
147
|
+
running_children: set[str] = field(default_factory=set)
|
|
148
|
+
closed: bool = False
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class TrailingSpinnerLine:
|
|
152
|
+
"""Render a text line with a trailing animated Rich spinner."""
|
|
153
|
+
|
|
154
|
+
def __init__(self, base_text: Text, spinner: Spinner) -> None:
|
|
155
|
+
"""Initialize spinner line with base text and spinner component."""
|
|
156
|
+
self._base_text = base_text
|
|
157
|
+
self._spinner = spinner
|
|
158
|
+
|
|
159
|
+
def __rich_console__(self, console: RichConsole, options: Any) -> Any:
|
|
160
|
+
"""Render the text with trailing animated spinner."""
|
|
161
|
+
spinner_render = self._spinner.render(console.get_time())
|
|
162
|
+
combined = Text.assemble(self._base_text.copy(), " ", spinner_render)
|
|
163
|
+
yield combined
|
|
164
|
+
|
|
165
|
+
def __rich_measure__(self, console: RichConsole, options: Any) -> Measurement:
|
|
166
|
+
"""Measure the combined text and spinner dimensions."""
|
|
167
|
+
snapshot = self._spinner.render(0)
|
|
168
|
+
combined = Text.assemble(self._base_text.copy(), " ", snapshot)
|
|
169
|
+
return Measurement.get(console, options, combined)
|
|
170
|
+
|
|
171
|
+
|
|
99
172
|
class RichStreamRenderer:
|
|
100
173
|
"""Live, modern terminal renderer for agent execution with rich visual output."""
|
|
101
174
|
|
|
@@ -122,17 +195,18 @@ class RichStreamRenderer:
|
|
|
122
195
|
self.state = RendererState()
|
|
123
196
|
|
|
124
197
|
# Initialize step manager and other state
|
|
125
|
-
self.steps = StepManager()
|
|
198
|
+
self.steps = StepManager(max_steps=self.cfg.summary_max_steps)
|
|
126
199
|
# Live display instance (single source of truth)
|
|
127
200
|
self.live: Live | None = None
|
|
201
|
+
self._step_spinners: dict[str, Spinner] = {}
|
|
128
202
|
|
|
129
|
-
#
|
|
130
|
-
self.context_order: list[str] = []
|
|
131
|
-
self.context_parent: dict[str, str] = {}
|
|
132
|
-
self.tool_order: list[str] = []
|
|
133
|
-
self.context_panels: dict[str, list[str]] = {}
|
|
134
|
-
self.context_meta: dict[str, dict[str, Any]] = {}
|
|
203
|
+
# Tool tracking and thinking scopes
|
|
135
204
|
self.tool_panels: dict[str, dict[str, Any]] = {}
|
|
205
|
+
self._thinking_scopes: dict[str, ThinkingScopeState] = {}
|
|
206
|
+
self._root_agent_friendly: str | None = None
|
|
207
|
+
self._root_agent_step_id: str | None = None
|
|
208
|
+
self._root_query: str | None = None
|
|
209
|
+
self._root_query_attached: bool = False
|
|
136
210
|
|
|
137
211
|
# Timing
|
|
138
212
|
self._started_at: float | None = None
|
|
@@ -145,6 +219,17 @@ class RichStreamRenderer:
|
|
|
145
219
|
# Output formatting constants
|
|
146
220
|
self.OUTPUT_PREFIX: str = "**Output:**\n"
|
|
147
221
|
|
|
222
|
+
# Transcript toggling
|
|
223
|
+
self._transcript_mode_enabled: bool = False
|
|
224
|
+
self._transcript_render_cursor: int = 0
|
|
225
|
+
self.transcript_controller: Any | None = None
|
|
226
|
+
self._transcript_hint_message = "[dim]Transcript view · Press Ctrl+T to return to the summary.[/dim]"
|
|
227
|
+
self._summary_hint_message = "[dim]Press Ctrl+T to inspect raw transcript events.[/dim]"
|
|
228
|
+
self._summary_hint_printed_once: bool = False
|
|
229
|
+
self._transcript_hint_printed_once: bool = False
|
|
230
|
+
self._transcript_header_printed: bool = False
|
|
231
|
+
self._transcript_enabled_message_printed: bool = False
|
|
232
|
+
|
|
148
233
|
def on_start(self, meta: dict[str, Any]) -> None:
|
|
149
234
|
"""Handle renderer start event."""
|
|
150
235
|
if self.cfg.live:
|
|
@@ -158,6 +243,20 @@ class RichStreamRenderer:
|
|
|
158
243
|
except Exception:
|
|
159
244
|
self.state.meta = dict(meta)
|
|
160
245
|
|
|
246
|
+
meta_payload = meta or {}
|
|
247
|
+
self.steps.set_root_agent(meta_payload.get("agent_id"))
|
|
248
|
+
self._root_agent_friendly = self._humanize_agent_slug(meta_payload.get("agent_name"))
|
|
249
|
+
self._root_query = _truncate_display(
|
|
250
|
+
meta_payload.get("input_message")
|
|
251
|
+
or meta_payload.get("query")
|
|
252
|
+
or meta_payload.get("message")
|
|
253
|
+
or (meta_payload.get("meta") or {}).get("input_message")
|
|
254
|
+
or ""
|
|
255
|
+
)
|
|
256
|
+
if not self._root_query:
|
|
257
|
+
self._root_query = None
|
|
258
|
+
self._root_query_attached = False
|
|
259
|
+
|
|
161
260
|
# Print compact header and user request (parity with old renderer)
|
|
162
261
|
self._render_header(meta)
|
|
163
262
|
self._render_user_query(meta)
|
|
@@ -207,20 +306,73 @@ class RichStreamRenderer:
|
|
|
207
306
|
except Exception:
|
|
208
307
|
logger.exception("Failed to print header fallback")
|
|
209
308
|
|
|
309
|
+
def _extract_query_from_meta(self, meta: dict[str, Any] | None) -> str | None:
|
|
310
|
+
"""Extract the primary query string from a metadata payload."""
|
|
311
|
+
if not meta:
|
|
312
|
+
return None
|
|
313
|
+
query = (
|
|
314
|
+
meta.get("input_message")
|
|
315
|
+
or meta.get("query")
|
|
316
|
+
or meta.get("message")
|
|
317
|
+
or (meta.get("meta") or {}).get("input_message")
|
|
318
|
+
)
|
|
319
|
+
if isinstance(query, str) and query.strip():
|
|
320
|
+
return query
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
def _build_user_query_panel(self, query: str) -> AIPPanel:
|
|
324
|
+
"""Create the panel used to display the user request."""
|
|
325
|
+
return AIPPanel(
|
|
326
|
+
Markdown(f"**Query:** {query}"),
|
|
327
|
+
title="User Request",
|
|
328
|
+
border_style="#d97706",
|
|
329
|
+
padding=(0, 1),
|
|
330
|
+
)
|
|
331
|
+
|
|
210
332
|
def _render_user_query(self, meta: dict[str, Any]) -> None:
|
|
211
333
|
"""Render the user query panel."""
|
|
212
|
-
query =
|
|
334
|
+
query = self._extract_query_from_meta(meta)
|
|
213
335
|
if not query:
|
|
214
336
|
return
|
|
337
|
+
self.console.print(self._build_user_query_panel(query))
|
|
338
|
+
|
|
339
|
+
def _render_summary_static_sections(self) -> None:
|
|
340
|
+
"""Re-render header and user query when returning to summary mode."""
|
|
341
|
+
meta = getattr(self.state, "meta", None)
|
|
342
|
+
if meta:
|
|
343
|
+
self._render_header(meta)
|
|
344
|
+
elif self.header_text and not self._render_header_rule():
|
|
345
|
+
self._render_header_fallback()
|
|
215
346
|
|
|
216
|
-
self.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
347
|
+
query = self._extract_query_from_meta(meta) or self._root_query
|
|
348
|
+
if query:
|
|
349
|
+
self.console.print(self._build_user_query_panel(query))
|
|
350
|
+
|
|
351
|
+
def _render_summary_after_transcript_toggle(self) -> None:
|
|
352
|
+
"""Render the summary panel after leaving transcript mode."""
|
|
353
|
+
if self.state.finalizing_ui:
|
|
354
|
+
self._render_final_summary_panels()
|
|
355
|
+
elif self.live:
|
|
356
|
+
self._refresh_live_panels()
|
|
357
|
+
else:
|
|
358
|
+
self._render_static_summary_panels()
|
|
359
|
+
|
|
360
|
+
def _render_final_summary_panels(self) -> None:
|
|
361
|
+
"""Render a static summary and disable live mode for final output."""
|
|
362
|
+
self.cfg.live = False
|
|
363
|
+
self.live = None
|
|
364
|
+
self._render_static_summary_panels()
|
|
365
|
+
|
|
366
|
+
def _render_static_summary_panels(self) -> None:
|
|
367
|
+
"""Render the steps and main panels in a static (non-live) layout."""
|
|
368
|
+
steps_renderable = self._render_steps_text()
|
|
369
|
+
steps_panel = AIPPanel(
|
|
370
|
+
steps_renderable,
|
|
371
|
+
title="Steps",
|
|
372
|
+
border_style="blue",
|
|
223
373
|
)
|
|
374
|
+
self.console.print(steps_panel)
|
|
375
|
+
self.console.print(self._render_main_panel())
|
|
224
376
|
|
|
225
377
|
def _ensure_streaming_started_baseline(self, timestamp: float) -> None:
|
|
226
378
|
"""Synchronize streaming start state across renderer components."""
|
|
@@ -237,10 +389,12 @@ class RichStreamRenderer:
|
|
|
237
389
|
self._sync_stream_start(ev, received_at)
|
|
238
390
|
|
|
239
391
|
metadata = self.stream_processor.extract_event_metadata(ev)
|
|
240
|
-
self.stream_processor.update_timing(metadata["context_id"])
|
|
241
392
|
|
|
242
393
|
self._maybe_render_debug(ev, received_at)
|
|
243
|
-
|
|
394
|
+
try:
|
|
395
|
+
self._dispatch_event(ev, metadata)
|
|
396
|
+
finally:
|
|
397
|
+
self.stream_processor.update_timing(metadata.get("context_id"))
|
|
244
398
|
|
|
245
399
|
def _resolve_received_timestamp(self, ev: dict[str, Any]) -> datetime:
|
|
246
400
|
"""Return the timestamp an event was received, normalising inputs."""
|
|
@@ -253,9 +407,7 @@ class RichStreamRenderer:
|
|
|
253
407
|
|
|
254
408
|
return received_at
|
|
255
409
|
|
|
256
|
-
def _sync_stream_start(
|
|
257
|
-
self, ev: dict[str, Any], received_at: datetime | None
|
|
258
|
-
) -> None:
|
|
410
|
+
def _sync_stream_start(self, ev: dict[str, Any], received_at: datetime | None) -> None:
|
|
259
411
|
"""Ensure renderer and stream processor share a streaming baseline."""
|
|
260
412
|
baseline = self.state.streaming_started_at
|
|
261
413
|
if baseline is None:
|
|
@@ -275,12 +427,14 @@ class RichStreamRenderer:
|
|
|
275
427
|
if not self.verbose:
|
|
276
428
|
return
|
|
277
429
|
|
|
430
|
+
self._ensure_transcript_header()
|
|
278
431
|
render_debug_event(
|
|
279
432
|
ev,
|
|
280
433
|
self.console,
|
|
281
434
|
received_ts=received_at,
|
|
282
435
|
baseline_ts=self.state.streaming_started_event_ts,
|
|
283
436
|
)
|
|
437
|
+
self._print_transcript_hint()
|
|
284
438
|
|
|
285
439
|
def _dispatch_event(self, ev: dict[str, Any], metadata: dict[str, Any]) -> None:
|
|
286
440
|
"""Route events to the appropriate renderer handlers."""
|
|
@@ -294,7 +448,7 @@ class RichStreamRenderer:
|
|
|
294
448
|
elif kind == "final_response":
|
|
295
449
|
self._handle_final_response_event(content, metadata)
|
|
296
450
|
elif kind in {"agent_step", "agent_thinking_step"}:
|
|
297
|
-
self._handle_agent_step_event(ev)
|
|
451
|
+
self._handle_agent_step_event(ev, metadata)
|
|
298
452
|
else:
|
|
299
453
|
self._ensure_live()
|
|
300
454
|
|
|
@@ -310,21 +464,32 @@ class RichStreamRenderer:
|
|
|
310
464
|
self.state.buffer.append(content)
|
|
311
465
|
self._ensure_live()
|
|
312
466
|
|
|
313
|
-
def _handle_final_response_event(
|
|
314
|
-
self, content: str, metadata: dict[str, Any]
|
|
315
|
-
) -> None:
|
|
467
|
+
def _handle_final_response_event(self, content: str, metadata: dict[str, Any]) -> None:
|
|
316
468
|
"""Handle final response events."""
|
|
317
469
|
if content:
|
|
318
470
|
self.state.buffer.append(content)
|
|
319
471
|
self.state.final_text = content
|
|
320
472
|
|
|
321
473
|
meta_payload = metadata.get("metadata") or {}
|
|
322
|
-
self.
|
|
474
|
+
final_time = self._coerce_server_time(meta_payload.get("time"))
|
|
475
|
+
self._update_final_duration(final_time)
|
|
476
|
+
self._close_active_thinking_scopes(final_time)
|
|
477
|
+
self._finish_running_steps()
|
|
478
|
+
self._finish_tool_panels()
|
|
479
|
+
self._normalise_finished_icons()
|
|
323
480
|
|
|
324
|
-
|
|
325
|
-
|
|
481
|
+
self._ensure_live()
|
|
482
|
+
self._print_final_panel_if_needed()
|
|
483
|
+
|
|
484
|
+
def _normalise_finished_icons(self) -> None:
|
|
485
|
+
"""Ensure finished steps do not keep spinner icons."""
|
|
486
|
+
for step in self.steps.by_id.values():
|
|
487
|
+
if getattr(step, "status", None) == "finished" and getattr(step, "status_icon", None) == "spinner":
|
|
488
|
+
step.status_icon = "success"
|
|
489
|
+
if getattr(step, "status", None) != "running":
|
|
490
|
+
self._step_spinners.pop(step.step_id, None)
|
|
326
491
|
|
|
327
|
-
def _handle_agent_step_event(self, ev: dict[str, Any]) -> None:
|
|
492
|
+
def _handle_agent_step_event(self, ev: dict[str, Any], metadata: dict[str, Any]) -> None:
|
|
328
493
|
"""Handle agent step events."""
|
|
329
494
|
# Extract tool information
|
|
330
495
|
(
|
|
@@ -334,22 +499,376 @@ class RichStreamRenderer:
|
|
|
334
499
|
tool_calls_info,
|
|
335
500
|
) = self.stream_processor.parse_tool_calls(ev)
|
|
336
501
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
502
|
+
payload = metadata.get("metadata") or {}
|
|
503
|
+
|
|
504
|
+
tracked_step: Step | None = None
|
|
505
|
+
try:
|
|
506
|
+
tracked_step = self.steps.apply_event(ev)
|
|
507
|
+
except ValueError:
|
|
508
|
+
logger.debug("Malformed step event skipped", exc_info=True)
|
|
509
|
+
else:
|
|
510
|
+
self._record_step_server_start(tracked_step, payload)
|
|
511
|
+
self._update_thinking_timeline(tracked_step, payload)
|
|
512
|
+
self._maybe_override_root_agent_label(tracked_step, payload)
|
|
513
|
+
self._maybe_attach_root_query(tracked_step)
|
|
514
|
+
|
|
515
|
+
# Track tools and sub-agents for transcript/debug context
|
|
516
|
+
self.stream_processor.track_tools_and_agents(tool_name, tool_calls_info, is_delegation_tool)
|
|
341
517
|
|
|
342
518
|
# Handle tool execution
|
|
343
|
-
self._handle_agent_step(
|
|
519
|
+
self._handle_agent_step(
|
|
520
|
+
ev,
|
|
521
|
+
tool_name,
|
|
522
|
+
tool_args,
|
|
523
|
+
tool_out,
|
|
524
|
+
tool_calls_info,
|
|
525
|
+
tracked_step=tracked_step,
|
|
526
|
+
)
|
|
344
527
|
|
|
345
528
|
# Update live display
|
|
346
529
|
self._ensure_live()
|
|
347
530
|
|
|
531
|
+
def _maybe_attach_root_query(self, step: Step | None) -> None:
|
|
532
|
+
"""Attach the user query to the root agent step for display."""
|
|
533
|
+
if not step or self._root_query_attached or not self._root_query or step.kind != "agent" or step.parent_id:
|
|
534
|
+
return
|
|
535
|
+
|
|
536
|
+
args = dict(getattr(step, "args", {}) or {})
|
|
537
|
+
args.setdefault("query", self._root_query)
|
|
538
|
+
step.args = args
|
|
539
|
+
self._root_query_attached = True
|
|
540
|
+
|
|
541
|
+
def _record_step_server_start(self, step: Step | None, payload: dict[str, Any]) -> None:
|
|
542
|
+
"""Store server-provided start times for elapsed calculations."""
|
|
543
|
+
if not step:
|
|
544
|
+
return
|
|
545
|
+
server_time = payload.get("time")
|
|
546
|
+
if not isinstance(server_time, (int, float)):
|
|
547
|
+
return
|
|
548
|
+
self._step_server_start_times.setdefault(step.step_id, float(server_time))
|
|
549
|
+
|
|
550
|
+
def _maybe_override_root_agent_label(self, step: Step | None, payload: dict[str, Any]) -> None:
|
|
551
|
+
"""Ensure the root agent row uses the human-friendly name and shows the ID."""
|
|
552
|
+
if not step or step.kind != "agent" or step.parent_id:
|
|
553
|
+
return
|
|
554
|
+
friendly = self._root_agent_friendly or self._humanize_agent_slug((payload or {}).get("agent_name"))
|
|
555
|
+
if not friendly:
|
|
556
|
+
return
|
|
557
|
+
agent_identifier = step.name or step.step_id
|
|
558
|
+
if not agent_identifier:
|
|
559
|
+
return
|
|
560
|
+
step.display_label = normalise_display_label(f"{ICON_AGENT} {friendly} ({agent_identifier})")
|
|
561
|
+
if not self._root_agent_step_id:
|
|
562
|
+
self._root_agent_step_id = step.step_id
|
|
563
|
+
|
|
564
|
+
def _update_thinking_timeline(self, step: Step | None, payload: dict[str, Any]) -> None:
|
|
565
|
+
"""Maintain deterministic thinking spans for each agent/delegate scope."""
|
|
566
|
+
if not self.cfg.render_thinking or not step:
|
|
567
|
+
return
|
|
568
|
+
|
|
569
|
+
now_monotonic = monotonic()
|
|
570
|
+
server_time = self._coerce_server_time(payload.get("time"))
|
|
571
|
+
status_hint = (payload.get("status") or "").lower()
|
|
572
|
+
|
|
573
|
+
if self._is_scope_anchor(step):
|
|
574
|
+
self._update_anchor_thinking(
|
|
575
|
+
step=step,
|
|
576
|
+
server_time=server_time,
|
|
577
|
+
status_hint=status_hint,
|
|
578
|
+
now_monotonic=now_monotonic,
|
|
579
|
+
)
|
|
580
|
+
return
|
|
581
|
+
|
|
582
|
+
self._update_child_thinking(
|
|
583
|
+
step=step,
|
|
584
|
+
server_time=server_time,
|
|
585
|
+
status_hint=status_hint,
|
|
586
|
+
now_monotonic=now_monotonic,
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
def _update_anchor_thinking(
|
|
590
|
+
self,
|
|
591
|
+
*,
|
|
592
|
+
step: Step,
|
|
593
|
+
server_time: float | None,
|
|
594
|
+
status_hint: str,
|
|
595
|
+
now_monotonic: float,
|
|
596
|
+
) -> None:
|
|
597
|
+
"""Handle deterministic thinking bookkeeping for agent/delegate anchors."""
|
|
598
|
+
scope = self._get_or_create_scope(step)
|
|
599
|
+
if scope.anchor_started_at is None and server_time is not None:
|
|
600
|
+
scope.anchor_started_at = server_time
|
|
601
|
+
|
|
602
|
+
if not scope.closed and scope.active_thinking_id is None:
|
|
603
|
+
self._start_scope_thinking(
|
|
604
|
+
scope,
|
|
605
|
+
start_server_time=scope.anchor_started_at or server_time,
|
|
606
|
+
start_monotonic=now_monotonic,
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
is_anchor_finished = status_hint in FINISHED_STATUS_HINTS or (not status_hint and is_step_finished(step))
|
|
610
|
+
if is_anchor_finished:
|
|
611
|
+
scope.anchor_finished_at = server_time or scope.anchor_finished_at
|
|
612
|
+
self._finish_scope_thinking(scope, server_time, now_monotonic)
|
|
613
|
+
scope.closed = True
|
|
614
|
+
|
|
615
|
+
parent_anchor_id = self._resolve_anchor_id(step)
|
|
616
|
+
if parent_anchor_id:
|
|
617
|
+
self._cascade_anchor_update(
|
|
618
|
+
parent_anchor_id=parent_anchor_id,
|
|
619
|
+
child_step=step,
|
|
620
|
+
server_time=server_time,
|
|
621
|
+
now_monotonic=now_monotonic,
|
|
622
|
+
is_finished=is_anchor_finished,
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
def _cascade_anchor_update(
|
|
626
|
+
self,
|
|
627
|
+
*,
|
|
628
|
+
parent_anchor_id: str,
|
|
629
|
+
child_step: Step,
|
|
630
|
+
server_time: float | None,
|
|
631
|
+
now_monotonic: float,
|
|
632
|
+
is_finished: bool,
|
|
633
|
+
) -> None:
|
|
634
|
+
"""Propagate anchor state changes to the parent scope."""
|
|
635
|
+
parent_scope = self._thinking_scopes.get(parent_anchor_id)
|
|
636
|
+
if not parent_scope or parent_scope.closed:
|
|
637
|
+
return
|
|
638
|
+
if is_finished:
|
|
639
|
+
self._mark_child_finished(parent_scope, child_step.step_id, server_time, now_monotonic)
|
|
640
|
+
else:
|
|
641
|
+
self._mark_child_running(parent_scope, child_step, server_time, now_monotonic)
|
|
642
|
+
|
|
643
|
+
def _update_child_thinking(
|
|
644
|
+
self,
|
|
645
|
+
*,
|
|
646
|
+
step: Step,
|
|
647
|
+
server_time: float | None,
|
|
648
|
+
status_hint: str,
|
|
649
|
+
now_monotonic: float,
|
|
650
|
+
) -> None:
|
|
651
|
+
"""Update deterministic thinking state for non-anchor steps."""
|
|
652
|
+
anchor_id = self._resolve_anchor_id(step)
|
|
653
|
+
if not anchor_id:
|
|
654
|
+
return
|
|
655
|
+
|
|
656
|
+
scope = self._thinking_scopes.get(anchor_id)
|
|
657
|
+
if not scope or scope.closed or step.kind == "thinking":
|
|
658
|
+
return
|
|
659
|
+
|
|
660
|
+
is_finish_event = status_hint in FINISHED_STATUS_HINTS or (not status_hint and is_step_finished(step))
|
|
661
|
+
if is_finish_event:
|
|
662
|
+
self._mark_child_finished(scope, step.step_id, server_time, now_monotonic)
|
|
663
|
+
else:
|
|
664
|
+
self._mark_child_running(scope, step, server_time, now_monotonic)
|
|
665
|
+
|
|
666
|
+
def _resolve_anchor_id(self, step: Step) -> str | None:
|
|
667
|
+
"""Return the nearest agent/delegate ancestor for a step."""
|
|
668
|
+
parent_id = step.parent_id
|
|
669
|
+
while parent_id:
|
|
670
|
+
parent = self.steps.by_id.get(parent_id)
|
|
671
|
+
if not parent:
|
|
672
|
+
return None
|
|
673
|
+
if self._is_scope_anchor(parent):
|
|
674
|
+
return parent.step_id
|
|
675
|
+
parent_id = parent.parent_id
|
|
676
|
+
return None
|
|
677
|
+
|
|
678
|
+
def _get_or_create_scope(self, step: Step) -> ThinkingScopeState:
|
|
679
|
+
"""Fetch (or create) thinking state for the given anchor step."""
|
|
680
|
+
scope = self._thinking_scopes.get(step.step_id)
|
|
681
|
+
if scope:
|
|
682
|
+
if scope.task_id is None:
|
|
683
|
+
scope.task_id = step.task_id
|
|
684
|
+
if scope.context_id is None:
|
|
685
|
+
scope.context_id = step.context_id
|
|
686
|
+
return scope
|
|
687
|
+
scope = ThinkingScopeState(
|
|
688
|
+
anchor_id=step.step_id,
|
|
689
|
+
task_id=step.task_id,
|
|
690
|
+
context_id=step.context_id,
|
|
691
|
+
)
|
|
692
|
+
self._thinking_scopes[step.step_id] = scope
|
|
693
|
+
return scope
|
|
694
|
+
|
|
695
|
+
def _is_scope_anchor(self, step: Step) -> bool:
|
|
696
|
+
"""Return True when a step should host its own thinking timeline."""
|
|
697
|
+
if step.kind in {"agent", "delegate"}:
|
|
698
|
+
return True
|
|
699
|
+
name = (step.name or "").lower()
|
|
700
|
+
return name.startswith(("delegate_to_", "delegate_", "delegate "))
|
|
701
|
+
|
|
702
|
+
def _start_scope_thinking(
|
|
703
|
+
self,
|
|
704
|
+
scope: ThinkingScopeState,
|
|
705
|
+
*,
|
|
706
|
+
start_server_time: float | None,
|
|
707
|
+
start_monotonic: float,
|
|
708
|
+
) -> None:
|
|
709
|
+
"""Open a deterministic thinking node beneath the scope anchor."""
|
|
710
|
+
if scope.closed or scope.active_thinking_id or not scope.anchor_id:
|
|
711
|
+
return
|
|
712
|
+
step = self.steps.start_or_get(
|
|
713
|
+
task_id=scope.task_id,
|
|
714
|
+
context_id=scope.context_id,
|
|
715
|
+
kind="thinking",
|
|
716
|
+
name=f"agent_thinking_step::{scope.anchor_id}",
|
|
717
|
+
parent_id=scope.anchor_id,
|
|
718
|
+
args={"reason": "deterministic_timeline"},
|
|
719
|
+
)
|
|
720
|
+
step.display_label = "💭 Thinking…"
|
|
721
|
+
step.status_icon = "spinner"
|
|
722
|
+
scope.active_thinking_id = step.step_id
|
|
723
|
+
scope.idle_started_at = start_server_time
|
|
724
|
+
scope.idle_started_monotonic = start_monotonic
|
|
725
|
+
|
|
726
|
+
def _finish_scope_thinking(
|
|
727
|
+
self,
|
|
728
|
+
scope: ThinkingScopeState,
|
|
729
|
+
end_server_time: float | None,
|
|
730
|
+
end_monotonic: float,
|
|
731
|
+
) -> None:
|
|
732
|
+
"""Close the currently running thinking node if one exists."""
|
|
733
|
+
if not scope.active_thinking_id:
|
|
734
|
+
return
|
|
735
|
+
thinking_step = self.steps.by_id.get(scope.active_thinking_id)
|
|
736
|
+
if not thinking_step:
|
|
737
|
+
scope.active_thinking_id = None
|
|
738
|
+
scope.idle_started_at = None
|
|
739
|
+
scope.idle_started_monotonic = None
|
|
740
|
+
return
|
|
741
|
+
|
|
742
|
+
duration = self._calculate_timeline_duration(
|
|
743
|
+
scope.idle_started_at,
|
|
744
|
+
end_server_time,
|
|
745
|
+
scope.idle_started_monotonic,
|
|
746
|
+
end_monotonic,
|
|
747
|
+
)
|
|
748
|
+
thinking_step.display_label = thinking_step.display_label or "💭 Thinking…"
|
|
749
|
+
if duration is not None:
|
|
750
|
+
thinking_step.finish(duration, source="timeline")
|
|
751
|
+
else:
|
|
752
|
+
thinking_step.finish(None, source="timeline")
|
|
753
|
+
thinking_step.status_icon = "success"
|
|
754
|
+
scope.active_thinking_id = None
|
|
755
|
+
scope.idle_started_at = None
|
|
756
|
+
scope.idle_started_monotonic = None
|
|
757
|
+
|
|
758
|
+
def _mark_child_running(
|
|
759
|
+
self,
|
|
760
|
+
scope: ThinkingScopeState,
|
|
761
|
+
step: Step,
|
|
762
|
+
server_time: float | None,
|
|
763
|
+
now_monotonic: float,
|
|
764
|
+
) -> None:
|
|
765
|
+
"""Mark a direct child as running and close any open thinking node."""
|
|
766
|
+
if step.step_id in scope.running_children:
|
|
767
|
+
return
|
|
768
|
+
scope.running_children.add(step.step_id)
|
|
769
|
+
if not scope.active_thinking_id:
|
|
770
|
+
return
|
|
771
|
+
|
|
772
|
+
start_server = self._step_server_start_times.get(step.step_id)
|
|
773
|
+
if start_server is None:
|
|
774
|
+
start_server = server_time
|
|
775
|
+
self._finish_scope_thinking(scope, start_server, now_monotonic)
|
|
776
|
+
|
|
777
|
+
def _mark_child_finished(
|
|
778
|
+
self,
|
|
779
|
+
scope: ThinkingScopeState,
|
|
780
|
+
step_id: str,
|
|
781
|
+
server_time: float | None,
|
|
782
|
+
now_monotonic: float,
|
|
783
|
+
) -> None:
|
|
784
|
+
"""Handle completion for a scope child and resume thinking if idle."""
|
|
785
|
+
if step_id in scope.running_children:
|
|
786
|
+
scope.running_children.discard(step_id)
|
|
787
|
+
if scope.running_children or scope.closed:
|
|
788
|
+
return
|
|
789
|
+
self._start_scope_thinking(
|
|
790
|
+
scope,
|
|
791
|
+
start_server_time=server_time,
|
|
792
|
+
start_monotonic=now_monotonic,
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
def _close_active_thinking_scopes(self, server_time: float | None) -> None:
|
|
796
|
+
"""Finish any in-flight thinking nodes during finalization."""
|
|
797
|
+
now = monotonic()
|
|
798
|
+
for scope in self._thinking_scopes.values():
|
|
799
|
+
if not scope.active_thinking_id:
|
|
800
|
+
continue
|
|
801
|
+
self._finish_scope_thinking(scope, server_time, now)
|
|
802
|
+
scope.closed = True
|
|
803
|
+
# Parent scopes resume thinking via _cascade_anchor_update
|
|
804
|
+
|
|
805
|
+
def _apply_root_duration(self, duration_seconds: float | None) -> None:
|
|
806
|
+
"""Propagate the final run duration to the root agent step."""
|
|
807
|
+
if duration_seconds is None or not self._root_agent_step_id:
|
|
808
|
+
return
|
|
809
|
+
root_step = self.steps.by_id.get(self._root_agent_step_id)
|
|
810
|
+
if not root_step:
|
|
811
|
+
return
|
|
812
|
+
try:
|
|
813
|
+
duration_ms = max(0, int(round(float(duration_seconds) * 1000)))
|
|
814
|
+
except Exception:
|
|
815
|
+
return
|
|
816
|
+
root_step.duration_ms = duration_ms
|
|
817
|
+
root_step.duration_source = root_step.duration_source or "run"
|
|
818
|
+
root_step.status = "finished"
|
|
819
|
+
|
|
820
|
+
@staticmethod
|
|
821
|
+
def _coerce_server_time(value: Any) -> float | None:
|
|
822
|
+
"""Convert a raw SSE time payload into a float if possible."""
|
|
823
|
+
if isinstance(value, (int, float)):
|
|
824
|
+
return float(value)
|
|
825
|
+
try:
|
|
826
|
+
return float(value)
|
|
827
|
+
except (TypeError, ValueError):
|
|
828
|
+
return None
|
|
829
|
+
|
|
830
|
+
@staticmethod
|
|
831
|
+
def _calculate_timeline_duration(
|
|
832
|
+
start_server: float | None,
|
|
833
|
+
end_server: float | None,
|
|
834
|
+
start_monotonic: float | None,
|
|
835
|
+
end_monotonic: float,
|
|
836
|
+
) -> float | None:
|
|
837
|
+
"""Pick the most reliable pair of timestamps to derive duration seconds."""
|
|
838
|
+
if start_server is not None and end_server is not None:
|
|
839
|
+
return max(0.0, float(end_server) - float(start_server))
|
|
840
|
+
if start_monotonic is not None:
|
|
841
|
+
try:
|
|
842
|
+
return max(0.0, float(end_monotonic) - float(start_monotonic))
|
|
843
|
+
except Exception:
|
|
844
|
+
return None
|
|
845
|
+
return None
|
|
846
|
+
|
|
847
|
+
@staticmethod
|
|
848
|
+
def _humanize_agent_slug(value: Any) -> str | None:
|
|
849
|
+
"""Convert a slugified agent name into Title Case."""
|
|
850
|
+
if not isinstance(value, str):
|
|
851
|
+
return None
|
|
852
|
+
cleaned = value.replace("_", " ").replace("-", " ").strip()
|
|
853
|
+
if not cleaned:
|
|
854
|
+
return None
|
|
855
|
+
parts = [part for part in cleaned.split() if part]
|
|
856
|
+
return " ".join(part[:1].upper() + part[1:] for part in parts)
|
|
857
|
+
|
|
348
858
|
def _finish_running_steps(self) -> None:
|
|
349
859
|
"""Mark any running steps as finished to avoid lingering spinners."""
|
|
350
860
|
for st in self.steps.by_id.values():
|
|
351
861
|
if not is_step_finished(st):
|
|
352
|
-
|
|
862
|
+
self._mark_incomplete_step(st)
|
|
863
|
+
|
|
864
|
+
def _mark_incomplete_step(self, step: Step) -> None:
|
|
865
|
+
"""Mark a lingering step as incomplete/warning with unknown duration."""
|
|
866
|
+
step.status = "finished"
|
|
867
|
+
step.duration_unknown = True
|
|
868
|
+
if step.duration_ms is None:
|
|
869
|
+
step.duration_ms = 0
|
|
870
|
+
step.duration_source = step.duration_source or "unknown"
|
|
871
|
+
step.status_icon = "warning"
|
|
353
872
|
|
|
354
873
|
def _finish_tool_panels(self) -> None:
|
|
355
874
|
"""Mark unfinished tool panels as finished."""
|
|
@@ -376,11 +895,14 @@ class RichStreamRenderer:
|
|
|
376
895
|
if not body:
|
|
377
896
|
return
|
|
378
897
|
|
|
898
|
+
if getattr(self, "_transcript_mode_enabled", False):
|
|
899
|
+
return
|
|
900
|
+
|
|
379
901
|
if self.verbose:
|
|
380
902
|
final_panel = create_final_panel(
|
|
381
903
|
body,
|
|
382
904
|
title=self._final_panel_title(),
|
|
383
|
-
theme=
|
|
905
|
+
theme=DEFAULT_RENDERER_THEME,
|
|
384
906
|
)
|
|
385
907
|
self.console.print(final_panel)
|
|
386
908
|
self.state.printed_final_output = True
|
|
@@ -389,25 +911,37 @@ class RichStreamRenderer:
|
|
|
389
911
|
"""Handle completion event."""
|
|
390
912
|
self.state.finalizing_ui = True
|
|
391
913
|
|
|
392
|
-
|
|
914
|
+
self._handle_stats_duration(stats)
|
|
915
|
+
self._close_active_thinking_scopes(self.state.final_duration_seconds)
|
|
916
|
+
self._cleanup_ui_elements()
|
|
917
|
+
self._finalize_display()
|
|
918
|
+
self._print_completion_message()
|
|
919
|
+
|
|
920
|
+
def _handle_stats_duration(self, stats: RunStats) -> None:
|
|
921
|
+
"""Handle stats processing and duration calculation."""
|
|
922
|
+
if not isinstance(stats, RunStats):
|
|
923
|
+
return
|
|
924
|
+
|
|
925
|
+
duration = None
|
|
926
|
+
try:
|
|
927
|
+
if stats.finished_at is not None and stats.started_at is not None:
|
|
928
|
+
duration = max(0.0, float(stats.finished_at) - float(stats.started_at))
|
|
929
|
+
except Exception:
|
|
393
930
|
duration = None
|
|
394
|
-
try:
|
|
395
|
-
if stats.finished_at is not None and stats.started_at is not None:
|
|
396
|
-
duration = max(
|
|
397
|
-
0.0, float(stats.finished_at) - float(stats.started_at)
|
|
398
|
-
)
|
|
399
|
-
except Exception:
|
|
400
|
-
duration = None
|
|
401
931
|
|
|
402
|
-
|
|
403
|
-
|
|
932
|
+
if duration is not None:
|
|
933
|
+
self._update_final_duration(duration, overwrite=True)
|
|
404
934
|
|
|
935
|
+
def _cleanup_ui_elements(self) -> None:
|
|
936
|
+
"""Clean up running UI elements."""
|
|
405
937
|
# Mark any running steps as finished to avoid lingering spinners
|
|
406
938
|
self._finish_running_steps()
|
|
407
939
|
|
|
408
940
|
# Mark unfinished tool panels as finished
|
|
409
941
|
self._finish_tool_panels()
|
|
410
942
|
|
|
943
|
+
def _finalize_display(self) -> None:
|
|
944
|
+
"""Finalize live display and render final output."""
|
|
411
945
|
# Final refresh
|
|
412
946
|
self._ensure_live()
|
|
413
947
|
|
|
@@ -417,8 +951,24 @@ class RichStreamRenderer:
|
|
|
417
951
|
# Render final output based on configuration
|
|
418
952
|
self._print_final_panel_if_needed()
|
|
419
953
|
|
|
954
|
+
def _print_completion_message(self) -> None:
|
|
955
|
+
"""Print completion message based on current mode."""
|
|
956
|
+
if self._transcript_mode_enabled:
|
|
957
|
+
try:
|
|
958
|
+
self.console.print(
|
|
959
|
+
"[dim]Run finished. Press Ctrl+T to return to the summary view or stay here to inspect events. "
|
|
960
|
+
"Use the post-run viewer for export.[/dim]"
|
|
961
|
+
)
|
|
962
|
+
except Exception:
|
|
963
|
+
pass
|
|
964
|
+
else:
|
|
965
|
+
# No transcript toggle in summary mode; nothing to print here.
|
|
966
|
+
return
|
|
967
|
+
|
|
420
968
|
def _ensure_live(self) -> None:
|
|
421
969
|
"""Ensure live display is updated."""
|
|
970
|
+
if getattr(self, "_transcript_mode_enabled", False):
|
|
971
|
+
return
|
|
422
972
|
if not self._ensure_live_stack():
|
|
423
973
|
return
|
|
424
974
|
|
|
@@ -426,6 +976,12 @@ class RichStreamRenderer:
|
|
|
426
976
|
|
|
427
977
|
if self.live:
|
|
428
978
|
self._refresh_live_panels()
|
|
979
|
+
if (
|
|
980
|
+
not self._transcript_mode_enabled
|
|
981
|
+
and not self.state.finalizing_ui
|
|
982
|
+
and not self._summary_hint_printed_once
|
|
983
|
+
):
|
|
984
|
+
self._print_summary_hint(force=True)
|
|
429
985
|
|
|
430
986
|
def _ensure_live_stack(self) -> bool:
|
|
431
987
|
"""Guarantee the console exposes the internal live stack Rich expects."""
|
|
@@ -472,8 +1028,7 @@ class RichStreamRenderer:
|
|
|
472
1028
|
title="Steps",
|
|
473
1029
|
border_style="blue",
|
|
474
1030
|
)
|
|
475
|
-
|
|
476
|
-
panels = self._build_live_panels(main_panel, steps_panel, tool_panels)
|
|
1031
|
+
panels = self._build_live_panels(main_panel, steps_panel)
|
|
477
1032
|
|
|
478
1033
|
self.live.update(Group(*panels))
|
|
479
1034
|
|
|
@@ -481,17 +1036,12 @@ class RichStreamRenderer:
|
|
|
481
1036
|
self,
|
|
482
1037
|
main_panel: Any,
|
|
483
1038
|
steps_panel: Any,
|
|
484
|
-
tool_panels: list[Any],
|
|
485
1039
|
) -> list[Any]:
|
|
486
1040
|
"""Assemble the panel order for the live display."""
|
|
487
1041
|
if self.verbose:
|
|
488
|
-
return [main_panel, steps_panel
|
|
1042
|
+
return [main_panel, steps_panel]
|
|
489
1043
|
|
|
490
|
-
|
|
491
|
-
if tool_panels:
|
|
492
|
-
panels.extend(tool_panels)
|
|
493
|
-
panels.append(main_panel)
|
|
494
|
-
return panels
|
|
1044
|
+
return [steps_panel, main_panel]
|
|
495
1045
|
|
|
496
1046
|
def _render_main_panel(self) -> Any:
|
|
497
1047
|
"""Render the main content panel."""
|
|
@@ -503,11 +1053,11 @@ class RichStreamRenderer:
|
|
|
503
1053
|
return create_final_panel(
|
|
504
1054
|
final_content,
|
|
505
1055
|
title=title,
|
|
506
|
-
theme=
|
|
1056
|
+
theme=DEFAULT_RENDERER_THEME,
|
|
507
1057
|
)
|
|
508
1058
|
# Dynamic title with spinner + elapsed/hints
|
|
509
1059
|
title = self._format_enhanced_main_title()
|
|
510
|
-
return create_main_panel(body, title,
|
|
1060
|
+
return create_main_panel(body, title, DEFAULT_RENDERER_THEME)
|
|
511
1061
|
|
|
512
1062
|
def _final_panel_title(self) -> str:
|
|
513
1063
|
"""Compose title for the final result panel including duration."""
|
|
@@ -522,8 +1072,6 @@ class RichStreamRenderer:
|
|
|
522
1072
|
return
|
|
523
1073
|
|
|
524
1074
|
self.verbose = verbose
|
|
525
|
-
self.cfg.style = "debug" if verbose else "pretty"
|
|
526
|
-
|
|
527
1075
|
desired_live = not verbose
|
|
528
1076
|
if desired_live != self.cfg.live:
|
|
529
1077
|
self.cfg.live = desired_live
|
|
@@ -538,9 +1086,123 @@ class RichStreamRenderer:
|
|
|
538
1086
|
# ------------------------------------------------------------------
|
|
539
1087
|
# Transcript helpers
|
|
540
1088
|
# ------------------------------------------------------------------
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
1089
|
+
@property
|
|
1090
|
+
def transcript_mode_enabled(self) -> bool:
|
|
1091
|
+
"""Return True when transcript mode is currently active."""
|
|
1092
|
+
return self._transcript_mode_enabled
|
|
1093
|
+
|
|
1094
|
+
def toggle_transcript_mode(self) -> None:
|
|
1095
|
+
"""Flip transcript mode on/off."""
|
|
1096
|
+
self.set_transcript_mode(not self._transcript_mode_enabled)
|
|
1097
|
+
|
|
1098
|
+
def set_transcript_mode(self, enabled: bool) -> None:
|
|
1099
|
+
"""Set transcript mode explicitly."""
|
|
1100
|
+
if enabled == self._transcript_mode_enabled:
|
|
1101
|
+
return
|
|
1102
|
+
|
|
1103
|
+
self._transcript_mode_enabled = enabled
|
|
1104
|
+
self.apply_verbosity(enabled)
|
|
1105
|
+
|
|
1106
|
+
if enabled:
|
|
1107
|
+
self._summary_hint_printed_once = False
|
|
1108
|
+
self._transcript_hint_printed_once = False
|
|
1109
|
+
self._transcript_header_printed = False
|
|
1110
|
+
self._transcript_enabled_message_printed = False
|
|
1111
|
+
self._stop_live_display()
|
|
1112
|
+
self._clear_console_safe()
|
|
1113
|
+
self._print_transcript_enabled_message()
|
|
1114
|
+
self._render_transcript_backfill()
|
|
1115
|
+
else:
|
|
1116
|
+
self._transcript_hint_printed_once = False
|
|
1117
|
+
self._transcript_header_printed = False
|
|
1118
|
+
self._transcript_enabled_message_printed = False
|
|
1119
|
+
self._clear_console_safe()
|
|
1120
|
+
self._render_summary_static_sections()
|
|
1121
|
+
summary_notice = (
|
|
1122
|
+
"[dim]Returning to the summary view. Streaming will continue here.[/dim]"
|
|
1123
|
+
if not self.state.finalizing_ui
|
|
1124
|
+
else "[dim]Returning to the summary view.[/dim]"
|
|
1125
|
+
)
|
|
1126
|
+
self.console.print(summary_notice)
|
|
1127
|
+
self._render_summary_after_transcript_toggle()
|
|
1128
|
+
if not self.state.finalizing_ui:
|
|
1129
|
+
self._print_summary_hint(force=True)
|
|
1130
|
+
|
|
1131
|
+
def _clear_console_safe(self) -> None:
|
|
1132
|
+
"""Best-effort console clear that ignores platform quirks."""
|
|
1133
|
+
try:
|
|
1134
|
+
self.console.clear()
|
|
1135
|
+
except Exception:
|
|
1136
|
+
pass
|
|
1137
|
+
|
|
1138
|
+
def _print_transcript_hint(self) -> None:
|
|
1139
|
+
"""Render the transcript toggle hint, keeping it near the bottom."""
|
|
1140
|
+
if not self._transcript_mode_enabled:
|
|
1141
|
+
return
|
|
1142
|
+
try:
|
|
1143
|
+
self.console.print(self._transcript_hint_message)
|
|
1144
|
+
except Exception:
|
|
1145
|
+
pass
|
|
1146
|
+
else:
|
|
1147
|
+
self._transcript_hint_printed_once = True
|
|
1148
|
+
|
|
1149
|
+
def _print_transcript_enabled_message(self) -> None:
|
|
1150
|
+
if self._transcript_enabled_message_printed:
|
|
1151
|
+
return
|
|
1152
|
+
try:
|
|
1153
|
+
self.console.print("[dim]Transcript mode enabled — streaming raw transcript events.[/dim]")
|
|
1154
|
+
except Exception:
|
|
1155
|
+
pass
|
|
1156
|
+
else:
|
|
1157
|
+
self._transcript_enabled_message_printed = True
|
|
1158
|
+
|
|
1159
|
+
def _ensure_transcript_header(self) -> None:
|
|
1160
|
+
if self._transcript_header_printed:
|
|
1161
|
+
return
|
|
1162
|
+
try:
|
|
1163
|
+
self.console.rule("Transcript Events")
|
|
1164
|
+
except Exception:
|
|
1165
|
+
self._transcript_header_printed = True
|
|
1166
|
+
return
|
|
1167
|
+
self._transcript_header_printed = True
|
|
1168
|
+
|
|
1169
|
+
def _print_summary_hint(self, force: bool = False) -> None:
|
|
1170
|
+
"""Show the summary-mode toggle hint."""
|
|
1171
|
+
controller = getattr(self, "transcript_controller", None)
|
|
1172
|
+
if controller and not getattr(controller, "enabled", False):
|
|
1173
|
+
if not force:
|
|
1174
|
+
self._summary_hint_printed_once = True
|
|
1175
|
+
return
|
|
1176
|
+
if not force and self._summary_hint_printed_once:
|
|
1177
|
+
return
|
|
1178
|
+
try:
|
|
1179
|
+
self.console.print(self._summary_hint_message)
|
|
1180
|
+
except Exception:
|
|
1181
|
+
return
|
|
1182
|
+
self._summary_hint_printed_once = True
|
|
1183
|
+
|
|
1184
|
+
def _render_transcript_backfill(self) -> None:
|
|
1185
|
+
"""Render any captured events that haven't been shown in transcript mode."""
|
|
1186
|
+
pending = self.state.events[self._transcript_render_cursor :]
|
|
1187
|
+
self._ensure_transcript_header()
|
|
1188
|
+
if not pending:
|
|
1189
|
+
self._print_transcript_hint()
|
|
1190
|
+
return
|
|
1191
|
+
|
|
1192
|
+
baseline = self.state.streaming_started_event_ts
|
|
1193
|
+
for ev in pending:
|
|
1194
|
+
received_ts = _coerce_received_at(ev.get("received_at"))
|
|
1195
|
+
render_debug_event(
|
|
1196
|
+
ev,
|
|
1197
|
+
self.console,
|
|
1198
|
+
received_ts=received_ts,
|
|
1199
|
+
baseline_ts=baseline,
|
|
1200
|
+
)
|
|
1201
|
+
|
|
1202
|
+
self._transcript_render_cursor = len(self.state.events)
|
|
1203
|
+
self._print_transcript_hint()
|
|
1204
|
+
|
|
1205
|
+
def _capture_event(self, ev: dict[str, Any], received_at: datetime | None = None) -> None:
|
|
544
1206
|
"""Capture a deep copy of SSE events for transcript replay."""
|
|
545
1207
|
try:
|
|
546
1208
|
captured = json.loads(json.dumps(ev))
|
|
@@ -557,6 +1219,8 @@ class RichStreamRenderer:
|
|
|
557
1219
|
captured["received_at"] = repr(received_at)
|
|
558
1220
|
|
|
559
1221
|
self.state.events.append(captured)
|
|
1222
|
+
if self._transcript_mode_enabled:
|
|
1223
|
+
self._transcript_render_cursor = len(self.state.events)
|
|
560
1224
|
|
|
561
1225
|
def get_aggregated_output(self) -> str:
|
|
562
1226
|
"""Return the concatenated assistant output collected so far."""
|
|
@@ -566,16 +1230,7 @@ class RichStreamRenderer:
|
|
|
566
1230
|
"""Return captured SSE events."""
|
|
567
1231
|
return list(self.state.events)
|
|
568
1232
|
|
|
569
|
-
def
|
|
570
|
-
self, task_id: str | None, context_id: str | None
|
|
571
|
-
) -> None:
|
|
572
|
-
"""Insert thinking gap if needed."""
|
|
573
|
-
# Implementation would track thinking states
|
|
574
|
-
pass
|
|
575
|
-
|
|
576
|
-
def _ensure_tool_panel(
|
|
577
|
-
self, name: str, args: Any, task_id: str, context_id: str
|
|
578
|
-
) -> str:
|
|
1233
|
+
def _ensure_tool_panel(self, name: str, args: Any, task_id: str, context_id: str) -> str:
|
|
579
1234
|
"""Ensure a tool panel exists and return its ID."""
|
|
580
1235
|
formatted_title = format_tool_title(name)
|
|
581
1236
|
is_delegation = is_delegation_tool(name)
|
|
@@ -595,15 +1250,10 @@ class RichStreamRenderer:
|
|
|
595
1250
|
# Add Args section once
|
|
596
1251
|
if args:
|
|
597
1252
|
try:
|
|
598
|
-
args_content = (
|
|
599
|
-
"**Args:**\n```json\n"
|
|
600
|
-
+ json.dumps(args, indent=2)
|
|
601
|
-
+ "\n```\n\n"
|
|
602
|
-
)
|
|
1253
|
+
args_content = "**Args:**\n```json\n" + json.dumps(args, indent=2) + "\n```\n\n"
|
|
603
1254
|
except Exception:
|
|
604
1255
|
args_content = f"**Args:**\n{args}\n\n"
|
|
605
1256
|
self.tool_panels[tool_sid]["chunks"].append(args_content)
|
|
606
|
-
self.tool_order.append(tool_sid)
|
|
607
1257
|
|
|
608
1258
|
return tool_sid
|
|
609
1259
|
|
|
@@ -614,8 +1264,13 @@ class RichStreamRenderer:
|
|
|
614
1264
|
tool_name: str,
|
|
615
1265
|
tool_args: Any,
|
|
616
1266
|
_tool_sid: str,
|
|
1267
|
+
*,
|
|
1268
|
+
tracked_step: Step | None = None,
|
|
617
1269
|
) -> Step | None:
|
|
618
1270
|
"""Start or get a step for a tool."""
|
|
1271
|
+
if tracked_step is not None:
|
|
1272
|
+
return tracked_step
|
|
1273
|
+
|
|
619
1274
|
if is_delegation_tool(tool_name):
|
|
620
1275
|
st = self.steps.start_or_get(
|
|
621
1276
|
task_id=task_id,
|
|
@@ -635,9 +1290,7 @@ class RichStreamRenderer:
|
|
|
635
1290
|
|
|
636
1291
|
# Record server start time for this step if available
|
|
637
1292
|
if st and self.stream_processor.server_elapsed_time is not None:
|
|
638
|
-
self._step_server_start_times[st.step_id] =
|
|
639
|
-
self.stream_processor.server_elapsed_time
|
|
640
|
-
)
|
|
1293
|
+
self._step_server_start_times[st.step_id] = self.stream_processor.server_elapsed_time
|
|
641
1294
|
|
|
642
1295
|
return st
|
|
643
1296
|
|
|
@@ -651,26 +1304,18 @@ class RichStreamRenderer:
|
|
|
651
1304
|
"""Process additional tool calls to avoid duplicates."""
|
|
652
1305
|
for call_name, call_args, _ in tool_calls_info or []:
|
|
653
1306
|
if call_name and call_name != tool_name:
|
|
654
|
-
self._process_single_tool_call(
|
|
655
|
-
call_name, call_args, task_id, context_id
|
|
656
|
-
)
|
|
1307
|
+
self._process_single_tool_call(call_name, call_args, task_id, context_id)
|
|
657
1308
|
|
|
658
|
-
def _process_single_tool_call(
|
|
659
|
-
self, call_name: str, call_args: Any, task_id: str, context_id: str
|
|
660
|
-
) -> None:
|
|
1309
|
+
def _process_single_tool_call(self, call_name: str, call_args: Any, task_id: str, context_id: str) -> None:
|
|
661
1310
|
"""Process a single additional tool call."""
|
|
662
1311
|
self._ensure_tool_panel(call_name, call_args, task_id, context_id)
|
|
663
1312
|
|
|
664
1313
|
st2 = self._create_step_for_tool_call(call_name, call_args, task_id, context_id)
|
|
665
1314
|
|
|
666
1315
|
if self.stream_processor.server_elapsed_time is not None and st2:
|
|
667
|
-
self._step_server_start_times[st2.step_id] =
|
|
668
|
-
self.stream_processor.server_elapsed_time
|
|
669
|
-
)
|
|
1316
|
+
self._step_server_start_times[st2.step_id] = self.stream_processor.server_elapsed_time
|
|
670
1317
|
|
|
671
|
-
def _create_step_for_tool_call(
|
|
672
|
-
self, call_name: str, call_args: Any, task_id: str, context_id: str
|
|
673
|
-
) -> Any:
|
|
1318
|
+
def _create_step_for_tool_call(self, call_name: str, call_args: Any, task_id: str, context_id: str) -> Any:
|
|
674
1319
|
"""Create appropriate step for tool call."""
|
|
675
1320
|
if is_delegation_tool(call_name):
|
|
676
1321
|
return self.steps.start_or_get(
|
|
@@ -689,9 +1334,7 @@ class RichStreamRenderer:
|
|
|
689
1334
|
args=call_args,
|
|
690
1335
|
)
|
|
691
1336
|
|
|
692
|
-
def _detect_tool_completion(
|
|
693
|
-
self, metadata: dict, content: str
|
|
694
|
-
) -> tuple[bool, str | None, Any]:
|
|
1337
|
+
def _detect_tool_completion(self, metadata: dict, content: str) -> tuple[bool, str | None, Any]:
|
|
695
1338
|
"""Detect if a tool has completed and return completion info."""
|
|
696
1339
|
tool_info = metadata.get("tool_info", {}) if isinstance(metadata, dict) else {}
|
|
697
1340
|
|
|
@@ -701,18 +1344,14 @@ class RichStreamRenderer:
|
|
|
701
1344
|
# content like "Completed google_serper"
|
|
702
1345
|
tname = content.replace("Completed ", "").strip()
|
|
703
1346
|
if tname:
|
|
704
|
-
output = (
|
|
705
|
-
tool_info.get("output") if tool_info.get("name") == tname else None
|
|
706
|
-
)
|
|
1347
|
+
output = tool_info.get("output") if tool_info.get("name") == tname else None
|
|
707
1348
|
return True, tname, output
|
|
708
1349
|
elif metadata.get("status") == "finished" and tool_info.get("name"):
|
|
709
1350
|
return True, tool_info.get("name"), tool_info.get("output")
|
|
710
1351
|
|
|
711
1352
|
return False, None, None
|
|
712
1353
|
|
|
713
|
-
def _get_tool_session_id(
|
|
714
|
-
self, finished_tool_name: str, task_id: str, context_id: str
|
|
715
|
-
) -> str:
|
|
1354
|
+
def _get_tool_session_id(self, finished_tool_name: str, task_id: str, context_id: str) -> str:
|
|
716
1355
|
"""Generate tool session ID."""
|
|
717
1356
|
return f"tool_{finished_tool_name}_{task_id}_{context_id}"
|
|
718
1357
|
|
|
@@ -742,7 +1381,7 @@ class RichStreamRenderer:
|
|
|
742
1381
|
meta["duration_seconds"] = dur
|
|
743
1382
|
meta["server_finished_at"] = (
|
|
744
1383
|
self.stream_processor.server_elapsed_time
|
|
745
|
-
if isinstance(self.stream_processor.server_elapsed_time, int
|
|
1384
|
+
if isinstance(self.stream_processor.server_elapsed_time, (int, float))
|
|
746
1385
|
else None
|
|
747
1386
|
)
|
|
748
1387
|
meta["finished_at"] = monotonic()
|
|
@@ -752,9 +1391,7 @@ class RichStreamRenderer:
|
|
|
752
1391
|
) -> None:
|
|
753
1392
|
"""Add tool output to panel metadata."""
|
|
754
1393
|
if finished_tool_output is not None:
|
|
755
|
-
meta["chunks"].append(
|
|
756
|
-
self._format_output_block(finished_tool_output, finished_tool_name)
|
|
757
|
-
)
|
|
1394
|
+
meta["chunks"].append(self._format_output_block(finished_tool_output, finished_tool_name))
|
|
758
1395
|
meta["output"] = finished_tool_output
|
|
759
1396
|
|
|
760
1397
|
def _mark_panel_as_finished(self, meta: dict[str, Any], tool_sid: str) -> None:
|
|
@@ -784,9 +1421,7 @@ class RichStreamRenderer:
|
|
|
784
1421
|
self._mark_panel_as_finished(meta, tool_sid)
|
|
785
1422
|
self._add_tool_output_to_panel(meta, finished_tool_output, finished_tool_name)
|
|
786
1423
|
|
|
787
|
-
def _get_step_duration(
|
|
788
|
-
self, finished_tool_name: str, task_id: str, context_id: str
|
|
789
|
-
) -> float | None:
|
|
1424
|
+
def _get_step_duration(self, finished_tool_name: str, task_id: str, context_id: str) -> float | None:
|
|
790
1425
|
"""Get step duration from tool panels."""
|
|
791
1426
|
tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
|
|
792
1427
|
return self.tool_panels.get(tool_sid, {}).get("duration_seconds")
|
|
@@ -833,8 +1468,13 @@ class RichStreamRenderer:
|
|
|
833
1468
|
finished_tool_output: Any,
|
|
834
1469
|
task_id: str,
|
|
835
1470
|
context_id: str,
|
|
1471
|
+
*,
|
|
1472
|
+
tracked_step: Step | None = None,
|
|
836
1473
|
) -> None:
|
|
837
1474
|
"""Finish the corresponding step for a completed tool."""
|
|
1475
|
+
if tracked_step is not None:
|
|
1476
|
+
return
|
|
1477
|
+
|
|
838
1478
|
step_duration = self._get_step_duration(finished_tool_name, task_id, context_id)
|
|
839
1479
|
|
|
840
1480
|
if is_delegation_tool(finished_tool_name):
|
|
@@ -856,9 +1496,7 @@ class RichStreamRenderer:
|
|
|
856
1496
|
|
|
857
1497
|
def _should_create_snapshot(self, tool_sid: str) -> bool:
|
|
858
1498
|
"""Check if a snapshot should be created."""
|
|
859
|
-
return self.cfg.append_finished_snapshots and not self.tool_panels.get(
|
|
860
|
-
tool_sid, {}
|
|
861
|
-
).get("snapshot_printed")
|
|
1499
|
+
return self.cfg.append_finished_snapshots and not self.tool_panels.get(tool_sid, {}).get("snapshot_printed")
|
|
862
1500
|
|
|
863
1501
|
def _get_snapshot_title(self, meta: dict[str, Any], finished_tool_name: str) -> str:
|
|
864
1502
|
"""Get the title for the snapshot."""
|
|
@@ -866,7 +1504,7 @@ class RichStreamRenderer:
|
|
|
866
1504
|
|
|
867
1505
|
# Add elapsed time to title
|
|
868
1506
|
dur = meta.get("duration_seconds")
|
|
869
|
-
if isinstance(dur, int
|
|
1507
|
+
if isinstance(dur, (int, float)):
|
|
870
1508
|
elapsed_str = self._format_snapshot_duration(dur)
|
|
871
1509
|
adjusted_title = f"{adjusted_title} · {elapsed_str}"
|
|
872
1510
|
|
|
@@ -903,15 +1541,13 @@ class RichStreamRenderer:
|
|
|
903
1541
|
|
|
904
1542
|
return body_text
|
|
905
1543
|
|
|
906
|
-
def _create_snapshot_panel(
|
|
907
|
-
self, adjusted_title: str, body_text: str, finished_tool_name: str
|
|
908
|
-
) -> Any:
|
|
1544
|
+
def _create_snapshot_panel(self, adjusted_title: str, body_text: str, finished_tool_name: str) -> Any:
|
|
909
1545
|
"""Create the snapshot panel."""
|
|
910
1546
|
return create_tool_panel(
|
|
911
1547
|
title=adjusted_title,
|
|
912
1548
|
content=body_text or "(no output)",
|
|
913
1549
|
status="finished",
|
|
914
|
-
theme=
|
|
1550
|
+
theme=DEFAULT_RENDERER_THEME,
|
|
915
1551
|
is_delegation=is_delegation_tool(finished_tool_name),
|
|
916
1552
|
)
|
|
917
1553
|
|
|
@@ -920,9 +1556,7 @@ class RichStreamRenderer:
|
|
|
920
1556
|
self.console.print(snapshot_panel)
|
|
921
1557
|
self.tool_panels[tool_sid]["snapshot_printed"] = True
|
|
922
1558
|
|
|
923
|
-
def _create_tool_snapshot(
|
|
924
|
-
self, finished_tool_name: str, task_id: str, context_id: str
|
|
925
|
-
) -> None:
|
|
1559
|
+
def _create_tool_snapshot(self, finished_tool_name: str, task_id: str, context_id: str) -> None:
|
|
926
1560
|
"""Create and print a snapshot for a finished tool."""
|
|
927
1561
|
tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
|
|
928
1562
|
|
|
@@ -936,9 +1570,7 @@ class RichStreamRenderer:
|
|
|
936
1570
|
body_text = "".join(meta.get("chunks") or [])
|
|
937
1571
|
body_text = self._clamp_snapshot_body(body_text)
|
|
938
1572
|
|
|
939
|
-
snapshot_panel = self._create_snapshot_panel(
|
|
940
|
-
adjusted_title, body_text, finished_tool_name
|
|
941
|
-
)
|
|
1573
|
+
snapshot_panel = self._create_snapshot_panel(adjusted_title, body_text, finished_tool_name)
|
|
942
1574
|
|
|
943
1575
|
self._print_and_mark_snapshot(tool_sid, snapshot_panel)
|
|
944
1576
|
|
|
@@ -949,24 +1581,29 @@ class RichStreamRenderer:
|
|
|
949
1581
|
tool_args: Any,
|
|
950
1582
|
_tool_out: Any,
|
|
951
1583
|
tool_calls_info: list[tuple[str, Any, Any]],
|
|
1584
|
+
*,
|
|
1585
|
+
tracked_step: Step | None = None,
|
|
952
1586
|
) -> None:
|
|
953
1587
|
"""Handle agent step event."""
|
|
954
1588
|
metadata = event.get("metadata", {})
|
|
955
|
-
task_id = event.get("task_id")
|
|
956
|
-
context_id = event.get("context_id")
|
|
1589
|
+
task_id = event.get("task_id") or metadata.get("task_id")
|
|
1590
|
+
context_id = event.get("context_id") or metadata.get("context_id")
|
|
957
1591
|
content = event.get("content", "")
|
|
958
1592
|
|
|
959
1593
|
# Create steps and panels for the primary tool
|
|
960
1594
|
if tool_name:
|
|
961
|
-
tool_sid = self._ensure_tool_panel(
|
|
962
|
-
|
|
1595
|
+
tool_sid = self._ensure_tool_panel(tool_name, tool_args, task_id, context_id)
|
|
1596
|
+
self._start_tool_step(
|
|
1597
|
+
task_id,
|
|
1598
|
+
context_id,
|
|
1599
|
+
tool_name,
|
|
1600
|
+
tool_args,
|
|
1601
|
+
tool_sid,
|
|
1602
|
+
tracked_step=tracked_step,
|
|
963
1603
|
)
|
|
964
|
-
self._start_tool_step(task_id, context_id, tool_name, tool_args, tool_sid)
|
|
965
1604
|
|
|
966
1605
|
# Handle additional tool calls
|
|
967
|
-
self._process_additional_tool_calls(
|
|
968
|
-
tool_calls_info, tool_name, task_id, context_id
|
|
969
|
-
)
|
|
1606
|
+
self._process_additional_tool_calls(tool_calls_info, tool_name, task_id, context_id)
|
|
970
1607
|
|
|
971
1608
|
# Check for tool completion
|
|
972
1609
|
(
|
|
@@ -976,11 +1613,13 @@ class RichStreamRenderer:
|
|
|
976
1613
|
) = self._detect_tool_completion(metadata, content)
|
|
977
1614
|
|
|
978
1615
|
if is_tool_finished and finished_tool_name:
|
|
979
|
-
self._finish_tool_panel(
|
|
980
|
-
finished_tool_name, finished_tool_output, task_id, context_id
|
|
981
|
-
)
|
|
1616
|
+
self._finish_tool_panel(finished_tool_name, finished_tool_output, task_id, context_id)
|
|
982
1617
|
self._finish_tool_step(
|
|
983
|
-
finished_tool_name,
|
|
1618
|
+
finished_tool_name,
|
|
1619
|
+
finished_tool_output,
|
|
1620
|
+
task_id,
|
|
1621
|
+
context_id,
|
|
1622
|
+
tracked_step=tracked_step,
|
|
984
1623
|
)
|
|
985
1624
|
self._create_tool_snapshot(finished_tool_name, task_id, context_id)
|
|
986
1625
|
|
|
@@ -1030,9 +1669,7 @@ class RichStreamRenderer:
|
|
|
1030
1669
|
|
|
1031
1670
|
def _get_analysis_progress_info(self) -> dict[str, Any]:
|
|
1032
1671
|
total_steps = len(self.steps.order)
|
|
1033
|
-
completed_steps = sum(
|
|
1034
|
-
1 for sid in self.steps.order if is_step_finished(self.steps.by_id[sid])
|
|
1035
|
-
)
|
|
1672
|
+
completed_steps = sum(1 for sid in self.steps.order if is_step_finished(self.steps.by_id[sid]))
|
|
1036
1673
|
current_step = None
|
|
1037
1674
|
for sid in self.steps.order:
|
|
1038
1675
|
if not is_step_finished(self.steps.by_id[sid]):
|
|
@@ -1040,13 +1677,11 @@ class RichStreamRenderer:
|
|
|
1040
1677
|
break
|
|
1041
1678
|
# Prefer server elapsed time when available
|
|
1042
1679
|
elapsed = 0.0
|
|
1043
|
-
if isinstance(self.stream_processor.server_elapsed_time, int
|
|
1680
|
+
if isinstance(self.stream_processor.server_elapsed_time, (int, float)):
|
|
1044
1681
|
elapsed = float(self.stream_processor.server_elapsed_time)
|
|
1045
1682
|
elif self._started_at is not None:
|
|
1046
1683
|
elapsed = monotonic() - self._started_at
|
|
1047
|
-
progress_percent = (
|
|
1048
|
-
int((completed_steps / total_steps) * 100) if total_steps else 0
|
|
1049
|
-
)
|
|
1684
|
+
progress_percent = int((completed_steps / total_steps) * 100) if total_steps else 0
|
|
1050
1685
|
return {
|
|
1051
1686
|
"total_steps": total_steps,
|
|
1052
1687
|
"completed_steps": completed_steps,
|
|
@@ -1100,29 +1735,42 @@ class RichStreamRenderer:
|
|
|
1100
1735
|
def _format_step_status(self, step: Step) -> str:
|
|
1101
1736
|
"""Format step status with elapsed time or duration."""
|
|
1102
1737
|
if is_step_finished(step):
|
|
1103
|
-
|
|
1104
|
-
return LESS_THAN_1MS
|
|
1105
|
-
elif step.duration_ms >= 1000:
|
|
1106
|
-
return f"[{step.duration_ms / 1000:.2f}s]"
|
|
1107
|
-
elif step.duration_ms > 0:
|
|
1108
|
-
return f"[{step.duration_ms}ms]"
|
|
1109
|
-
return LESS_THAN_1MS
|
|
1738
|
+
return self._format_finished_badge(step)
|
|
1110
1739
|
else:
|
|
1111
1740
|
# Calculate elapsed time for running steps
|
|
1112
1741
|
elapsed = self._calculate_step_elapsed_time(step)
|
|
1113
|
-
if elapsed >= 1:
|
|
1742
|
+
if elapsed >= 0.1:
|
|
1114
1743
|
return f"[{elapsed:.2f}s]"
|
|
1115
|
-
ms = int(elapsed * 1000)
|
|
1116
|
-
|
|
1744
|
+
ms = int(round(elapsed * 1000))
|
|
1745
|
+
if ms <= 0:
|
|
1746
|
+
return ""
|
|
1747
|
+
return f"[{ms}ms]"
|
|
1748
|
+
|
|
1749
|
+
def _format_finished_badge(self, step: Step) -> str:
|
|
1750
|
+
"""Compose duration badge for finished steps including source tagging."""
|
|
1751
|
+
if getattr(step, "duration_unknown", False) is True:
|
|
1752
|
+
payload = "??s"
|
|
1753
|
+
else:
|
|
1754
|
+
duration_ms = step.duration_ms
|
|
1755
|
+
if duration_ms is None:
|
|
1756
|
+
payload = "<1ms"
|
|
1757
|
+
elif duration_ms < 0:
|
|
1758
|
+
payload = "<1ms"
|
|
1759
|
+
elif duration_ms >= 100:
|
|
1760
|
+
payload = f"{duration_ms / 1000:.2f}s"
|
|
1761
|
+
elif duration_ms > 0:
|
|
1762
|
+
payload = f"{duration_ms}ms"
|
|
1763
|
+
else:
|
|
1764
|
+
payload = "<1ms"
|
|
1765
|
+
|
|
1766
|
+
return f"[{payload}]"
|
|
1117
1767
|
|
|
1118
1768
|
def _calculate_step_elapsed_time(self, step: Step) -> float:
|
|
1119
1769
|
"""Calculate elapsed time for a running step."""
|
|
1120
1770
|
server_elapsed = self.stream_processor.server_elapsed_time
|
|
1121
1771
|
server_start = self._step_server_start_times.get(step.step_id)
|
|
1122
1772
|
|
|
1123
|
-
if isinstance(server_elapsed, int
|
|
1124
|
-
server_start, int | float
|
|
1125
|
-
):
|
|
1773
|
+
if isinstance(server_elapsed, (int, float)) and isinstance(server_start, (int, float)):
|
|
1126
1774
|
return max(0.0, float(server_elapsed) - float(server_start))
|
|
1127
1775
|
|
|
1128
1776
|
try:
|
|
@@ -1136,6 +1784,21 @@ class RichStreamRenderer:
|
|
|
1136
1784
|
return step.name
|
|
1137
1785
|
return "thinking..." if step.kind == "agent" else f"{step.kind} step"
|
|
1138
1786
|
|
|
1787
|
+
def _resolve_step_label(self, step: Step) -> str:
|
|
1788
|
+
"""Return the display label for a step with sensible fallbacks."""
|
|
1789
|
+
raw_label = getattr(step, "display_label", None)
|
|
1790
|
+
label = raw_label.strip() if isinstance(raw_label, str) else ""
|
|
1791
|
+
if label:
|
|
1792
|
+
return normalise_display_label(label)
|
|
1793
|
+
|
|
1794
|
+
if not (step.name or "").strip():
|
|
1795
|
+
return UNKNOWN_STEP_DETAIL
|
|
1796
|
+
|
|
1797
|
+
icon = self._get_step_icon(step.kind)
|
|
1798
|
+
base_name = self._get_step_display_name(step)
|
|
1799
|
+
fallback = " ".join(part for part in (icon, base_name) if part).strip()
|
|
1800
|
+
return normalise_display_label(fallback)
|
|
1801
|
+
|
|
1139
1802
|
def _check_parallel_tools(self) -> dict[tuple[str | None, str | None], list]:
|
|
1140
1803
|
"""Check for parallel running tools."""
|
|
1141
1804
|
running_by_ctx: dict[tuple[str | None, str | None], list] = {}
|
|
@@ -1158,69 +1821,379 @@ class RichStreamRenderer:
|
|
|
1158
1821
|
def _compose_step_renderable(
|
|
1159
1822
|
self,
|
|
1160
1823
|
step: Step,
|
|
1161
|
-
|
|
1824
|
+
branch_state: tuple[bool, ...],
|
|
1162
1825
|
) -> Any:
|
|
1163
|
-
"""Compose a single renderable for the steps panel."""
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
if (
|
|
1169
|
-
not finished
|
|
1170
|
-
and step.kind == "tool"
|
|
1171
|
-
and self._is_parallel_tool(step, running_by_ctx)
|
|
1172
|
-
):
|
|
1173
|
-
status_br = status_br.replace("]", " 🔄]")
|
|
1826
|
+
"""Compose a single renderable for the hierarchical steps panel."""
|
|
1827
|
+
prefix = build_connector_prefix(branch_state)
|
|
1828
|
+
text_line = self._build_step_text_line(step, prefix)
|
|
1829
|
+
renderables = self._wrap_step_text(step, text_line)
|
|
1174
1830
|
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1831
|
+
args_renderable = self._build_args_renderable(step, prefix)
|
|
1832
|
+
if args_renderable is not None:
|
|
1833
|
+
renderables.append(args_renderable)
|
|
1834
|
+
|
|
1835
|
+
return self._collapse_renderables(renderables)
|
|
1836
|
+
|
|
1837
|
+
def _build_step_text_line(
|
|
1838
|
+
self,
|
|
1839
|
+
step: Step,
|
|
1840
|
+
prefix: str,
|
|
1841
|
+
) -> Text:
|
|
1842
|
+
"""Create the textual portion of a step renderable."""
|
|
1843
|
+
text_line = Text()
|
|
1844
|
+
text_line.append(prefix, style="dim")
|
|
1845
|
+
text_line.append(self._resolve_step_label(step))
|
|
1846
|
+
|
|
1847
|
+
status_badge = self._format_step_status(step)
|
|
1848
|
+
self._append_status_badge(text_line, step, status_badge)
|
|
1849
|
+
self._append_state_glyph(text_line, step)
|
|
1850
|
+
return text_line
|
|
1851
|
+
|
|
1852
|
+
def _append_status_badge(self, text_line: Text, step: Step, status_badge: str) -> None:
|
|
1853
|
+
"""Append the formatted status badge when available."""
|
|
1854
|
+
glyph_key = getattr(step, "status_icon", None)
|
|
1855
|
+
glyph = glyph_for_status(glyph_key)
|
|
1856
|
+
|
|
1857
|
+
if status_badge:
|
|
1858
|
+
text_line.append(" ")
|
|
1859
|
+
text_line.append(status_badge, style="cyan")
|
|
1860
|
+
|
|
1861
|
+
if glyph:
|
|
1181
1862
|
text_line.append(" ")
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1863
|
+
style = self._status_icon_style(glyph_key)
|
|
1864
|
+
if style:
|
|
1865
|
+
text_line.append(glyph, style=style)
|
|
1866
|
+
else:
|
|
1867
|
+
text_line.append(glyph)
|
|
1868
|
+
|
|
1869
|
+
def _append_state_glyph(self, text_line: Text, step: Step) -> None:
|
|
1870
|
+
"""Append glyph/failure markers in a single place."""
|
|
1871
|
+
failure_reason = (step.failure_reason or "").strip()
|
|
1872
|
+
if failure_reason:
|
|
1873
|
+
text_line.append(f" {failure_reason}")
|
|
1874
|
+
|
|
1875
|
+
@staticmethod
|
|
1876
|
+
def _status_icon_style(icon_key: str | None) -> str | None:
|
|
1877
|
+
"""Return style for a given status icon."""
|
|
1878
|
+
if not icon_key:
|
|
1879
|
+
return None
|
|
1880
|
+
return STATUS_ICON_STYLES.get(icon_key)
|
|
1881
|
+
|
|
1882
|
+
def _wrap_step_text(self, step: Step, text_line: Text) -> list[Any]:
|
|
1883
|
+
"""Return the base text, optionally decorated with a trailing spinner."""
|
|
1884
|
+
if getattr(step, "status", None) == "running":
|
|
1885
|
+
spinner = self._step_spinners.get(step.step_id)
|
|
1886
|
+
if spinner is None:
|
|
1887
|
+
spinner = Spinner("dots", style="dim")
|
|
1888
|
+
self._step_spinners[step.step_id] = spinner
|
|
1889
|
+
return [TrailingSpinnerLine(text_line, spinner)]
|
|
1890
|
+
|
|
1891
|
+
self._step_spinners.pop(step.step_id, None)
|
|
1892
|
+
return [text_line]
|
|
1893
|
+
|
|
1894
|
+
def _collapse_renderables(self, renderables: list[Any]) -> Any:
|
|
1895
|
+
"""Collapse a list of renderables into a single object."""
|
|
1896
|
+
if not renderables:
|
|
1897
|
+
return None
|
|
1185
1898
|
|
|
1186
|
-
if
|
|
1187
|
-
return
|
|
1899
|
+
if len(renderables) == 1:
|
|
1900
|
+
return renderables[0]
|
|
1188
1901
|
|
|
1189
|
-
|
|
1190
|
-
|
|
1902
|
+
return Group(*renderables)
|
|
1903
|
+
|
|
1904
|
+
def _build_args_renderable(self, step: Step, prefix: str) -> Text | Group | None:
|
|
1905
|
+
"""Build a dimmed argument line for tool or agent steps."""
|
|
1906
|
+
if step.kind not in {"tool", "delegate", "agent"}:
|
|
1907
|
+
return None
|
|
1908
|
+
if step.kind == "agent" and step.parent_id:
|
|
1909
|
+
return None
|
|
1910
|
+
formatted_args = self._format_step_args(step)
|
|
1911
|
+
if not formatted_args:
|
|
1912
|
+
return None
|
|
1913
|
+
if isinstance(formatted_args, list):
|
|
1914
|
+
return self._build_arg_list(prefix, formatted_args)
|
|
1915
|
+
|
|
1916
|
+
args_text = Text()
|
|
1917
|
+
args_text.append(prefix, style="dim")
|
|
1918
|
+
args_text.append(" " * 5)
|
|
1919
|
+
args_text.append(formatted_args, style="dim")
|
|
1920
|
+
return args_text
|
|
1921
|
+
|
|
1922
|
+
def _build_arg_list(self, prefix: str, formatted_args: list[str | tuple[int, str]]) -> Group | None:
|
|
1923
|
+
"""Render multi-line argument entries preserving indentation."""
|
|
1924
|
+
arg_lines: list[Text] = []
|
|
1925
|
+
for indent_level, text_value in self._iter_arg_entries(formatted_args):
|
|
1926
|
+
arg_text = Text()
|
|
1927
|
+
arg_text.append(prefix, style="dim")
|
|
1928
|
+
arg_text.append(" " * 5)
|
|
1929
|
+
arg_text.append(" " * (indent_level * 2))
|
|
1930
|
+
arg_text.append(text_value, style="dim")
|
|
1931
|
+
arg_lines.append(arg_text)
|
|
1932
|
+
if not arg_lines:
|
|
1933
|
+
return None
|
|
1934
|
+
return Group(*arg_lines)
|
|
1935
|
+
|
|
1936
|
+
@staticmethod
|
|
1937
|
+
def _iter_arg_entries(
|
|
1938
|
+
formatted_args: list[str | tuple[int, str]],
|
|
1939
|
+
) -> Iterable[tuple[int, str]]:
|
|
1940
|
+
"""Yield normalized indentation/value pairs for argument entries."""
|
|
1941
|
+
for value in formatted_args:
|
|
1942
|
+
if isinstance(value, tuple) and len(value) == 2:
|
|
1943
|
+
indent_level, text_value = value
|
|
1944
|
+
yield indent_level, str(text_value)
|
|
1945
|
+
else:
|
|
1946
|
+
yield 0, str(value)
|
|
1947
|
+
|
|
1948
|
+
def _format_step_args(self, step: Step) -> str | list[str] | list[tuple[int, str]] | None:
|
|
1949
|
+
"""Return a printable representation of tool arguments."""
|
|
1950
|
+
args = getattr(step, "args", None)
|
|
1951
|
+
if args is None:
|
|
1952
|
+
return None
|
|
1953
|
+
|
|
1954
|
+
if isinstance(args, dict):
|
|
1955
|
+
return self._format_dict_args(args, step=step)
|
|
1956
|
+
|
|
1957
|
+
if isinstance(args, (list, tuple)):
|
|
1958
|
+
return self._safe_pretty_args(list(args))
|
|
1959
|
+
|
|
1960
|
+
if isinstance(args, (str, int, float)):
|
|
1961
|
+
return self._stringify_args(args)
|
|
1962
|
+
|
|
1963
|
+
return None
|
|
1964
|
+
|
|
1965
|
+
def _format_dict_args(self, args: dict[str, Any], *, step: Step) -> str | list[str] | list[tuple[int, str]] | None:
|
|
1966
|
+
"""Format dictionary arguments with guardrails."""
|
|
1967
|
+
if not args:
|
|
1968
|
+
return None
|
|
1969
|
+
|
|
1970
|
+
masked_args = self._redact_arg_payload(args)
|
|
1971
|
+
|
|
1972
|
+
if self._should_collapse_single_query(step):
|
|
1973
|
+
single_query = self._extract_single_query_arg(masked_args)
|
|
1974
|
+
if single_query:
|
|
1975
|
+
return single_query
|
|
1976
|
+
|
|
1977
|
+
return self._format_dict_arg_lines(masked_args)
|
|
1978
|
+
|
|
1979
|
+
@staticmethod
|
|
1980
|
+
def _extract_single_query_arg(args: dict[str, Any]) -> str | None:
|
|
1981
|
+
"""Return a trimmed query argument when it is the only entry."""
|
|
1982
|
+
if len(args) != 1:
|
|
1983
|
+
return None
|
|
1984
|
+
key, value = next(iter(args.items()))
|
|
1985
|
+
if key != "query" or not isinstance(value, str):
|
|
1986
|
+
return None
|
|
1987
|
+
stripped = value.strip()
|
|
1988
|
+
return stripped or None
|
|
1989
|
+
|
|
1990
|
+
@staticmethod
|
|
1991
|
+
def _redact_arg_payload(args: dict[str, Any]) -> dict[str, Any]:
|
|
1992
|
+
"""Apply best-effort masking before rendering arguments."""
|
|
1993
|
+
try:
|
|
1994
|
+
cleaned = redact_sensitive(args)
|
|
1995
|
+
return cleaned if isinstance(cleaned, dict) else args
|
|
1996
|
+
except Exception:
|
|
1997
|
+
return args
|
|
1998
|
+
|
|
1999
|
+
@staticmethod
|
|
2000
|
+
def _should_collapse_single_query(step: Step) -> bool:
|
|
2001
|
+
"""Return True when we should display raw query text."""
|
|
2002
|
+
if step.kind == "agent":
|
|
2003
|
+
return True
|
|
2004
|
+
if step.kind == "delegate":
|
|
2005
|
+
return True
|
|
2006
|
+
return False
|
|
2007
|
+
|
|
2008
|
+
def _format_dict_arg_lines(self, args: dict[str, Any]) -> list[tuple[int, str]] | None:
|
|
2009
|
+
"""Render dictionary arguments as nested YAML-style lines."""
|
|
2010
|
+
lines: list[tuple[int, str]] = []
|
|
2011
|
+
for raw_key, value in args.items():
|
|
2012
|
+
key = str(raw_key)
|
|
2013
|
+
lines.extend(self._format_nested_entry(key, value, indent=0))
|
|
2014
|
+
return lines or None
|
|
2015
|
+
|
|
2016
|
+
def _format_nested_entry(self, key: str, value: Any, indent: int) -> list[tuple[int, str]]:
|
|
2017
|
+
"""Format a mapping entry recursively."""
|
|
2018
|
+
lines: list[tuple[int, str]] = []
|
|
2019
|
+
|
|
2020
|
+
if isinstance(value, dict):
|
|
2021
|
+
if value:
|
|
2022
|
+
lines.append((indent, f"{key}:"))
|
|
2023
|
+
lines.extend(self._format_nested_mapping(value, indent + 1))
|
|
2024
|
+
else:
|
|
2025
|
+
lines.append((indent, f"{key}: {{}}"))
|
|
2026
|
+
return lines
|
|
2027
|
+
|
|
2028
|
+
if isinstance(value, (list, tuple, set)):
|
|
2029
|
+
seq_lines = self._format_sequence_entries(list(value), indent + 1)
|
|
2030
|
+
if seq_lines:
|
|
2031
|
+
lines.append((indent, f"{key}:"))
|
|
2032
|
+
lines.extend(seq_lines)
|
|
2033
|
+
else:
|
|
2034
|
+
lines.append((indent, f"{key}: []"))
|
|
2035
|
+
return lines
|
|
2036
|
+
|
|
2037
|
+
formatted_value = self._format_arg_value(value)
|
|
2038
|
+
if formatted_value is not None:
|
|
2039
|
+
lines.append((indent, f"{key}: {formatted_value}"))
|
|
2040
|
+
return lines
|
|
2041
|
+
|
|
2042
|
+
def _format_nested_mapping(self, mapping: dict[str, Any], indent: int) -> list[tuple[int, str]]:
|
|
2043
|
+
"""Format nested dictionary values."""
|
|
2044
|
+
nested_lines: list[tuple[int, str]] = []
|
|
2045
|
+
for raw_key, value in mapping.items():
|
|
2046
|
+
key = str(raw_key)
|
|
2047
|
+
nested_lines.extend(self._format_nested_entry(key, value, indent))
|
|
2048
|
+
return nested_lines
|
|
2049
|
+
|
|
2050
|
+
def _format_sequence_entries(self, sequence: list[Any], indent: int) -> list[tuple[int, str]]:
|
|
2051
|
+
"""Format list/tuple/set values with YAML-style bullets."""
|
|
2052
|
+
if not sequence:
|
|
2053
|
+
return []
|
|
2054
|
+
|
|
2055
|
+
lines: list[tuple[int, str]] = []
|
|
2056
|
+
for item in sequence:
|
|
2057
|
+
lines.extend(self._format_sequence_item(item, indent))
|
|
2058
|
+
return lines
|
|
2059
|
+
|
|
2060
|
+
def _format_sequence_item(self, item: Any, indent: int) -> list[tuple[int, str]]:
|
|
2061
|
+
"""Format a single list entry."""
|
|
2062
|
+
if isinstance(item, dict):
|
|
2063
|
+
return self._format_dict_sequence_item(item, indent)
|
|
2064
|
+
|
|
2065
|
+
if isinstance(item, (list, tuple, set)):
|
|
2066
|
+
return self._format_nested_sequence_item(list(item), indent)
|
|
2067
|
+
|
|
2068
|
+
formatted = self._format_arg_value(item)
|
|
2069
|
+
if formatted is not None:
|
|
2070
|
+
return [(indent, f"- {formatted}")]
|
|
2071
|
+
return []
|
|
2072
|
+
|
|
2073
|
+
def _format_dict_sequence_item(self, mapping: dict[str, Any], indent: int) -> list[tuple[int, str]]:
|
|
2074
|
+
"""Format a dictionary entry within a list."""
|
|
2075
|
+
child_lines = self._format_nested_mapping(mapping, indent + 1)
|
|
2076
|
+
if child_lines:
|
|
2077
|
+
return self._prepend_sequence_prefix(child_lines, indent)
|
|
2078
|
+
return [(indent, "- {}")]
|
|
2079
|
+
|
|
2080
|
+
def _format_nested_sequence_item(self, sequence: list[Any], indent: int) -> list[tuple[int, str]]:
|
|
2081
|
+
"""Format a nested sequence entry within a list."""
|
|
2082
|
+
child_lines = self._format_sequence_entries(sequence, indent + 1)
|
|
2083
|
+
if child_lines:
|
|
2084
|
+
return self._prepend_sequence_prefix(child_lines, indent)
|
|
2085
|
+
return [(indent, "- []")]
|
|
2086
|
+
|
|
2087
|
+
@staticmethod
|
|
2088
|
+
def _prepend_sequence_prefix(child_lines: list[tuple[int, str]], indent: int) -> list[tuple[int, str]]:
|
|
2089
|
+
"""Attach a sequence bullet to the first child line."""
|
|
2090
|
+
_, first_text = child_lines[0]
|
|
2091
|
+
prefixed: list[tuple[int, str]] = [(indent, f"- {first_text}")]
|
|
2092
|
+
prefixed.extend(child_lines[1:])
|
|
2093
|
+
return prefixed
|
|
2094
|
+
|
|
2095
|
+
def _format_arg_value(self, value: Any) -> str | None:
|
|
2096
|
+
"""Format a single argument value with per-value truncation."""
|
|
2097
|
+
if value is None:
|
|
2098
|
+
return "null"
|
|
2099
|
+
if isinstance(value, (bool, int, float)):
|
|
2100
|
+
return json.dumps(value, ensure_ascii=False)
|
|
2101
|
+
if isinstance(value, str):
|
|
2102
|
+
return self._format_string_arg_value(value)
|
|
2103
|
+
return _truncate_display(str(value), limit=ARGS_VALUE_MAX_LEN)
|
|
2104
|
+
|
|
2105
|
+
@staticmethod
|
|
2106
|
+
def _format_string_arg_value(value: str) -> str:
|
|
2107
|
+
"""Return a trimmed, quoted representation of a string argument."""
|
|
2108
|
+
sanitised = value.replace("\n", " ").strip()
|
|
2109
|
+
sanitised = sanitised.replace('"', '\\"')
|
|
2110
|
+
trimmed = _truncate_display(sanitised, limit=ARGS_VALUE_MAX_LEN)
|
|
2111
|
+
return f'"{trimmed}"'
|
|
2112
|
+
|
|
2113
|
+
@staticmethod
|
|
2114
|
+
def _safe_pretty_args(args: dict[str, Any]) -> str | None:
|
|
2115
|
+
"""Defensively format argument dictionaries."""
|
|
2116
|
+
try:
|
|
2117
|
+
return pretty_args(args, max_len=160)
|
|
2118
|
+
except Exception:
|
|
2119
|
+
return str(args)
|
|
2120
|
+
|
|
2121
|
+
@staticmethod
|
|
2122
|
+
def _stringify_args(args: Any) -> str | None:
|
|
2123
|
+
"""Format non-dictionary argument payloads."""
|
|
2124
|
+
text = str(args).strip()
|
|
2125
|
+
if not text:
|
|
2126
|
+
return None
|
|
2127
|
+
return _truncate_display(text)
|
|
1191
2128
|
|
|
1192
2129
|
def _render_steps_text(self) -> Any:
|
|
1193
2130
|
"""Render the steps panel content."""
|
|
1194
2131
|
if not (self.steps.order or self.steps.children):
|
|
1195
|
-
return
|
|
2132
|
+
return _NO_STEPS_TEXT.copy()
|
|
2133
|
+
|
|
2134
|
+
nodes = list(self.steps.iter_tree())
|
|
2135
|
+
if not nodes:
|
|
2136
|
+
return _NO_STEPS_TEXT.copy()
|
|
2137
|
+
|
|
2138
|
+
window = self._summary_window_size()
|
|
2139
|
+
display_nodes, header_notice, footer_notice = clamp_step_nodes(
|
|
2140
|
+
nodes,
|
|
2141
|
+
window=window,
|
|
2142
|
+
get_label=self._get_step_label,
|
|
2143
|
+
get_parent=self._get_step_parent,
|
|
2144
|
+
)
|
|
2145
|
+
step_renderables = self._build_step_renderables(display_nodes)
|
|
1196
2146
|
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
for sid in self.steps.order:
|
|
1200
|
-
line = self._compose_step_renderable(self.steps.by_id[sid], running_by_ctx)
|
|
1201
|
-
renderables.append(line)
|
|
2147
|
+
if not step_renderables and not header_notice and not footer_notice:
|
|
2148
|
+
return _NO_STEPS_TEXT.copy()
|
|
1202
2149
|
|
|
1203
|
-
|
|
1204
|
-
return Text("No steps yet", style="dim")
|
|
2150
|
+
return self._assemble_step_renderables(step_renderables, header_notice, footer_notice)
|
|
1205
2151
|
|
|
1206
|
-
|
|
2152
|
+
def _get_step_label(self, step_id: str) -> str:
|
|
2153
|
+
"""Get label for a step by ID."""
|
|
2154
|
+
step = self.steps.by_id.get(step_id)
|
|
2155
|
+
if step:
|
|
2156
|
+
return self._resolve_step_label(step)
|
|
2157
|
+
return UNKNOWN_STEP_DETAIL
|
|
1207
2158
|
|
|
1208
|
-
def
|
|
1209
|
-
"""
|
|
1210
|
-
|
|
1211
|
-
|
|
2159
|
+
def _get_step_parent(self, step_id: str) -> str | None:
|
|
2160
|
+
"""Get parent ID for a step by ID."""
|
|
2161
|
+
step = self.steps.by_id.get(step_id)
|
|
2162
|
+
return step.parent_id if step else None
|
|
1212
2163
|
|
|
1213
|
-
|
|
1214
|
-
|
|
2164
|
+
def _summary_window_size(self) -> int:
|
|
2165
|
+
"""Return the active window size for step display."""
|
|
2166
|
+
if self.state.finalizing_ui:
|
|
2167
|
+
return 0
|
|
2168
|
+
return int(self.cfg.summary_display_window or 0)
|
|
1215
2169
|
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
2170
|
+
def _assemble_step_renderables(self, step_renderables: list[Any], header_notice: Any, footer_notice: Any) -> Any:
|
|
2171
|
+
"""Assemble step renderables with header and footer into final output."""
|
|
2172
|
+
renderables: list[Any] = []
|
|
2173
|
+
if header_notice is not None:
|
|
2174
|
+
renderables.append(header_notice)
|
|
2175
|
+
renderables.extend(step_renderables)
|
|
2176
|
+
if footer_notice is not None:
|
|
2177
|
+
renderables.append(footer_notice)
|
|
1220
2178
|
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
2179
|
+
if len(renderables) == 1:
|
|
2180
|
+
return renderables[0]
|
|
2181
|
+
|
|
2182
|
+
return Group(*renderables)
|
|
2183
|
+
|
|
2184
|
+
def _build_step_renderables(self, display_nodes: list[tuple[str, tuple[bool, ...]]]) -> list[Any]:
|
|
2185
|
+
"""Convert step nodes to renderables for the steps panel."""
|
|
2186
|
+
renderables: list[Any] = []
|
|
2187
|
+
for step_id, branch_state in display_nodes:
|
|
2188
|
+
step = self.steps.by_id.get(step_id)
|
|
2189
|
+
if not step:
|
|
2190
|
+
continue
|
|
2191
|
+
renderable = self._compose_step_renderable(step, branch_state)
|
|
2192
|
+
if renderable is not None:
|
|
2193
|
+
renderables.append(renderable)
|
|
2194
|
+
return renderables
|
|
2195
|
+
|
|
2196
|
+
def _update_final_duration(self, duration: float | None, *, overwrite: bool = False) -> None:
|
|
1224
2197
|
"""Store formatted duration for eventual final panels."""
|
|
1225
2198
|
if duration is None:
|
|
1226
2199
|
return
|
|
@@ -1240,20 +2213,7 @@ class RichStreamRenderer:
|
|
|
1240
2213
|
|
|
1241
2214
|
self.state.final_duration_seconds = duration_val
|
|
1242
2215
|
self.state.final_duration_text = self._format_elapsed_time(duration_val)
|
|
1243
|
-
|
|
1244
|
-
def _calculate_elapsed_time(self, meta: dict[str, Any]) -> str:
|
|
1245
|
-
"""Calculate elapsed time string for running tools."""
|
|
1246
|
-
server_elapsed = self.stream_processor.server_elapsed_time
|
|
1247
|
-
server_start = meta.get("server_started_at")
|
|
1248
|
-
|
|
1249
|
-
if isinstance(server_elapsed, int | float) and isinstance(
|
|
1250
|
-
server_start, int | float
|
|
1251
|
-
):
|
|
1252
|
-
elapsed = max(0.0, float(server_elapsed) - float(server_start))
|
|
1253
|
-
else:
|
|
1254
|
-
elapsed = max(0.0, monotonic() - (meta.get("started_at") or 0.0))
|
|
1255
|
-
|
|
1256
|
-
return self._format_elapsed_time(elapsed)
|
|
2216
|
+
self._apply_root_duration(duration_val)
|
|
1257
2217
|
|
|
1258
2218
|
def _format_elapsed_time(self, elapsed: float) -> str:
|
|
1259
2219
|
"""Format elapsed time as a readable string."""
|
|
@@ -1264,110 +2224,10 @@ class RichStreamRenderer:
|
|
|
1264
2224
|
else:
|
|
1265
2225
|
return "<1ms"
|
|
1266
2226
|
|
|
1267
|
-
def _calculate_finished_duration(self, meta: dict[str, Any]) -> str | None:
|
|
1268
|
-
"""Calculate duration string for finished tools."""
|
|
1269
|
-
dur = meta.get("duration_seconds")
|
|
1270
|
-
if isinstance(dur, int | float):
|
|
1271
|
-
return self._format_elapsed_time(dur)
|
|
1272
|
-
|
|
1273
|
-
try:
|
|
1274
|
-
server_now = self.stream_processor.server_elapsed_time
|
|
1275
|
-
server_start = meta.get("server_started_at")
|
|
1276
|
-
if isinstance(server_now, int | float) and isinstance(
|
|
1277
|
-
server_start, int | float
|
|
1278
|
-
):
|
|
1279
|
-
dur = max(0.0, float(server_now) - float(server_start))
|
|
1280
|
-
elif meta.get("started_at") is not None:
|
|
1281
|
-
dur = max(0.0, float(monotonic() - meta.get("started_at")))
|
|
1282
|
-
except Exception:
|
|
1283
|
-
dur = None
|
|
1284
|
-
|
|
1285
|
-
return self._format_elapsed_time(dur) if isinstance(dur, int | float) else None
|
|
1286
|
-
|
|
1287
|
-
def _process_running_tool_panel(
|
|
1288
|
-
self,
|
|
1289
|
-
title: str,
|
|
1290
|
-
meta: dict[str, Any],
|
|
1291
|
-
body: str,
|
|
1292
|
-
*,
|
|
1293
|
-
include_spinner: bool = False,
|
|
1294
|
-
) -> tuple[str, str] | tuple[str, str, str | None]:
|
|
1295
|
-
"""Process a running tool panel."""
|
|
1296
|
-
elapsed_str = self._calculate_elapsed_time(meta)
|
|
1297
|
-
adjusted_title = f"{title} · {elapsed_str}"
|
|
1298
|
-
chip = f"⏱ {elapsed_str}"
|
|
1299
|
-
spinner_message: str | None = None
|
|
1300
|
-
|
|
1301
|
-
if not body.strip():
|
|
1302
|
-
body = ""
|
|
1303
|
-
spinner_message = f"{title} running... {elapsed_str}"
|
|
1304
|
-
else:
|
|
1305
|
-
body = f"{body}\n\n{chip}"
|
|
1306
|
-
|
|
1307
|
-
if include_spinner:
|
|
1308
|
-
return adjusted_title, body, spinner_message
|
|
1309
|
-
return adjusted_title, body
|
|
1310
|
-
|
|
1311
|
-
def _process_finished_tool_panel(self, title: str, meta: dict[str, Any]) -> str:
|
|
1312
|
-
"""Process a finished tool panel."""
|
|
1313
|
-
duration_str = self._calculate_finished_duration(meta)
|
|
1314
|
-
return f"{title} · {duration_str}" if duration_str else title
|
|
1315
|
-
|
|
1316
|
-
def _create_tool_panel_for_session(
|
|
1317
|
-
self, sid: str, meta: dict[str, Any]
|
|
1318
|
-
) -> AIPPanel | None:
|
|
1319
|
-
"""Create a single tool panel for the session."""
|
|
1320
|
-
title = meta.get("title") or "Tool"
|
|
1321
|
-
status = meta.get("status") or "running"
|
|
1322
|
-
chunks = meta.get("chunks") or []
|
|
1323
|
-
is_delegation = bool(meta.get("is_delegation"))
|
|
1324
|
-
|
|
1325
|
-
if self._should_skip_finished_panel(sid, status):
|
|
1326
|
-
return None
|
|
1327
|
-
|
|
1328
|
-
body = "".join(chunks)
|
|
1329
|
-
adjusted_title = title
|
|
1330
|
-
|
|
1331
|
-
spinner_message: str | None = None
|
|
1332
|
-
|
|
1333
|
-
if status == "running":
|
|
1334
|
-
adjusted_title, body, spinner_message = self._process_running_tool_panel(
|
|
1335
|
-
title, meta, body, include_spinner=True
|
|
1336
|
-
)
|
|
1337
|
-
elif status == "finished":
|
|
1338
|
-
adjusted_title = self._process_finished_tool_panel(title, meta)
|
|
1339
|
-
|
|
1340
|
-
return create_tool_panel(
|
|
1341
|
-
title=adjusted_title,
|
|
1342
|
-
content=body,
|
|
1343
|
-
status=status,
|
|
1344
|
-
theme=self.cfg.theme,
|
|
1345
|
-
is_delegation=is_delegation,
|
|
1346
|
-
spinner_message=spinner_message,
|
|
1347
|
-
)
|
|
1348
|
-
|
|
1349
|
-
def _render_tool_panels(self) -> list[AIPPanel]:
|
|
1350
|
-
"""Render tool execution output panels."""
|
|
1351
|
-
if not getattr(self.cfg, "show_delegate_tool_panels", False):
|
|
1352
|
-
return []
|
|
1353
|
-
panels: list[AIPPanel] = []
|
|
1354
|
-
for sid in self.tool_order:
|
|
1355
|
-
meta = self.tool_panels.get(sid) or {}
|
|
1356
|
-
panel = self._create_tool_panel_for_session(sid, meta)
|
|
1357
|
-
if panel:
|
|
1358
|
-
panels.append(panel)
|
|
1359
|
-
|
|
1360
|
-
return panels
|
|
1361
|
-
|
|
1362
2227
|
def _format_dict_or_list_output(self, output_value: dict | list) -> str:
|
|
1363
2228
|
"""Format dict/list output as pretty JSON."""
|
|
1364
2229
|
try:
|
|
1365
|
-
return (
|
|
1366
|
-
self.OUTPUT_PREFIX
|
|
1367
|
-
+ "```json\n"
|
|
1368
|
-
+ json.dumps(output_value, indent=2)
|
|
1369
|
-
+ "\n```\n"
|
|
1370
|
-
)
|
|
2230
|
+
return self.OUTPUT_PREFIX + "```json\n" + json.dumps(output_value, indent=2) + "\n```\n"
|
|
1371
2231
|
except Exception:
|
|
1372
2232
|
return self.OUTPUT_PREFIX + str(output_value) + "\n"
|
|
1373
2233
|
|
|
@@ -1391,12 +2251,7 @@ class RichStreamRenderer:
|
|
|
1391
2251
|
"""Format string that looks like JSON."""
|
|
1392
2252
|
try:
|
|
1393
2253
|
parsed = json.loads(output)
|
|
1394
|
-
return (
|
|
1395
|
-
self.OUTPUT_PREFIX
|
|
1396
|
-
+ "```json\n"
|
|
1397
|
-
+ json.dumps(parsed, indent=2)
|
|
1398
|
-
+ "\n```\n"
|
|
1399
|
-
)
|
|
2254
|
+
return self.OUTPUT_PREFIX + "```json\n" + json.dumps(parsed, indent=2) + "\n```\n"
|
|
1400
2255
|
except Exception:
|
|
1401
2256
|
return self.OUTPUT_PREFIX + output + "\n"
|
|
1402
2257
|
|
|
@@ -1406,9 +2261,7 @@ class RichStreamRenderer:
|
|
|
1406
2261
|
s = self._clean_sub_agent_prefix(s, tool_name)
|
|
1407
2262
|
|
|
1408
2263
|
# If looks like JSON, pretty print it
|
|
1409
|
-
if (s.startswith("{") and s.endswith("}")) or (
|
|
1410
|
-
s.startswith("[") and s.endswith("]")
|
|
1411
|
-
):
|
|
2264
|
+
if (s.startswith("{") and s.endswith("}")) or (s.startswith("[") and s.endswith("]")):
|
|
1412
2265
|
return self._format_json_string_output(s)
|
|
1413
2266
|
|
|
1414
2267
|
return self.OUTPUT_PREFIX + s + "\n"
|
|
@@ -1422,7 +2275,7 @@ class RichStreamRenderer:
|
|
|
1422
2275
|
|
|
1423
2276
|
def _format_output_block(self, output_value: Any, tool_name: str | None) -> str:
|
|
1424
2277
|
"""Format an output value for panel display."""
|
|
1425
|
-
if isinstance(output_value, dict
|
|
2278
|
+
if isinstance(output_value, (dict, list)):
|
|
1426
2279
|
return self._format_dict_or_list_output(output_value)
|
|
1427
2280
|
elif isinstance(output_value, str):
|
|
1428
2281
|
return self._format_string_output(output_value, tool_name)
|