synapse-cli-agent 0.1.13__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.
- synapse/__init__.py +13 -0
- synapse/__main__.py +6 -0
- synapse/app/__init__.py +1 -0
- synapse/app/agent.py +492 -0
- synapse/app/agent_md.py +107 -0
- synapse/cli.py +750 -0
- synapse/commands/__init__.py +1 -0
- synapse/commands/compression.py +573 -0
- synapse/commands/helpers.py +22 -0
- synapse/commands/mcp.py +406 -0
- synapse/commands/model.py +173 -0
- synapse/commands/result.py +34 -0
- synapse/commands/sessions.py +443 -0
- synapse/commands/slash_cmds.py +521 -0
- synapse/commands/slash_complete.py +816 -0
- synapse/commands/theme.py +99 -0
- synapse/config.py +27 -0
- synapse/content/__init__.py +1 -0
- synapse/content/input_history.py +122 -0
- synapse/content/multimodal.py +733 -0
- synapse/content/prompts.py +249 -0
- synapse/content/skills_catalog.py +128 -0
- synapse/integrations/__init__.py +1 -0
- synapse/integrations/checkpoint_seed.py +281 -0
- synapse/integrations/codex_history.py +375 -0
- synapse/integrations/codex_import.py +393 -0
- synapse/integrations/codex_sessions.py +629 -0
- synapse/integrations/describe_image.py +370 -0
- synapse/integrations/http_clients.py +199 -0
- synapse/integrations/llm_openai_compat.py +90 -0
- synapse/integrations/llm_openai_websocket.py +187 -0
- synapse/integrations/mcp_client.py +646 -0
- synapse/integrations/vision_middleware.py +62 -0
- synapse/models/__init__.py +5 -0
- synapse/models/config.py +240 -0
- synapse/models/helpers.py +206 -0
- synapse/models/profile.py +59 -0
- synapse/models/registry.py +722 -0
- synapse/models_registry.py +7 -0
- synapse/observability/__init__.py +1 -0
- synapse/observability/startup_trace.py +127 -0
- synapse/runtime/__init__.py +1 -0
- synapse/runtime/async_runtime.py +176 -0
- synapse/runtime/backends.py +458 -0
- synapse/runtime/context_compact.py +249 -0
- synapse/runtime/execute_capture.py +48 -0
- synapse/runtime/fs_permissions.py +79 -0
- synapse/runtime/harness.py +57 -0
- synapse/runtime/hitl.py +197 -0
- synapse/runtime/interaction_ledger.py +82 -0
- synapse/runtime/middleware.py +802 -0
- synapse/runtime/model_request_compression_middleware.py +745 -0
- synapse/runtime/pathing.py +146 -0
- synapse/runtime/safety.py +184 -0
- synapse/runtime/steer.py +240 -0
- synapse/runtime/subagents.py +207 -0
- synapse/runtime/tool_ignore.py +221 -0
- synapse/runtime/tool_output_eval.py +118 -0
- synapse/runtime/tool_output_middleware.py +585 -0
- synapse/runtime/tool_output_usage_middleware.py +60 -0
- synapse/sessions/__init__.py +31 -0
- synapse/sessions/cancel_repair.py +208 -0
- synapse/sessions/session_recap.py +174 -0
- synapse/sessions/store.py +695 -0
- synapse/sessions/transcript.py +754 -0
- synapse/settings/__init__.py +5 -0
- synapse/settings/config_paths.py +184 -0
- synapse/settings/schema.py +464 -0
- synapse/tool_output/__init__.py +59 -0
- synapse/tool_output/detection.py +170 -0
- synapse/tool_output/metrics.py +32 -0
- synapse/tool_output/models.py +173 -0
- synapse/tool_output/pipeline.py +330 -0
- synapse/tool_output/repository.py +721 -0
- synapse/tool_output/transformers.py +648 -0
- synapse/tools/__init__.py +5 -0
- synapse/tools/session_tools.py +204 -0
- synapse/ui/__init__.py +10 -0
- synapse/ui/bottombar/__init__.py +73 -0
- synapse/ui/bottombar/components/__init__.py +143 -0
- synapse/ui/bottombar/components/key_hints.py +30 -0
- synapse/ui/bottombar/components/mcp.py +64 -0
- synapse/ui/bottombar/components/mode.py +24 -0
- synapse/ui/bottombar/components/model.py +28 -0
- synapse/ui/bottombar/components/thread.py +29 -0
- synapse/ui/bottombar/context.py +36 -0
- synapse/ui/bottombar/core.py +74 -0
- synapse/ui/dialogs/__init__.py +25 -0
- synapse/ui/dialogs/base.py +362 -0
- synapse/ui/dialogs/codex_session_list.py +84 -0
- synapse/ui/dialogs/compression_diagnostics.py +210 -0
- synapse/ui/dialogs/git_explore.py +702 -0
- synapse/ui/dialogs/mcp_panel.py +407 -0
- synapse/ui/dialogs/model_picker.py +128 -0
- synapse/ui/dialogs/safety_panel.py +63 -0
- synapse/ui/dialogs/session_list.py +98 -0
- synapse/ui/dialogs/theme_designer.py +863 -0
- synapse/ui/dialogs/theme_picker.py +113 -0
- synapse/ui/git_explore/__init__.py +31 -0
- synapse/ui/git_explore/engine.py +82 -0
- synapse/ui/git_explore/provider.py +242 -0
- synapse/ui/git_explore/unified.py +85 -0
- synapse/ui/rendering.py +350 -0
- synapse/ui/sink.py +70 -0
- synapse/ui/steer_widget.py +367 -0
- synapse/ui/stream.py +1207 -0
- synapse/ui/stream_events.py +421 -0
- synapse/ui/stream_runtime.py +252 -0
- synapse/ui/theme.py +1154 -0
- synapse/ui/timeline.py +621 -0
- synapse/ui/topbar/__init__.py +97 -0
- synapse/ui/topbar/components/__init__.py +150 -0
- synapse/ui/topbar/components/branch.py +41 -0
- synapse/ui/topbar/components/title.py +24 -0
- synapse/ui/topbar/components/tool_output.py +24 -0
- synapse/ui/topbar/components/usage.py +24 -0
- synapse/ui/topbar/components/workspace.py +32 -0
- synapse/ui/topbar/context.py +32 -0
- synapse/ui/topbar/core.py +979 -0
- synapse/ui/topbar/git_changes_popover.py +178 -0
- synapse/ui/topbar/git_chrome.py +475 -0
- synapse/ui/topbar/tool_output_popover.py +84 -0
- synapse/ui/topbar/widget.py +474 -0
- synapse/ui/tui.py +5717 -0
- synapse/ui/turn_rail.py +71 -0
- synapse/ui/user_turn.py +83 -0
- synapse/ui/welcome.py +261 -0
- synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
- synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
- synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
- synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
synapse/ui/stream.py
ADDED
|
@@ -0,0 +1,1207 @@
|
|
|
1
|
+
"""Streaming UI helpers for CLI output.
|
|
2
|
+
|
|
3
|
+
Supports:
|
|
4
|
+
- token-level streaming (`messages` mode)
|
|
5
|
+
- reasoning / thinking stream (DeepSeek etc.)
|
|
6
|
+
- intermediate assistant messages between tool rounds
|
|
7
|
+
- concurrent multi-tool progress + subagent heartbeat
|
|
8
|
+
- compact tool results (params on call; status only on return)
|
|
9
|
+
|
|
10
|
+
Rendering is pluggable via ``StreamSink``:
|
|
11
|
+
- default: ``RichStreamSink`` (CLI)
|
|
12
|
+
- TUI: ``synapse.ui.tui.TextualStreamSink``
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from rich.live import Live
|
|
22
|
+
from rich.spinner import Spinner
|
|
23
|
+
from rich.text import Text
|
|
24
|
+
|
|
25
|
+
from synapse.runtime.context_compact import (
|
|
26
|
+
is_context_compact_text,
|
|
27
|
+
is_lc_summarization_message,
|
|
28
|
+
is_stream_meta_summarization,
|
|
29
|
+
)
|
|
30
|
+
from synapse.runtime.pathing import summarize_tool_result
|
|
31
|
+
from synapse.runtime.steer import is_steer_message
|
|
32
|
+
|
|
33
|
+
# soft_wrap keeps long lines readable; force_terminal helps Windows color.
|
|
34
|
+
# highlight=False avoids over-styling plain identifiers in non-markdown UI.
|
|
35
|
+
from synapse.ui.rendering import (
|
|
36
|
+
_FullBorderMarkdown,
|
|
37
|
+
_FullTableElement,
|
|
38
|
+
_MermaidCodeBlock,
|
|
39
|
+
console,
|
|
40
|
+
print_banner,
|
|
41
|
+
print_error,
|
|
42
|
+
print_final,
|
|
43
|
+
print_info,
|
|
44
|
+
print_markdown,
|
|
45
|
+
print_user,
|
|
46
|
+
render_markdown,
|
|
47
|
+
render_math_in_text,
|
|
48
|
+
render_mermaid_diagram,
|
|
49
|
+
)
|
|
50
|
+
from synapse.ui.sink import StreamSink, sink_supports_tool_items
|
|
51
|
+
from synapse.ui.stream_events import (
|
|
52
|
+
StreamResult,
|
|
53
|
+
_chunk_text,
|
|
54
|
+
_extract_reasoning,
|
|
55
|
+
_extract_usage,
|
|
56
|
+
_format_tool_args,
|
|
57
|
+
_is_ai_message,
|
|
58
|
+
_is_tool_message,
|
|
59
|
+
_looks_like_middleware_update,
|
|
60
|
+
_normalize_content,
|
|
61
|
+
_reasoning_token_count,
|
|
62
|
+
_tool_call_args,
|
|
63
|
+
_tool_call_id,
|
|
64
|
+
_tool_call_name,
|
|
65
|
+
extract_last_ai_text,
|
|
66
|
+
human_nested_tools_detail,
|
|
67
|
+
human_tool_label,
|
|
68
|
+
)
|
|
69
|
+
from synapse.ui.stream_events import (
|
|
70
|
+
aggregate_usage_from_messages as _aggregate_usage_from_messages,
|
|
71
|
+
)
|
|
72
|
+
from synapse.ui.stream_runtime import (
|
|
73
|
+
_is_sync_only_checkpointer_error,
|
|
74
|
+
_iter_stream_events,
|
|
75
|
+
checkpointer_supports_async,
|
|
76
|
+
)
|
|
77
|
+
from synapse.ui.timeline import (
|
|
78
|
+
build_tool_item,
|
|
79
|
+
content_to_text,
|
|
80
|
+
is_error_status,
|
|
81
|
+
is_todo_tool,
|
|
82
|
+
match_tool_result,
|
|
83
|
+
truncate_preview,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
aggregate_usage_from_messages = _aggregate_usage_from_messages
|
|
87
|
+
|
|
88
|
+
__all__ = [
|
|
89
|
+
"_FullBorderMarkdown",
|
|
90
|
+
"_FullTableElement",
|
|
91
|
+
"_MermaidCodeBlock",
|
|
92
|
+
"_iter_stream_events",
|
|
93
|
+
"_is_sync_only_checkpointer_error",
|
|
94
|
+
"_extract_reasoning",
|
|
95
|
+
"_extract_usage",
|
|
96
|
+
"_is_ai_message",
|
|
97
|
+
"_is_tool_message",
|
|
98
|
+
"_normalize_content",
|
|
99
|
+
"_reasoning_token_count",
|
|
100
|
+
"_tool_call_name",
|
|
101
|
+
"aggregate_usage_from_messages",
|
|
102
|
+
"checkpointer_supports_async",
|
|
103
|
+
"extract_last_ai_text",
|
|
104
|
+
"print_banner",
|
|
105
|
+
"print_error",
|
|
106
|
+
"print_final",
|
|
107
|
+
"print_info",
|
|
108
|
+
"print_markdown",
|
|
109
|
+
"print_user",
|
|
110
|
+
"render_markdown",
|
|
111
|
+
"render_math_in_text",
|
|
112
|
+
"render_mermaid_diagram",
|
|
113
|
+
"stream_agent",
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class _ActivityLine:
|
|
120
|
+
"""Animated status with heartbeat so long waits never look frozen."""
|
|
121
|
+
|
|
122
|
+
_LABELS = {
|
|
123
|
+
"thinking": "thinking",
|
|
124
|
+
"tool": "running tools",
|
|
125
|
+
"subagent": "running subagent",
|
|
126
|
+
"model": "waiting for model",
|
|
127
|
+
"stream": "streaming",
|
|
128
|
+
"reasoning": "reasoning",
|
|
129
|
+
"done": "done",
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
def __init__(self) -> None:
|
|
133
|
+
self._phase = "thinking"
|
|
134
|
+
self._detail = "waiting for model"
|
|
135
|
+
self._started_at = time.time()
|
|
136
|
+
self._live: Live | None = None
|
|
137
|
+
self._stop_hb = threading.Event()
|
|
138
|
+
self._hb_thread: threading.Thread | None = None
|
|
139
|
+
self._lock = threading.Lock()
|
|
140
|
+
self._spinner = Spinner(
|
|
141
|
+
"line",
|
|
142
|
+
text=Text(self._format_text(), style="orange"),
|
|
143
|
+
style="bold orange",
|
|
144
|
+
speed=1.2,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def _format_text(self) -> str:
|
|
148
|
+
label = self._LABELS.get(self._phase, self._phase)
|
|
149
|
+
elapsed = max(0.0, time.time() - self._started_at)
|
|
150
|
+
base = f"{label} — {self._detail}" if self._detail else label
|
|
151
|
+
return f"{base} ({elapsed:0.0f}s)"
|
|
152
|
+
|
|
153
|
+
def _apply_text(self) -> None:
|
|
154
|
+
with self._lock:
|
|
155
|
+
self._spinner.update(text=Text(self._format_text(), style="orange"))
|
|
156
|
+
|
|
157
|
+
def _heartbeat(self) -> None:
|
|
158
|
+
while not self._stop_hb.wait(0.08):
|
|
159
|
+
self._apply_text()
|
|
160
|
+
live = self._live
|
|
161
|
+
if live is not None:
|
|
162
|
+
try:
|
|
163
|
+
live.refresh()
|
|
164
|
+
except Exception: # noqa: BLE001
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
def start(self, phase: str = "thinking", detail: str = "waiting for model") -> None:
|
|
168
|
+
self._phase = phase
|
|
169
|
+
self._detail = detail
|
|
170
|
+
self._started_at = time.time()
|
|
171
|
+
self._apply_text()
|
|
172
|
+
if self._live is None:
|
|
173
|
+
self._live = Live(
|
|
174
|
+
self._spinner,
|
|
175
|
+
console=console,
|
|
176
|
+
refresh_per_second=16,
|
|
177
|
+
transient=True,
|
|
178
|
+
auto_refresh=True,
|
|
179
|
+
)
|
|
180
|
+
self._live.start()
|
|
181
|
+
if self._hb_thread is None or not self._hb_thread.is_alive():
|
|
182
|
+
self._stop_hb.clear()
|
|
183
|
+
self._hb_thread = threading.Thread(
|
|
184
|
+
target=self._heartbeat, name="activity-heartbeat", daemon=True
|
|
185
|
+
)
|
|
186
|
+
self._hb_thread.start()
|
|
187
|
+
|
|
188
|
+
def update(self, phase: str, detail: str = "", *, reset_timer: bool = False) -> None:
|
|
189
|
+
if detail.startswith("node="):
|
|
190
|
+
if self._live is None:
|
|
191
|
+
self.start(phase, "working")
|
|
192
|
+
else:
|
|
193
|
+
self._apply_text()
|
|
194
|
+
return
|
|
195
|
+
if phase == self._phase and detail == self._detail and not reset_timer:
|
|
196
|
+
self._apply_text()
|
|
197
|
+
return
|
|
198
|
+
self._phase = phase
|
|
199
|
+
self._detail = detail
|
|
200
|
+
if reset_timer:
|
|
201
|
+
self._started_at = time.time()
|
|
202
|
+
self._apply_text()
|
|
203
|
+
if self._live is None:
|
|
204
|
+
self.start(phase, detail)
|
|
205
|
+
|
|
206
|
+
def stop(self) -> None:
|
|
207
|
+
self._stop_hb.set()
|
|
208
|
+
if self._hb_thread is not None:
|
|
209
|
+
self._hb_thread.join(timeout=0.3)
|
|
210
|
+
self._hb_thread = None
|
|
211
|
+
if self._live is not None:
|
|
212
|
+
try:
|
|
213
|
+
self._live.stop()
|
|
214
|
+
except Exception: # noqa: BLE001
|
|
215
|
+
pass
|
|
216
|
+
self._live = None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class _StreamPrinter:
|
|
220
|
+
"""Owns console layout for reasoning + assistant text.
|
|
221
|
+
|
|
222
|
+
Design (low overhead):
|
|
223
|
+
- During tokens: **buffer only** + lightweight activity status.
|
|
224
|
+
No Rich Live, no per-token Markdown re-render (avoids flicker/cost).
|
|
225
|
+
- On commit: print permanent Markdown **once**.
|
|
226
|
+
- Dedup by msg_id + normalized text so final content is not repeated.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
def __init__(self, activity: _ActivityLine) -> None:
|
|
230
|
+
self.activity = activity
|
|
231
|
+
self.reasoning_open = False
|
|
232
|
+
self.answer_open = False
|
|
233
|
+
self.streamed_answer = False
|
|
234
|
+
self.streamed_reasoning = False
|
|
235
|
+
self.answer_buf: list[str] = []
|
|
236
|
+
self.reasoning_buf: list[str] = []
|
|
237
|
+
self._printed_complete_texts: set[str] = set()
|
|
238
|
+
self._token_streamed_msg_ids: set[str] = set()
|
|
239
|
+
self._open_msg_id: str | None = None
|
|
240
|
+
self._open_answer_parts: list[str] = []
|
|
241
|
+
self._open_reasoning_parts: list[str] = []
|
|
242
|
+
self._markdown_rendered_ids: set[str] = set()
|
|
243
|
+
self._last_committed_answer = ""
|
|
244
|
+
self._reasoning_committed_norms: set[str] = set()
|
|
245
|
+
self._last_status_at = 0.0
|
|
246
|
+
self._status_interval = 0.35
|
|
247
|
+
|
|
248
|
+
def _stop_activity(self) -> None:
|
|
249
|
+
self.activity.stop()
|
|
250
|
+
|
|
251
|
+
@staticmethod
|
|
252
|
+
def _norm_text(text: str) -> str:
|
|
253
|
+
return " ".join((text or "").split())
|
|
254
|
+
|
|
255
|
+
def _answer_group(self, text: str):
|
|
256
|
+
from rich.console import Group
|
|
257
|
+
|
|
258
|
+
body = text if text.strip() else "…"
|
|
259
|
+
return Group(
|
|
260
|
+
Text("assistant:", style="bold green"),
|
|
261
|
+
render_markdown(body),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def _reasoning_group(self, text: str):
|
|
265
|
+
from rich.console import Group
|
|
266
|
+
|
|
267
|
+
body = text if text.strip() else "…"
|
|
268
|
+
return Group(
|
|
269
|
+
Text("reasoning:", style="dim italic"),
|
|
270
|
+
render_markdown(body),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def _status(self, phase: str, detail: str) -> None:
|
|
274
|
+
"""Throttle activity-line updates so streaming stays cheap."""
|
|
275
|
+
now = time.time()
|
|
276
|
+
if (now - self._last_status_at) < self._status_interval:
|
|
277
|
+
return
|
|
278
|
+
self._last_status_at = now
|
|
279
|
+
try:
|
|
280
|
+
self.activity.update(phase, detail)
|
|
281
|
+
except Exception: # noqa: BLE001
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
def close_reasoning(self) -> None:
|
|
285
|
+
"""Seal reasoning buffer and commit permanent markdown once."""
|
|
286
|
+
if not self.reasoning_open and not self._open_reasoning_parts:
|
|
287
|
+
return
|
|
288
|
+
text = "".join(self._open_reasoning_parts).strip()
|
|
289
|
+
self.reasoning_open = False
|
|
290
|
+
self._open_reasoning_parts = []
|
|
291
|
+
if not text:
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
norm = self._norm_text(text)
|
|
295
|
+
if norm and norm in self._reasoning_committed_norms:
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
self._stop_activity()
|
|
299
|
+
console.print()
|
|
300
|
+
console.print(self._reasoning_group(text))
|
|
301
|
+
try:
|
|
302
|
+
console.file.flush()
|
|
303
|
+
except Exception: # noqa: BLE001
|
|
304
|
+
pass
|
|
305
|
+
if norm:
|
|
306
|
+
self._reasoning_committed_norms.add(norm)
|
|
307
|
+
self.streamed_reasoning = True
|
|
308
|
+
|
|
309
|
+
def close_answer(self) -> None:
|
|
310
|
+
"""Seal token buffer flag; content is committed via flush/complete."""
|
|
311
|
+
self.answer_open = False
|
|
312
|
+
if self._open_msg_id:
|
|
313
|
+
self._token_streamed_msg_ids.add(self._open_msg_id)
|
|
314
|
+
|
|
315
|
+
def write_reasoning(self, text: str) -> None:
|
|
316
|
+
"""Buffer reasoning tokens; render Markdown only on close."""
|
|
317
|
+
if not text:
|
|
318
|
+
return
|
|
319
|
+
if self._open_answer_parts:
|
|
320
|
+
self.flush_buffered_answer()
|
|
321
|
+
self.close_answer()
|
|
322
|
+
if not self.reasoning_open:
|
|
323
|
+
self.reasoning_open = True
|
|
324
|
+
self.streamed_reasoning = True
|
|
325
|
+
self._open_reasoning_parts = []
|
|
326
|
+
self._open_reasoning_parts.append(text)
|
|
327
|
+
self.reasoning_buf.append(text)
|
|
328
|
+
n = sum(len(p) for p in self._open_reasoning_parts)
|
|
329
|
+
self._status("thinking", f"reasoning {n}c")
|
|
330
|
+
|
|
331
|
+
def write_answer_token(self, text: str, *, msg_id: str | None = None) -> None:
|
|
332
|
+
"""Buffer answer tokens; render Markdown only on complete/flush."""
|
|
333
|
+
if not text:
|
|
334
|
+
return
|
|
335
|
+
if msg_id and msg_id in self._markdown_rendered_ids:
|
|
336
|
+
return
|
|
337
|
+
self.close_reasoning()
|
|
338
|
+
if not self.answer_open:
|
|
339
|
+
if (
|
|
340
|
+
msg_id
|
|
341
|
+
and msg_id in self._token_streamed_msg_ids
|
|
342
|
+
and self._last_committed_answer
|
|
343
|
+
):
|
|
344
|
+
return
|
|
345
|
+
self.answer_open = True
|
|
346
|
+
self._open_answer_parts = []
|
|
347
|
+
self._open_msg_id = msg_id
|
|
348
|
+
elif msg_id and self._open_msg_id and msg_id != self._open_msg_id:
|
|
349
|
+
self.flush_buffered_answer()
|
|
350
|
+
if msg_id in self._markdown_rendered_ids:
|
|
351
|
+
return
|
|
352
|
+
self.answer_open = True
|
|
353
|
+
self._open_answer_parts = []
|
|
354
|
+
self._open_msg_id = msg_id
|
|
355
|
+
elif msg_id and not self._open_msg_id:
|
|
356
|
+
self._open_msg_id = msg_id
|
|
357
|
+
|
|
358
|
+
self._open_answer_parts.append(text)
|
|
359
|
+
if msg_id:
|
|
360
|
+
self._token_streamed_msg_ids.add(msg_id)
|
|
361
|
+
self.streamed_answer = True
|
|
362
|
+
n = sum(len(p) for p in self._open_answer_parts)
|
|
363
|
+
self._status("model", f"composing {n}c")
|
|
364
|
+
|
|
365
|
+
def _print_markdown_answer(self, text: str, *, msg_id: str | None = None) -> None:
|
|
366
|
+
"""Commit one assistant message as permanent Markdown (exactly once)."""
|
|
367
|
+
text = text.strip()
|
|
368
|
+
if not text:
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
norm = self._norm_text(text)
|
|
372
|
+
if msg_id and msg_id in self._markdown_rendered_ids:
|
|
373
|
+
self.answer_open = False
|
|
374
|
+
self._open_answer_parts = []
|
|
375
|
+
self._open_msg_id = None
|
|
376
|
+
return
|
|
377
|
+
if norm and (
|
|
378
|
+
norm in self._printed_complete_texts
|
|
379
|
+
or norm == self._norm_text(self._last_committed_answer)
|
|
380
|
+
):
|
|
381
|
+
self.answer_open = False
|
|
382
|
+
self._open_answer_parts = []
|
|
383
|
+
self._open_msg_id = None
|
|
384
|
+
self.streamed_answer = True
|
|
385
|
+
if msg_id:
|
|
386
|
+
self._markdown_rendered_ids.add(msg_id)
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
self._stop_activity()
|
|
390
|
+
self.close_reasoning()
|
|
391
|
+
self.answer_open = False
|
|
392
|
+
self._open_answer_parts = []
|
|
393
|
+
self._open_msg_id = None
|
|
394
|
+
|
|
395
|
+
console.print()
|
|
396
|
+
console.print(self._answer_group(text))
|
|
397
|
+
try:
|
|
398
|
+
console.file.flush()
|
|
399
|
+
except Exception: # noqa: BLE001
|
|
400
|
+
pass
|
|
401
|
+
|
|
402
|
+
self.answer_buf.append(text)
|
|
403
|
+
self._last_committed_answer = text
|
|
404
|
+
if norm:
|
|
405
|
+
self._printed_complete_texts.add(norm)
|
|
406
|
+
self.streamed_answer = True
|
|
407
|
+
if msg_id:
|
|
408
|
+
self._markdown_rendered_ids.add(msg_id)
|
|
409
|
+
self._token_streamed_msg_ids.add(msg_id)
|
|
410
|
+
|
|
411
|
+
def write_answer_complete(
|
|
412
|
+
self,
|
|
413
|
+
text: str,
|
|
414
|
+
*,
|
|
415
|
+
msg_id: str | None = None,
|
|
416
|
+
) -> None:
|
|
417
|
+
"""Complete an assistant message; commit permanent markdown once."""
|
|
418
|
+
text = text.strip()
|
|
419
|
+
if not text:
|
|
420
|
+
return
|
|
421
|
+
self._print_markdown_answer(text, msg_id=msg_id)
|
|
422
|
+
|
|
423
|
+
def flush_buffered_answer(self) -> None:
|
|
424
|
+
"""Flush token buffer when tools or reasoning interrupt."""
|
|
425
|
+
buffered = "".join(self._open_answer_parts).strip()
|
|
426
|
+
msg_id = self._open_msg_id
|
|
427
|
+
self._open_answer_parts = []
|
|
428
|
+
self.answer_open = False
|
|
429
|
+
self._open_msg_id = None
|
|
430
|
+
if buffered:
|
|
431
|
+
self._print_markdown_answer(buffered, msg_id=msg_id)
|
|
432
|
+
|
|
433
|
+
def finalize_line(self) -> None:
|
|
434
|
+
self.close_reasoning()
|
|
435
|
+
self.flush_buffered_answer()
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
class RichStreamSink:
|
|
439
|
+
"""CLI StreamSink backed by Rich Live + console printing."""
|
|
440
|
+
|
|
441
|
+
def __init__(self) -> None:
|
|
442
|
+
self._activity = _ActivityLine()
|
|
443
|
+
self._printer = _StreamPrinter(self._activity)
|
|
444
|
+
|
|
445
|
+
@property
|
|
446
|
+
def streamed_answer(self) -> bool:
|
|
447
|
+
return self._printer.streamed_answer
|
|
448
|
+
|
|
449
|
+
@streamed_answer.setter
|
|
450
|
+
def streamed_answer(self, value: bool) -> None:
|
|
451
|
+
self._printer.streamed_answer = value
|
|
452
|
+
|
|
453
|
+
@property
|
|
454
|
+
def answer_buf(self) -> list[str]:
|
|
455
|
+
return self._printer.answer_buf
|
|
456
|
+
|
|
457
|
+
@property
|
|
458
|
+
def reasoning_buf(self) -> list[str]:
|
|
459
|
+
return self._printer.reasoning_buf
|
|
460
|
+
|
|
461
|
+
@property
|
|
462
|
+
def streamed_reasoning(self) -> bool:
|
|
463
|
+
return self._printer.streamed_reasoning
|
|
464
|
+
|
|
465
|
+
@streamed_reasoning.setter
|
|
466
|
+
def streamed_reasoning(self, value: bool) -> None:
|
|
467
|
+
self._printer.streamed_reasoning = value
|
|
468
|
+
|
|
469
|
+
def activity_start(self, phase: str = "thinking", detail: str = "waiting for model") -> None:
|
|
470
|
+
self._activity.start(phase, detail)
|
|
471
|
+
|
|
472
|
+
def activity_update(
|
|
473
|
+
self,
|
|
474
|
+
phase: str,
|
|
475
|
+
detail: str = "",
|
|
476
|
+
*,
|
|
477
|
+
reset_timer: bool = False,
|
|
478
|
+
) -> None:
|
|
479
|
+
self._activity.update(phase, detail, reset_timer=reset_timer)
|
|
480
|
+
|
|
481
|
+
def activity_stop(self) -> None:
|
|
482
|
+
self._activity.stop()
|
|
483
|
+
|
|
484
|
+
def write_reasoning(self, text: str) -> None:
|
|
485
|
+
self._printer.write_reasoning(text)
|
|
486
|
+
|
|
487
|
+
def close_reasoning(self) -> None:
|
|
488
|
+
self._printer.close_reasoning()
|
|
489
|
+
|
|
490
|
+
def write_answer_token(self, text: str, *, msg_id: str | None = None) -> None:
|
|
491
|
+
self._printer.write_answer_token(text, msg_id=msg_id)
|
|
492
|
+
|
|
493
|
+
def write_answer_complete(self, text: str, *, msg_id: str | None = None) -> None:
|
|
494
|
+
self._printer.write_answer_complete(text, msg_id=msg_id)
|
|
495
|
+
|
|
496
|
+
def finalize_line(self) -> None:
|
|
497
|
+
self._printer.finalize_line()
|
|
498
|
+
|
|
499
|
+
def tool_calls_started(self, calls: list[Any], *, parallel: bool) -> None:
|
|
500
|
+
if parallel:
|
|
501
|
+
console.print(
|
|
502
|
+
f"[bold magenta]→ tools x{len(calls)} (parallel)[/bold magenta]"
|
|
503
|
+
)
|
|
504
|
+
else:
|
|
505
|
+
console.print("[bold magenta]→ tool[/bold magenta]")
|
|
506
|
+
for call in calls:
|
|
507
|
+
name = _tool_call_name(call)
|
|
508
|
+
args = _tool_call_args(call)
|
|
509
|
+
console.print(
|
|
510
|
+
f" [yellow]{name}[/yellow] "
|
|
511
|
+
f"[dim]{_format_tool_args(args)}[/dim]"
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
def tool_result(self, name: str, status: str, *, sub: bool = False) -> None:
|
|
515
|
+
prefix = "sub" if sub else ""
|
|
516
|
+
style = "red" if status.lower().startswith("error") else "green"
|
|
517
|
+
console.print()
|
|
518
|
+
console.print(
|
|
519
|
+
f"[dim]←{prefix}[/dim] [yellow]{name}[/yellow] "
|
|
520
|
+
f"[{style}]{status}[/{style}]"
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
def info(self, message: str) -> None:
|
|
524
|
+
print_info(message)
|
|
525
|
+
|
|
526
|
+
def note_usage(
|
|
527
|
+
self,
|
|
528
|
+
*,
|
|
529
|
+
turn_input: int = 0,
|
|
530
|
+
turn_output: int = 0,
|
|
531
|
+
turn_cache: int = 0,
|
|
532
|
+
last_input: int = 0,
|
|
533
|
+
last_output: int = 0,
|
|
534
|
+
last_cache: int = 0,
|
|
535
|
+
) -> None:
|
|
536
|
+
"""Optional live token chrome (TUI overrides)."""
|
|
537
|
+
del turn_input, turn_output, turn_cache, last_input, last_output, last_cache
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def stream_agent(
|
|
545
|
+
agent,
|
|
546
|
+
payload: Any,
|
|
547
|
+
config: dict[str, Any],
|
|
548
|
+
*,
|
|
549
|
+
token_stream: bool = True,
|
|
550
|
+
prefer_async: bool = True,
|
|
551
|
+
max_concurrency: int = 8,
|
|
552
|
+
subgraphs: bool = True,
|
|
553
|
+
sink: StreamSink | None = None,
|
|
554
|
+
cancel_event: threading.Event | None = None,
|
|
555
|
+
) -> StreamResult:
|
|
556
|
+
"""Stream agent with reasoning + answer tokens and tool/subagent progress.
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
payload: User message dict or LangGraph ``Command`` (HITL resume).
|
|
560
|
+
sink: Optional UI consumer. Defaults to Rich CLI sink.
|
|
561
|
+
"""
|
|
562
|
+
# Sync-only SqliteSaver cannot astream. AsyncSqliteSaver + process runtime can.
|
|
563
|
+
if prefer_async:
|
|
564
|
+
cp = getattr(agent, "_coding_checkpointer", None)
|
|
565
|
+
if not checkpointer_supports_async(cp):
|
|
566
|
+
prefer_async = False
|
|
567
|
+
|
|
568
|
+
started = time.time()
|
|
569
|
+
final: dict[str, Any] = {}
|
|
570
|
+
printed_ids: set[str] = set()
|
|
571
|
+
tool_calls = 0
|
|
572
|
+
input_tokens = 0
|
|
573
|
+
output_tokens = 0
|
|
574
|
+
cache_tokens = 0
|
|
575
|
+
last_input_tokens = 0
|
|
576
|
+
last_output_tokens = 0
|
|
577
|
+
last_cache_tokens = 0
|
|
578
|
+
_usage_seen: set[str] = set() # dedupe usage from repeated messages
|
|
579
|
+
sink = sink or RichStreamSink()
|
|
580
|
+
active_tools: list[str] = []
|
|
581
|
+
use_tool_items = sink_supports_tool_items(sink)
|
|
582
|
+
pending_tool_items: list[Any] = []
|
|
583
|
+
tool_group_seq = 0
|
|
584
|
+
# Nested subagent events are interleaved. Keep labels, pending items, and
|
|
585
|
+
# parent task ownership scoped by LangGraph namespace.
|
|
586
|
+
sub_tool_labels: dict[tuple[str, ...], dict[str, str]] = {}
|
|
587
|
+
sub_scope_seq: dict[tuple[str, ...], int] = {}
|
|
588
|
+
parent_task_items: dict[str, str] = {}
|
|
589
|
+
current_parent_task_ids: set[str] = set()
|
|
590
|
+
|
|
591
|
+
def _sub_task_call_id(namespace: tuple[str, ...]) -> str | None:
|
|
592
|
+
"""Extract the nearest injected task call ID from the namespace."""
|
|
593
|
+
marker = "task_call:"
|
|
594
|
+
for segment in reversed(namespace):
|
|
595
|
+
for part in reversed(str(segment).split("|")):
|
|
596
|
+
if part.startswith(marker):
|
|
597
|
+
call_id = part.removeprefix(marker).strip()
|
|
598
|
+
if call_id:
|
|
599
|
+
return call_id
|
|
600
|
+
return None
|
|
601
|
+
|
|
602
|
+
def _sub_scope(namespace: tuple[str, ...]) -> tuple[str, ...]:
|
|
603
|
+
"""Return a stable scope ending at the injected parent task ID."""
|
|
604
|
+
call_id = _sub_task_call_id(namespace)
|
|
605
|
+
if call_id:
|
|
606
|
+
return (f"task_call:{call_id}",)
|
|
607
|
+
return namespace[:1] if namespace else ()
|
|
608
|
+
|
|
609
|
+
def _sub_parent_id(namespace: tuple[str, ...]) -> str | None:
|
|
610
|
+
call_id = _sub_task_call_id(namespace)
|
|
611
|
+
if call_id:
|
|
612
|
+
return parent_task_items.get(call_id)
|
|
613
|
+
|
|
614
|
+
# Some stream adapters omit the injected checkpoint namespace. Only a
|
|
615
|
+
# batch that launched exactly one parent task is safe to infer. Once a
|
|
616
|
+
# batch was concurrent, late events must never be reassigned to the last
|
|
617
|
+
# remaining task.
|
|
618
|
+
task_items = [
|
|
619
|
+
item for item in pending_tool_items if item.name == "task" and not item.sub
|
|
620
|
+
]
|
|
621
|
+
if len(current_parent_task_ids) == 1 and len(task_items) == 1:
|
|
622
|
+
task = task_items[0]
|
|
623
|
+
if task.status == "running":
|
|
624
|
+
return task.id
|
|
625
|
+
return None
|
|
626
|
+
|
|
627
|
+
def _pending_sub_item(namespace: tuple[str, ...], name: str, call_id: str) -> Any:
|
|
628
|
+
parent_id = _sub_parent_id(namespace)
|
|
629
|
+
if parent_id is None:
|
|
630
|
+
return None
|
|
631
|
+
for item in pending_tool_items:
|
|
632
|
+
if not getattr(item, "sub", False):
|
|
633
|
+
continue
|
|
634
|
+
if getattr(item, "parent_id", None) != parent_id:
|
|
635
|
+
continue
|
|
636
|
+
if call_id:
|
|
637
|
+
if getattr(item, "call_id", None) == call_id:
|
|
638
|
+
return item
|
|
639
|
+
continue
|
|
640
|
+
if item.name == name:
|
|
641
|
+
return item
|
|
642
|
+
return None
|
|
643
|
+
|
|
644
|
+
run_config = dict(config or {})
|
|
645
|
+
run_config.setdefault("max_concurrency", max_concurrency)
|
|
646
|
+
if "configurable" in (config or {}):
|
|
647
|
+
run_config["configurable"] = dict(config["configurable"])
|
|
648
|
+
|
|
649
|
+
sink.activity_start("thinking", "waiting for model")
|
|
650
|
+
cancelled = False
|
|
651
|
+
compact_announced = False
|
|
652
|
+
suppress_msg_ids: set[str] = set()
|
|
653
|
+
compact_events = 0
|
|
654
|
+
|
|
655
|
+
# -- install retry notifier so the middleware can post status-bar updates --
|
|
656
|
+
from synapse.runtime.middleware import clear_retry_notifier, set_retry_notifier
|
|
657
|
+
|
|
658
|
+
def _retry_notify(attempt: int, delay: float, reason: str) -> None:
|
|
659
|
+
"""Post a single-line retry notice through the sink."""
|
|
660
|
+
try:
|
|
661
|
+
sink.info(f"model retry #{attempt} in {delay:.1f}s ({reason})")
|
|
662
|
+
except Exception: # noqa: BLE001
|
|
663
|
+
pass
|
|
664
|
+
|
|
665
|
+
set_retry_notifier(_retry_notify)
|
|
666
|
+
|
|
667
|
+
def _note_compact() -> None:
|
|
668
|
+
nonlocal compact_announced, compact_events
|
|
669
|
+
compact_events += 1
|
|
670
|
+
if compact_announced:
|
|
671
|
+
return
|
|
672
|
+
compact_announced = True
|
|
673
|
+
try:
|
|
674
|
+
sink.info("context compacted (hidden)")
|
|
675
|
+
except Exception: # noqa: BLE001
|
|
676
|
+
pass
|
|
677
|
+
|
|
678
|
+
def _drop_leaked_stream() -> None:
|
|
679
|
+
for name in ("clear_stream", "close_stream", "finalize_line"):
|
|
680
|
+
fn = getattr(sink, name, None)
|
|
681
|
+
if callable(fn):
|
|
682
|
+
try:
|
|
683
|
+
fn()
|
|
684
|
+
except Exception: # noqa: BLE001
|
|
685
|
+
pass
|
|
686
|
+
break
|
|
687
|
+
for attr in ("answer_buf", "_open_answer"):
|
|
688
|
+
buf = getattr(sink, attr, None)
|
|
689
|
+
if isinstance(buf, list):
|
|
690
|
+
buf.clear()
|
|
691
|
+
if hasattr(sink, "streamed_answer"):
|
|
692
|
+
try:
|
|
693
|
+
sink.streamed_answer = False
|
|
694
|
+
except Exception: # noqa: BLE001
|
|
695
|
+
pass
|
|
696
|
+
|
|
697
|
+
def _mark_cancelled() -> None:
|
|
698
|
+
nonlocal cancelled
|
|
699
|
+
if cancelled:
|
|
700
|
+
return
|
|
701
|
+
cancelled = True
|
|
702
|
+
try:
|
|
703
|
+
sink.info("stream cancelled")
|
|
704
|
+
except Exception: # noqa: BLE001
|
|
705
|
+
pass
|
|
706
|
+
# Best-effort: close open tool rows so the timeline does not stick on "running".
|
|
707
|
+
if use_tool_items and pending_tool_items:
|
|
708
|
+
for item in list(pending_tool_items):
|
|
709
|
+
try:
|
|
710
|
+
item.status = "error"
|
|
711
|
+
item.error = True
|
|
712
|
+
sink.tool_item_finished(
|
|
713
|
+
item.id,
|
|
714
|
+
status="cancelled",
|
|
715
|
+
preview="cancelled",
|
|
716
|
+
error=True,
|
|
717
|
+
)
|
|
718
|
+
except Exception: # noqa: BLE001
|
|
719
|
+
pass
|
|
720
|
+
pending_tool_items.clear()
|
|
721
|
+
try:
|
|
722
|
+
sink.tool_group_closed(f"g{tool_group_seq}")
|
|
723
|
+
except Exception: # noqa: BLE001
|
|
724
|
+
pass
|
|
725
|
+
|
|
726
|
+
try:
|
|
727
|
+
for mode, chunk, ns in _iter_stream_events(
|
|
728
|
+
agent,
|
|
729
|
+
payload,
|
|
730
|
+
run_config,
|
|
731
|
+
token_stream=token_stream,
|
|
732
|
+
prefer_async=prefer_async,
|
|
733
|
+
subgraphs=subgraphs,
|
|
734
|
+
cancel_event=cancel_event,
|
|
735
|
+
):
|
|
736
|
+
if mode == "__cancelled__" or (
|
|
737
|
+
cancel_event is not None and cancel_event.is_set() and mode == "__heartbeat__"
|
|
738
|
+
):
|
|
739
|
+
_mark_cancelled()
|
|
740
|
+
break
|
|
741
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
742
|
+
_mark_cancelled()
|
|
743
|
+
break
|
|
744
|
+
if mode == "__heartbeat__":
|
|
745
|
+
if active_tools:
|
|
746
|
+
phase = "subagent" if "task" in active_tools else "tool"
|
|
747
|
+
if phase == "subagent":
|
|
748
|
+
# Keep sticky intent; sink coalesces/delays subagent text.
|
|
749
|
+
sink.activity_update("subagent", "子代理运行中")
|
|
750
|
+
else:
|
|
751
|
+
label = ", ".join(active_tools[:3])
|
|
752
|
+
sink.activity_update(phase, f"{label} still running")
|
|
753
|
+
else:
|
|
754
|
+
sink.activity_update("model", "waiting for model")
|
|
755
|
+
continue
|
|
756
|
+
|
|
757
|
+
in_sub = bool(ns)
|
|
758
|
+
|
|
759
|
+
if mode == "messages":
|
|
760
|
+
msg_chunk = chunk
|
|
761
|
+
meta: dict[str, Any] = {}
|
|
762
|
+
if isinstance(chunk, tuple) and len(chunk) == 2:
|
|
763
|
+
msg_chunk, meta = chunk[0], chunk[1] or {}
|
|
764
|
+
|
|
765
|
+
node = ""
|
|
766
|
+
if isinstance(meta, dict):
|
|
767
|
+
node = str(
|
|
768
|
+
meta.get("langgraph_node") or meta.get("checkpoint_ns") or ""
|
|
769
|
+
)
|
|
770
|
+
if node and any(x in node for x in ("tools", "tool")):
|
|
771
|
+
continue
|
|
772
|
+
|
|
773
|
+
# Nested summarization invoke must not stream SESSION INTENT into TUI.
|
|
774
|
+
if is_stream_meta_summarization(meta) or is_lc_summarization_message(
|
|
775
|
+
msg_chunk
|
|
776
|
+
):
|
|
777
|
+
mid = getattr(msg_chunk, "id", None)
|
|
778
|
+
if mid is not None:
|
|
779
|
+
suppress_msg_ids.add(str(mid))
|
|
780
|
+
_note_compact()
|
|
781
|
+
_drop_leaked_stream()
|
|
782
|
+
continue
|
|
783
|
+
|
|
784
|
+
# Model-only guidance must not enter visible token/reasoning buffers.
|
|
785
|
+
if is_steer_message(msg_chunk):
|
|
786
|
+
mid = getattr(msg_chunk, "id", None)
|
|
787
|
+
if mid is not None:
|
|
788
|
+
suppress_msg_ids.add(str(mid))
|
|
789
|
+
_drop_leaked_stream()
|
|
790
|
+
continue
|
|
791
|
+
|
|
792
|
+
mid = getattr(msg_chunk, "id", None)
|
|
793
|
+
if mid is not None and str(mid) in suppress_msg_ids:
|
|
794
|
+
continue
|
|
795
|
+
|
|
796
|
+
if in_sub:
|
|
797
|
+
# Nested token stream is high-frequency; keep sticky intent.
|
|
798
|
+
continue
|
|
799
|
+
|
|
800
|
+
reasoning_delta = _extract_reasoning(msg_chunk)
|
|
801
|
+
if reasoning_delta:
|
|
802
|
+
sink.activity_update("reasoning", "model thinking")
|
|
803
|
+
sink.write_reasoning(reasoning_delta)
|
|
804
|
+
|
|
805
|
+
# Content tokens first — same chunk may also carry tool_call_chunks.
|
|
806
|
+
text = _chunk_text(msg_chunk)
|
|
807
|
+
msg_id = getattr(msg_chunk, "id", None)
|
|
808
|
+
if msg_id is not None:
|
|
809
|
+
msg_id = str(msg_id)
|
|
810
|
+
if text:
|
|
811
|
+
if is_context_compact_text(text):
|
|
812
|
+
if msg_id:
|
|
813
|
+
suppress_msg_ids.add(msg_id)
|
|
814
|
+
_note_compact()
|
|
815
|
+
_drop_leaked_stream()
|
|
816
|
+
continue
|
|
817
|
+
sink.write_answer_token(text, msg_id=msg_id)
|
|
818
|
+
|
|
819
|
+
tool_call_chunks = getattr(msg_chunk, "tool_call_chunks", None) or []
|
|
820
|
+
if tool_call_chunks:
|
|
821
|
+
sink.finalize_line()
|
|
822
|
+
sink.activity_update("tool", "model requested tool call(s)")
|
|
823
|
+
continue
|
|
824
|
+
|
|
825
|
+
if mode != "updates" or not isinstance(chunk, dict):
|
|
826
|
+
continue
|
|
827
|
+
|
|
828
|
+
# Middleware-only jump maps (all Nones) are not agent state.
|
|
829
|
+
if _looks_like_middleware_update(chunk):
|
|
830
|
+
sink.activity_update("model", "working")
|
|
831
|
+
continue
|
|
832
|
+
|
|
833
|
+
if chunk and all(isinstance(v, dict) for v in chunk.values()):
|
|
834
|
+
node_items = list(chunk.items())
|
|
835
|
+
else:
|
|
836
|
+
node_items = [("graph" if not in_sub else "subagent", chunk)]
|
|
837
|
+
|
|
838
|
+
for _node_name, update in node_items:
|
|
839
|
+
if not isinstance(update, dict):
|
|
840
|
+
continue
|
|
841
|
+
if _looks_like_middleware_update(update):
|
|
842
|
+
sink.activity_update("model", "working")
|
|
843
|
+
continue
|
|
844
|
+
if not in_sub:
|
|
845
|
+
final.update(update)
|
|
846
|
+
messages = update.get("messages") or []
|
|
847
|
+
if not messages:
|
|
848
|
+
sink.activity_update("model", "working")
|
|
849
|
+
continue
|
|
850
|
+
|
|
851
|
+
for msg in messages:
|
|
852
|
+
msg_id = getattr(msg, "id", None) or id(msg)
|
|
853
|
+
dedupe_key = f"{'/'.join(ns)}:{msg_id}"
|
|
854
|
+
if dedupe_key in printed_ids:
|
|
855
|
+
continue
|
|
856
|
+
printed_ids.add(dedupe_key)
|
|
857
|
+
|
|
858
|
+
if is_steer_message(msg):
|
|
859
|
+
suppress_msg_ids.add(str(msg_id))
|
|
860
|
+
_drop_leaked_stream()
|
|
861
|
+
continue
|
|
862
|
+
|
|
863
|
+
if _is_tool_message(msg):
|
|
864
|
+
name = getattr(msg, "name", "tool")
|
|
865
|
+
raw_content = getattr(msg, "content", "")
|
|
866
|
+
status = summarize_tool_result(raw_content, limit=100)
|
|
867
|
+
sink.finalize_line()
|
|
868
|
+
# Nested subgraph tool traffic must not paint the parent
|
|
869
|
+
# timeline and must not reset status to idle mid-task.
|
|
870
|
+
if in_sub:
|
|
871
|
+
tool_call_id = str(
|
|
872
|
+
getattr(msg, "tool_call_id", None)
|
|
873
|
+
or getattr(msg, "id", None)
|
|
874
|
+
or ""
|
|
875
|
+
)
|
|
876
|
+
scope = _sub_scope(ns)
|
|
877
|
+
labels = sub_tool_labels.get(scope, {})
|
|
878
|
+
label = (
|
|
879
|
+
(tool_call_id and labels.get(tool_call_id))
|
|
880
|
+
or labels.get(str(name))
|
|
881
|
+
or str(name)
|
|
882
|
+
)
|
|
883
|
+
body = content_to_text(raw_content)
|
|
884
|
+
err = is_error_status(status, body)
|
|
885
|
+
detail = f"{label} 失败" if err else label
|
|
886
|
+
try:
|
|
887
|
+
sink.activity_update("subagent", detail, force=True)
|
|
888
|
+
except TypeError:
|
|
889
|
+
sink.activity_update("subagent", detail)
|
|
890
|
+
# Also finish the nested tool item in the timeline.
|
|
891
|
+
if use_tool_items:
|
|
892
|
+
item = _pending_sub_item(ns, str(name), tool_call_id)
|
|
893
|
+
preview = truncate_preview(raw_content)
|
|
894
|
+
if item is not None:
|
|
895
|
+
if is_todo_tool(item.name) and item.preview:
|
|
896
|
+
preview = item.preview
|
|
897
|
+
item.status = "error" if err else "ok"
|
|
898
|
+
item.error = err
|
|
899
|
+
item.preview = preview
|
|
900
|
+
sink.tool_item_finished(
|
|
901
|
+
item.id,
|
|
902
|
+
status=item.status,
|
|
903
|
+
preview=preview,
|
|
904
|
+
error=err,
|
|
905
|
+
)
|
|
906
|
+
try:
|
|
907
|
+
pending_tool_items.remove(item)
|
|
908
|
+
except ValueError:
|
|
909
|
+
pass
|
|
910
|
+
continue
|
|
911
|
+
sink.activity_stop()
|
|
912
|
+
if use_tool_items:
|
|
913
|
+
tool_call_id = str(
|
|
914
|
+
getattr(msg, "tool_call_id", None) or ""
|
|
915
|
+
)
|
|
916
|
+
item = match_tool_result(
|
|
917
|
+
pending_tool_items, str(name), tool_call_id or None
|
|
918
|
+
)
|
|
919
|
+
preview = truncate_preview(raw_content)
|
|
920
|
+
err = is_error_status(status, content_to_text(raw_content))
|
|
921
|
+
if item is not None:
|
|
922
|
+
# Keep checklist from tool args; result is usually a short ack.
|
|
923
|
+
if is_todo_tool(item.name) and item.preview:
|
|
924
|
+
preview = item.preview
|
|
925
|
+
item.status = "error" if err else "ok"
|
|
926
|
+
item.error = err
|
|
927
|
+
item.preview = preview
|
|
928
|
+
sink.tool_item_finished(
|
|
929
|
+
item.id,
|
|
930
|
+
status=status,
|
|
931
|
+
preview=preview,
|
|
932
|
+
error=err,
|
|
933
|
+
)
|
|
934
|
+
try:
|
|
935
|
+
pending_tool_items.remove(item)
|
|
936
|
+
except ValueError:
|
|
937
|
+
pass
|
|
938
|
+
# Unmatched parent results are ignored under the item
|
|
939
|
+
# API — never invent empty "0 tools" groups.
|
|
940
|
+
if not pending_tool_items:
|
|
941
|
+
sink.tool_group_closed(f"g{tool_group_seq}")
|
|
942
|
+
# Multi-round agent loop: after a tool batch the
|
|
943
|
+
# model may think / speak again.
|
|
944
|
+
sink.streamed_reasoning = False
|
|
945
|
+
else:
|
|
946
|
+
sink.tool_result(name, status, sub=False)
|
|
947
|
+
if name in active_tools:
|
|
948
|
+
try:
|
|
949
|
+
active_tools.remove(name)
|
|
950
|
+
except ValueError:
|
|
951
|
+
pass
|
|
952
|
+
sink.activity_start("model", "waiting for model")
|
|
953
|
+
continue
|
|
954
|
+
|
|
955
|
+
if not _is_ai_message(msg):
|
|
956
|
+
# Hide summarization HumanMessage wrappers if state-emitted.
|
|
957
|
+
if is_lc_summarization_message(msg) or is_context_compact_text(
|
|
958
|
+
_normalize_content(getattr(msg, "content", ""))
|
|
959
|
+
):
|
|
960
|
+
_note_compact()
|
|
961
|
+
continue
|
|
962
|
+
|
|
963
|
+
# Accumulate token usage (dedupe by msg id).
|
|
964
|
+
usage_key = f"usage:{msg_id if msg_id else id(msg)}"
|
|
965
|
+
if usage_key not in _usage_seen:
|
|
966
|
+
u = _extract_usage(msg)
|
|
967
|
+
input_tokens += u["input_tokens"]
|
|
968
|
+
output_tokens += u["output_tokens"]
|
|
969
|
+
cache_tokens += u.get("cache_tokens", 0)
|
|
970
|
+
# Occupancy chrome: keep the latest call's raw return values.
|
|
971
|
+
if (
|
|
972
|
+
u["input_tokens"]
|
|
973
|
+
or u["output_tokens"]
|
|
974
|
+
or u.get("cache_tokens")
|
|
975
|
+
):
|
|
976
|
+
last_input_tokens = int(u["input_tokens"] or 0)
|
|
977
|
+
last_output_tokens = int(u["output_tokens"] or 0)
|
|
978
|
+
last_cache_tokens = int(u.get("cache_tokens", 0) or 0)
|
|
979
|
+
_usage_seen.add(usage_key)
|
|
980
|
+
note = getattr(sink, "note_usage", None)
|
|
981
|
+
if callable(note):
|
|
982
|
+
try:
|
|
983
|
+
note(
|
|
984
|
+
turn_input=input_tokens,
|
|
985
|
+
turn_output=output_tokens,
|
|
986
|
+
turn_cache=cache_tokens,
|
|
987
|
+
last_input=last_input_tokens,
|
|
988
|
+
last_output=last_output_tokens,
|
|
989
|
+
last_cache=last_cache_tokens,
|
|
990
|
+
)
|
|
991
|
+
except Exception: # noqa: BLE001
|
|
992
|
+
pass
|
|
993
|
+
|
|
994
|
+
reasoning = _extract_reasoning(msg)
|
|
995
|
+
text = _normalize_content(getattr(msg, "content", "")).strip()
|
|
996
|
+
calls = getattr(msg, "tool_calls", None) or []
|
|
997
|
+
msg_id = getattr(msg, "id", None)
|
|
998
|
+
if msg_id is not None:
|
|
999
|
+
msg_id = str(msg_id)
|
|
1000
|
+
|
|
1001
|
+
if is_steer_message(msg, text=text):
|
|
1002
|
+
if msg_id:
|
|
1003
|
+
suppress_msg_ids.add(msg_id)
|
|
1004
|
+
_drop_leaked_stream()
|
|
1005
|
+
continue
|
|
1006
|
+
|
|
1007
|
+
if is_lc_summarization_message(msg) or is_context_compact_text(text):
|
|
1008
|
+
if msg_id:
|
|
1009
|
+
suppress_msg_ids.add(msg_id)
|
|
1010
|
+
_note_compact()
|
|
1011
|
+
_drop_leaked_stream()
|
|
1012
|
+
continue
|
|
1013
|
+
|
|
1014
|
+
if in_sub:
|
|
1015
|
+
if calls:
|
|
1016
|
+
scope = _sub_scope(ns)
|
|
1017
|
+
labels = sub_tool_labels.setdefault(scope, {})
|
|
1018
|
+
parent_id = _sub_parent_id(ns)
|
|
1019
|
+
for call in calls:
|
|
1020
|
+
label = human_tool_label(call)
|
|
1021
|
+
cid = _tool_call_id(call)
|
|
1022
|
+
n = _tool_call_name(call)
|
|
1023
|
+
if cid:
|
|
1024
|
+
labels[cid] = label
|
|
1025
|
+
if n:
|
|
1026
|
+
labels[n] = label
|
|
1027
|
+
detail = human_nested_tools_detail(list(calls), limit=5)
|
|
1028
|
+
try:
|
|
1029
|
+
sink.activity_update("subagent", detail, force=True)
|
|
1030
|
+
except TypeError:
|
|
1031
|
+
sink.activity_update("subagent", detail)
|
|
1032
|
+
# Emit nested tool items only when their task parent is
|
|
1033
|
+
# known. An orphan would otherwise be appended after the
|
|
1034
|
+
# last task and falsely appear to belong to that subagent.
|
|
1035
|
+
if use_tool_items and parent_id is not None:
|
|
1036
|
+
batch_seq = sub_scope_seq.get(scope, 0) + 1
|
|
1037
|
+
sub_scope_seq[scope] = batch_seq
|
|
1038
|
+
scope_key = str(parent_id)
|
|
1039
|
+
for idx, call in enumerate(calls):
|
|
1040
|
+
call_id = _tool_call_id(call) or str(idx)
|
|
1041
|
+
item = build_tool_item(
|
|
1042
|
+
call,
|
|
1043
|
+
item_id=f"{scope_key}-sub-{batch_seq}-{call_id}",
|
|
1044
|
+
index=idx,
|
|
1045
|
+
sub=True,
|
|
1046
|
+
)
|
|
1047
|
+
item.parent_id = parent_id
|
|
1048
|
+
pending_tool_items.append(item)
|
|
1049
|
+
sink.tool_item_started(item)
|
|
1050
|
+
# Nested free-text: keep last tool intent sticky.
|
|
1051
|
+
continue
|
|
1052
|
+
|
|
1053
|
+
r_tokens = _reasoning_token_count(msg)
|
|
1054
|
+
if reasoning and not sink.streamed_reasoning:
|
|
1055
|
+
sink.write_reasoning(reasoning)
|
|
1056
|
+
sink.close_reasoning()
|
|
1057
|
+
elif reasoning and sink.streamed_reasoning:
|
|
1058
|
+
sink.close_reasoning()
|
|
1059
|
+
elif r_tokens and r_tokens > 0 and not sink.streamed_reasoning:
|
|
1060
|
+
sink.write_reasoning(
|
|
1061
|
+
f"(reasoning text not exposed by gateway; "
|
|
1062
|
+
f"~{r_tokens} reasoning tokens)\n"
|
|
1063
|
+
)
|
|
1064
|
+
sink.close_reasoning()
|
|
1065
|
+
|
|
1066
|
+
# Always surface complete AI content once per message.
|
|
1067
|
+
# Intermediate (content + tool_calls) and final answers both print.
|
|
1068
|
+
if text:
|
|
1069
|
+
sink.write_answer_complete(text, msg_id=msg_id)
|
|
1070
|
+
|
|
1071
|
+
if calls:
|
|
1072
|
+
sink.finalize_line()
|
|
1073
|
+
sink.activity_stop()
|
|
1074
|
+
names = [_tool_call_name(c) for c in calls]
|
|
1075
|
+
current_parent_task_ids.clear()
|
|
1076
|
+
for n in names:
|
|
1077
|
+
active_tools.append(n)
|
|
1078
|
+
tool_calls += 1
|
|
1079
|
+
sink.tool_calls_started(calls, parallel=len(calls) > 1)
|
|
1080
|
+
if use_tool_items:
|
|
1081
|
+
tool_group_seq += 1
|
|
1082
|
+
gid = f"g{tool_group_seq}"
|
|
1083
|
+
for idx, call in enumerate(calls):
|
|
1084
|
+
item = build_tool_item(
|
|
1085
|
+
call,
|
|
1086
|
+
item_id=f"{gid}-{idx}",
|
|
1087
|
+
index=idx,
|
|
1088
|
+
sub=in_sub,
|
|
1089
|
+
)
|
|
1090
|
+
pending_tool_items.append(item)
|
|
1091
|
+
if item.name == "task" and item.call_id:
|
|
1092
|
+
parent_task_items[item.call_id] = item.id
|
|
1093
|
+
current_parent_task_ids.add(item.call_id)
|
|
1094
|
+
sink.tool_item_started(item)
|
|
1095
|
+
if any(n == "task" for n in names):
|
|
1096
|
+
sink.activity_start(
|
|
1097
|
+
"subagent",
|
|
1098
|
+
"task (this can take a while; progress may be sparse)",
|
|
1099
|
+
)
|
|
1100
|
+
else:
|
|
1101
|
+
sink.activity_start(
|
|
1102
|
+
"tool",
|
|
1103
|
+
f"{', '.join(names[:5])}"
|
|
1104
|
+
+ ("…" if len(names) > 5 else ""),
|
|
1105
|
+
)
|
|
1106
|
+
elif text:
|
|
1107
|
+
sink.activity_update("model", "composing answer")
|
|
1108
|
+
else:
|
|
1109
|
+
sink.activity_update("model", "working")
|
|
1110
|
+
finally:
|
|
1111
|
+
clear_retry_notifier()
|
|
1112
|
+
sink.finalize_line()
|
|
1113
|
+
sink.activity_stop()
|
|
1114
|
+
# Seal any leftover open tool group (e.g. incomplete batch).
|
|
1115
|
+
finish_turn = getattr(sink, "turn_finished", None)
|
|
1116
|
+
if callable(finish_turn):
|
|
1117
|
+
finish_turn()
|
|
1118
|
+
|
|
1119
|
+
# Prefer last AI message text; answer_buf holds already-rendered answers.
|
|
1120
|
+
complete = extract_last_ai_text(final)
|
|
1121
|
+
if not complete and not cancelled:
|
|
1122
|
+
# Stream updates may only have carried middleware jumps; recover from
|
|
1123
|
+
# checkpointer state so we do not show empty/garbled answers.
|
|
1124
|
+
try:
|
|
1125
|
+
get_state = getattr(agent, "get_state", None)
|
|
1126
|
+
if callable(get_state):
|
|
1127
|
+
snap = get_state(run_config)
|
|
1128
|
+
values = getattr(snap, "values", None)
|
|
1129
|
+
if isinstance(values, dict):
|
|
1130
|
+
recovered = extract_last_ai_text(values)
|
|
1131
|
+
if recovered:
|
|
1132
|
+
complete = recovered
|
|
1133
|
+
if "messages" in values and not final.get("messages"):
|
|
1134
|
+
final["messages"] = values.get("messages")
|
|
1135
|
+
except Exception: # noqa: BLE001
|
|
1136
|
+
pass
|
|
1137
|
+
buffered = "".join(sink.answer_buf).strip()
|
|
1138
|
+
final_text = complete or buffered
|
|
1139
|
+
|
|
1140
|
+
interrupted = False
|
|
1141
|
+
if not cancelled:
|
|
1142
|
+
try:
|
|
1143
|
+
from synapse.runtime.hitl import (
|
|
1144
|
+
extract_pending_interrupt,
|
|
1145
|
+
format_interrupt_lines,
|
|
1146
|
+
has_pending_interrupt,
|
|
1147
|
+
)
|
|
1148
|
+
|
|
1149
|
+
interrupted = has_pending_interrupt(agent, run_config)
|
|
1150
|
+
if interrupted:
|
|
1151
|
+
pending = extract_pending_interrupt(agent, run_config)
|
|
1152
|
+
if pending is not None:
|
|
1153
|
+
for line in format_interrupt_lines(pending):
|
|
1154
|
+
sink.info(line)
|
|
1155
|
+
except Exception: # noqa: BLE001
|
|
1156
|
+
interrupted = False
|
|
1157
|
+
|
|
1158
|
+
result = StreamResult(
|
|
1159
|
+
state=final,
|
|
1160
|
+
final_text=final_text if not interrupted else (final_text or ""),
|
|
1161
|
+
tool_calls=tool_calls,
|
|
1162
|
+
elapsed_s=time.time() - started,
|
|
1163
|
+
streamed_answer=sink.streamed_answer,
|
|
1164
|
+
reasoning_text="".join(sink.reasoning_buf).strip(),
|
|
1165
|
+
input_tokens=input_tokens,
|
|
1166
|
+
output_tokens=output_tokens,
|
|
1167
|
+
cache_tokens=cache_tokens,
|
|
1168
|
+
total_tokens=input_tokens + output_tokens,
|
|
1169
|
+
last_input_tokens=last_input_tokens,
|
|
1170
|
+
last_output_tokens=last_output_tokens,
|
|
1171
|
+
last_cache_tokens=last_cache_tokens,
|
|
1172
|
+
cancelled=cancelled,
|
|
1173
|
+
interrupted=interrupted,
|
|
1174
|
+
compact_events=compact_events,
|
|
1175
|
+
)
|
|
1176
|
+
if cancelled:
|
|
1177
|
+
# Preserve multi-turn continuity: seal open tool_calls / pending next.
|
|
1178
|
+
try:
|
|
1179
|
+
from synapse.sessions.cancel_repair import repair_thread_after_cancel
|
|
1180
|
+
|
|
1181
|
+
repair_thread_after_cancel(agent, run_config)
|
|
1182
|
+
except Exception: # noqa: BLE001
|
|
1183
|
+
pass
|
|
1184
|
+
elif interrupted:
|
|
1185
|
+
sink.info(
|
|
1186
|
+
f"paused for approval in {result.elapsed_s:.1f}s | "
|
|
1187
|
+
f"tools={result.tool_calls} — /approve or /reject"
|
|
1188
|
+
)
|
|
1189
|
+
elif result.tool_calls or result.elapsed_s >= 0.5:
|
|
1190
|
+
token_info = ""
|
|
1191
|
+
if result.total_tokens or result.cache_tokens:
|
|
1192
|
+
token_info = (
|
|
1193
|
+
f" | tokens: {result.total_tokens} "
|
|
1194
|
+
f"(in={result.input_tokens} cache={result.cache_tokens} "
|
|
1195
|
+
f"out={result.output_tokens})"
|
|
1196
|
+
)
|
|
1197
|
+
sink.info(
|
|
1198
|
+
f"finished in {result.elapsed_s:.1f}s | tools={result.tool_calls} | "
|
|
1199
|
+
f"token_stream={'on' if token_stream else 'off'}"
|
|
1200
|
+
+ (
|
|
1201
|
+
f" | reasoning={len(result.reasoning_text)}c"
|
|
1202
|
+
if result.reasoning_text
|
|
1203
|
+
else ""
|
|
1204
|
+
)
|
|
1205
|
+
+ token_info
|
|
1206
|
+
)
|
|
1207
|
+
return result
|