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.
Files changed (131) hide show
  1. synapse/__init__.py +13 -0
  2. synapse/__main__.py +6 -0
  3. synapse/app/__init__.py +1 -0
  4. synapse/app/agent.py +492 -0
  5. synapse/app/agent_md.py +107 -0
  6. synapse/cli.py +750 -0
  7. synapse/commands/__init__.py +1 -0
  8. synapse/commands/compression.py +573 -0
  9. synapse/commands/helpers.py +22 -0
  10. synapse/commands/mcp.py +406 -0
  11. synapse/commands/model.py +173 -0
  12. synapse/commands/result.py +34 -0
  13. synapse/commands/sessions.py +443 -0
  14. synapse/commands/slash_cmds.py +521 -0
  15. synapse/commands/slash_complete.py +816 -0
  16. synapse/commands/theme.py +99 -0
  17. synapse/config.py +27 -0
  18. synapse/content/__init__.py +1 -0
  19. synapse/content/input_history.py +122 -0
  20. synapse/content/multimodal.py +733 -0
  21. synapse/content/prompts.py +249 -0
  22. synapse/content/skills_catalog.py +128 -0
  23. synapse/integrations/__init__.py +1 -0
  24. synapse/integrations/checkpoint_seed.py +281 -0
  25. synapse/integrations/codex_history.py +375 -0
  26. synapse/integrations/codex_import.py +393 -0
  27. synapse/integrations/codex_sessions.py +629 -0
  28. synapse/integrations/describe_image.py +370 -0
  29. synapse/integrations/http_clients.py +199 -0
  30. synapse/integrations/llm_openai_compat.py +90 -0
  31. synapse/integrations/llm_openai_websocket.py +187 -0
  32. synapse/integrations/mcp_client.py +646 -0
  33. synapse/integrations/vision_middleware.py +62 -0
  34. synapse/models/__init__.py +5 -0
  35. synapse/models/config.py +240 -0
  36. synapse/models/helpers.py +206 -0
  37. synapse/models/profile.py +59 -0
  38. synapse/models/registry.py +722 -0
  39. synapse/models_registry.py +7 -0
  40. synapse/observability/__init__.py +1 -0
  41. synapse/observability/startup_trace.py +127 -0
  42. synapse/runtime/__init__.py +1 -0
  43. synapse/runtime/async_runtime.py +176 -0
  44. synapse/runtime/backends.py +458 -0
  45. synapse/runtime/context_compact.py +249 -0
  46. synapse/runtime/execute_capture.py +48 -0
  47. synapse/runtime/fs_permissions.py +79 -0
  48. synapse/runtime/harness.py +57 -0
  49. synapse/runtime/hitl.py +197 -0
  50. synapse/runtime/interaction_ledger.py +82 -0
  51. synapse/runtime/middleware.py +802 -0
  52. synapse/runtime/model_request_compression_middleware.py +745 -0
  53. synapse/runtime/pathing.py +146 -0
  54. synapse/runtime/safety.py +184 -0
  55. synapse/runtime/steer.py +240 -0
  56. synapse/runtime/subagents.py +207 -0
  57. synapse/runtime/tool_ignore.py +221 -0
  58. synapse/runtime/tool_output_eval.py +118 -0
  59. synapse/runtime/tool_output_middleware.py +585 -0
  60. synapse/runtime/tool_output_usage_middleware.py +60 -0
  61. synapse/sessions/__init__.py +31 -0
  62. synapse/sessions/cancel_repair.py +208 -0
  63. synapse/sessions/session_recap.py +174 -0
  64. synapse/sessions/store.py +695 -0
  65. synapse/sessions/transcript.py +754 -0
  66. synapse/settings/__init__.py +5 -0
  67. synapse/settings/config_paths.py +184 -0
  68. synapse/settings/schema.py +464 -0
  69. synapse/tool_output/__init__.py +59 -0
  70. synapse/tool_output/detection.py +170 -0
  71. synapse/tool_output/metrics.py +32 -0
  72. synapse/tool_output/models.py +173 -0
  73. synapse/tool_output/pipeline.py +330 -0
  74. synapse/tool_output/repository.py +721 -0
  75. synapse/tool_output/transformers.py +648 -0
  76. synapse/tools/__init__.py +5 -0
  77. synapse/tools/session_tools.py +204 -0
  78. synapse/ui/__init__.py +10 -0
  79. synapse/ui/bottombar/__init__.py +73 -0
  80. synapse/ui/bottombar/components/__init__.py +143 -0
  81. synapse/ui/bottombar/components/key_hints.py +30 -0
  82. synapse/ui/bottombar/components/mcp.py +64 -0
  83. synapse/ui/bottombar/components/mode.py +24 -0
  84. synapse/ui/bottombar/components/model.py +28 -0
  85. synapse/ui/bottombar/components/thread.py +29 -0
  86. synapse/ui/bottombar/context.py +36 -0
  87. synapse/ui/bottombar/core.py +74 -0
  88. synapse/ui/dialogs/__init__.py +25 -0
  89. synapse/ui/dialogs/base.py +362 -0
  90. synapse/ui/dialogs/codex_session_list.py +84 -0
  91. synapse/ui/dialogs/compression_diagnostics.py +210 -0
  92. synapse/ui/dialogs/git_explore.py +702 -0
  93. synapse/ui/dialogs/mcp_panel.py +407 -0
  94. synapse/ui/dialogs/model_picker.py +128 -0
  95. synapse/ui/dialogs/safety_panel.py +63 -0
  96. synapse/ui/dialogs/session_list.py +98 -0
  97. synapse/ui/dialogs/theme_designer.py +863 -0
  98. synapse/ui/dialogs/theme_picker.py +113 -0
  99. synapse/ui/git_explore/__init__.py +31 -0
  100. synapse/ui/git_explore/engine.py +82 -0
  101. synapse/ui/git_explore/provider.py +242 -0
  102. synapse/ui/git_explore/unified.py +85 -0
  103. synapse/ui/rendering.py +350 -0
  104. synapse/ui/sink.py +70 -0
  105. synapse/ui/steer_widget.py +367 -0
  106. synapse/ui/stream.py +1207 -0
  107. synapse/ui/stream_events.py +421 -0
  108. synapse/ui/stream_runtime.py +252 -0
  109. synapse/ui/theme.py +1154 -0
  110. synapse/ui/timeline.py +621 -0
  111. synapse/ui/topbar/__init__.py +97 -0
  112. synapse/ui/topbar/components/__init__.py +150 -0
  113. synapse/ui/topbar/components/branch.py +41 -0
  114. synapse/ui/topbar/components/title.py +24 -0
  115. synapse/ui/topbar/components/tool_output.py +24 -0
  116. synapse/ui/topbar/components/usage.py +24 -0
  117. synapse/ui/topbar/components/workspace.py +32 -0
  118. synapse/ui/topbar/context.py +32 -0
  119. synapse/ui/topbar/core.py +979 -0
  120. synapse/ui/topbar/git_changes_popover.py +178 -0
  121. synapse/ui/topbar/git_chrome.py +475 -0
  122. synapse/ui/topbar/tool_output_popover.py +84 -0
  123. synapse/ui/topbar/widget.py +474 -0
  124. synapse/ui/tui.py +5717 -0
  125. synapse/ui/turn_rail.py +71 -0
  126. synapse/ui/user_turn.py +83 -0
  127. synapse/ui/welcome.py +261 -0
  128. synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
  129. synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
  130. synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
  131. 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