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
@@ -0,0 +1,421 @@
1
+ """Pure stream-message, usage, and event normalization helpers."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+ from synapse.runtime.steer import is_steer_message
8
+ from synapse.ui.timeline import item_label
9
+
10
+
11
+ def _looks_like_middleware_update(data: Any) -> bool:
12
+ """True when an updates payload is only middleware jump metadata.
13
+
14
+ LangGraph emits maps like ``{"SkillsMiddleware.before_agent": None, ...}``
15
+ when hooks return no state patch. These must never become the answer body.
16
+ """
17
+ if not isinstance(data, dict) or not data:
18
+ return False
19
+ if "messages" in data:
20
+ return False
21
+ keys = [str(k) for k in data]
22
+ hook_markers = (".before_agent", ".after_agent", ".before_model", ".after_model")
23
+ hookish = sum(1 for k in keys if any(m in k for m in hook_markers))
24
+ if hookish >= max(1, len(keys) // 2):
25
+ return True
26
+ # All values empty/None and no known agent state channels.
27
+ state_keys = {"messages", "files", "todos", "structured_response", "jump_to"}
28
+ if any(k in state_keys for k in data):
29
+ return False
30
+ return all(v is None or v == {} or v == [] for v in data.values())
31
+
32
+
33
+ def extract_last_ai_text(result: dict[str, Any] | Any) -> str:
34
+ """Best-effort extraction of the final assistant message text.
35
+
36
+ Only reads a real ``messages`` channel. Never stringifies middleware jump
37
+ maps or other non-state updates (that used to leak into the TUI as the
38
+ assistant answer).
39
+ """
40
+ if not isinstance(result, dict) or not result:
41
+ return ""
42
+ if _looks_like_middleware_update(result):
43
+ return ""
44
+ if "messages" not in result:
45
+ return ""
46
+ messages = result.get("messages") or []
47
+ if not messages:
48
+ return ""
49
+ for msg in reversed(messages):
50
+ if not _is_ai_message(msg) or is_steer_message(msg):
51
+ continue
52
+ text = _normalize_content(getattr(msg, "content", "")).strip()
53
+ if text and not is_steer_message(text=text):
54
+ return text
55
+ return ""
56
+
57
+
58
+ def _normalize_content(content: Any) -> str:
59
+ if content is None:
60
+ return ""
61
+ if isinstance(content, str):
62
+ return content
63
+ if isinstance(content, list):
64
+ parts: list[str] = []
65
+ for block in content:
66
+ if isinstance(block, str):
67
+ parts.append(block)
68
+ elif isinstance(block, dict):
69
+ btype = str(block.get("type") or "")
70
+ if btype in {"reasoning", "thinking"}:
71
+ continue # handled separately
72
+ if btype == "text" or "text" in block:
73
+ parts.append(str(block.get("text", "")))
74
+ else:
75
+ text = getattr(block, "text", None)
76
+ if text:
77
+ parts.append(str(text))
78
+ return "".join(parts)
79
+ return str(content)
80
+
81
+
82
+ def _extract_reasoning(msg: Any) -> str:
83
+ """Extract model reasoning / thinking text from common provider fields."""
84
+ parts: list[str] = []
85
+
86
+ ak = getattr(msg, "additional_kwargs", None) or {}
87
+ if isinstance(ak, dict):
88
+ for key in ("reasoning_content", "reasoning", "thinking", "thought"):
89
+ val = ak.get(key)
90
+ if val:
91
+ parts.append(str(val))
92
+
93
+ rm = getattr(msg, "response_metadata", None) or {}
94
+ if isinstance(rm, dict):
95
+ for key in ("reasoning_content", "reasoning", "thinking"):
96
+ val = rm.get(key)
97
+ if val:
98
+ parts.append(str(val))
99
+
100
+ content = getattr(msg, "content", None)
101
+ if isinstance(content, list):
102
+ for block in content:
103
+ if not isinstance(block, dict):
104
+ continue
105
+ btype = str(block.get("type") or "")
106
+ if btype in {"reasoning", "thinking"}:
107
+ parts.append(str(block.get("text") or block.get("reasoning") or ""))
108
+
109
+ for key in ("reasoning_content", "reasoning"):
110
+ val = getattr(msg, key, None)
111
+ if val:
112
+ parts.append(str(val))
113
+
114
+ seen: set[str] = set()
115
+ out: list[str] = []
116
+ for p in parts:
117
+ if p and p not in seen:
118
+ seen.add(p)
119
+ out.append(p)
120
+ return "".join(out)
121
+
122
+
123
+ def _shorten(text: str, limit: int = 160) -> str:
124
+ text = text.replace("\n", " ").strip()
125
+ if len(text) <= limit:
126
+ return text
127
+ return text[: limit - 3] + "..."
128
+
129
+
130
+ def _format_tool_args(args: Any) -> str:
131
+ return _shorten(repr(args), 240)
132
+
133
+
134
+ @dataclass
135
+ class StreamResult:
136
+ state: dict[str, Any] = field(default_factory=dict)
137
+ final_text: str = ""
138
+ tool_calls: int = 0
139
+ elapsed_s: float = 0.0
140
+ streamed_answer: bool = False
141
+ reasoning_text: str = ""
142
+ input_tokens: int = 0
143
+ output_tokens: int = 0
144
+ cache_tokens: int = 0 # cache hit / cache_read tokens
145
+ total_tokens: int = 0
146
+ # Last model-call usage in this turn (not summed). Topbar occupancy uses these.
147
+ last_input_tokens: int = 0
148
+ last_output_tokens: int = 0
149
+ last_cache_tokens: int = 0
150
+ cancelled: bool = False # user abort (ESC / cancel_event)
151
+ interrupted: bool = False # graph paused for HITL approval
152
+ compact_events: int = 0 # context-compaction summaries hidden from UI
153
+ def _chunk_text(msg_chunk: Any) -> str:
154
+ content = getattr(msg_chunk, "content", None)
155
+ if content is None and isinstance(msg_chunk, dict):
156
+ content = msg_chunk.get("content")
157
+ return _normalize_content(content)
158
+
159
+
160
+ def _is_tool_message(msg: Any) -> bool:
161
+ """Detect tool result messages.
162
+
163
+ LangChain ToolMessage.type is the short string ``\"tool\"`` (not ``toolmessage``).
164
+ """
165
+ type_name = (getattr(msg, "type", None) or "").lower()
166
+ if type_name == "tool":
167
+ return True
168
+ cls_name = msg.__class__.__name__.lower()
169
+ return cls_name == "toolmessage" or (
170
+ "tool" in cls_name and "message" in cls_name
171
+ )
172
+
173
+
174
+ def _is_ai_message(msg: Any) -> bool:
175
+ if isinstance(msg, dict):
176
+ role = str(msg.get("role") or msg.get("type") or "").lower()
177
+ return role in {"ai", "assistant", "aimessage", "aimessagechunk"}
178
+ type_name = (getattr(msg, "type", None) or "").lower()
179
+ if type_name in {"ai", "assistant", "aimessage", "aimessagechunk"}:
180
+ return True
181
+ cls_name = msg.__class__.__name__.lower().lstrip("_")
182
+ return cls_name in {"ai", "aimessage", "aimessagechunk"}
183
+
184
+
185
+ def _reasoning_token_count(msg: Any) -> int | None:
186
+ usage = getattr(msg, "usage_metadata", None) or {}
187
+ if not isinstance(usage, dict):
188
+ details = getattr(usage, "output_token_details", None)
189
+ if details is not None:
190
+ val = getattr(details, "reasoning", None)
191
+ return int(val) if val is not None else None
192
+ return None
193
+ details = usage.get("output_token_details") or {}
194
+ if isinstance(details, dict) and details.get("reasoning") is not None:
195
+ try:
196
+ return int(details["reasoning"])
197
+ except (TypeError, ValueError):
198
+ return None
199
+ return None
200
+
201
+
202
+ def _as_int(value: Any) -> int:
203
+ try:
204
+ return int(value or 0)
205
+ except (TypeError, ValueError):
206
+ return 0
207
+
208
+
209
+ def _cache_tokens_from_details(details: Any) -> int:
210
+ """Best-effort cache-hit tokens from provider detail objects/dicts."""
211
+ if details is None:
212
+ return 0
213
+ keys = (
214
+ "cache_read",
215
+ "cache_read_tokens",
216
+ "cache_hit",
217
+ "cache_hit_tokens",
218
+ "cached",
219
+ "cached_tokens",
220
+ )
221
+ if isinstance(details, dict):
222
+ for key in keys:
223
+ if details.get(key) is not None:
224
+ return _as_int(details.get(key))
225
+ return 0
226
+ for key in keys:
227
+ val = getattr(details, key, None)
228
+ if val is not None:
229
+ return _as_int(val)
230
+ return 0
231
+
232
+
233
+ def _extract_cache_tokens(msg: Any, usage: Any) -> int:
234
+ """Extract cache-hit tokens from usage_metadata / response_metadata."""
235
+ if usage is not None:
236
+ if isinstance(usage, dict):
237
+ cache = _cache_tokens_from_details(usage.get("input_token_details"))
238
+ if cache:
239
+ return cache
240
+ cache = _cache_tokens_from_details(usage.get("input_tokens_details"))
241
+ if cache:
242
+ return cache
243
+ for key in ("cache_read_tokens", "cached_tokens", "cache_tokens"):
244
+ if usage.get(key) is not None:
245
+ return _as_int(usage.get(key))
246
+ else:
247
+ cache = _cache_tokens_from_details(
248
+ getattr(usage, "input_token_details", None)
249
+ )
250
+ if cache:
251
+ return cache
252
+ for key in ("cache_read_tokens", "cached_tokens", "cache_tokens"):
253
+ val = getattr(usage, key, None)
254
+ if val is not None:
255
+ return _as_int(val)
256
+
257
+ meta = getattr(msg, "response_metadata", None) or {}
258
+ if not isinstance(meta, dict):
259
+ return 0
260
+ token_usage = meta.get("token_usage") or meta.get("usage") or {}
261
+ if not isinstance(token_usage, dict):
262
+ return 0
263
+ details = token_usage.get("prompt_tokens_details") or token_usage.get(
264
+ "input_tokens_details"
265
+ )
266
+ cache = _cache_tokens_from_details(details)
267
+ if cache:
268
+ return cache
269
+ for key in ("cache_read_tokens", "cached_tokens", "cache_tokens"):
270
+ if token_usage.get(key) is not None:
271
+ return _as_int(token_usage.get(key))
272
+ return 0
273
+
274
+
275
+ def _extract_usage(msg: Any) -> dict[str, int]:
276
+ """Extract token usage from AIMessage usage_metadata (OpenAI-compatible format)."""
277
+ empty: dict[str, int] = {
278
+ "input_tokens": 0,
279
+ "output_tokens": 0,
280
+ "total_tokens": 0,
281
+ "cache_tokens": 0,
282
+ }
283
+
284
+ usage = getattr(msg, "usage_metadata", None)
285
+ if usage is None:
286
+ cache = _extract_cache_tokens(msg, None)
287
+ if cache:
288
+ empty["cache_tokens"] = cache
289
+ return empty
290
+
291
+ if not isinstance(usage, dict):
292
+ return {
293
+ "input_tokens": _as_int(getattr(usage, "input_tokens", 0)),
294
+ "output_tokens": _as_int(getattr(usage, "output_tokens", 0)),
295
+ "total_tokens": _as_int(getattr(usage, "total_tokens", 0)),
296
+ "cache_tokens": _extract_cache_tokens(msg, usage),
297
+ }
298
+
299
+ return {
300
+ "input_tokens": _as_int(usage.get("input_tokens", 0)),
301
+ "output_tokens": _as_int(usage.get("output_tokens", 0)),
302
+ "total_tokens": _as_int(usage.get("total_tokens", 0)),
303
+ "cache_tokens": _extract_cache_tokens(msg, usage),
304
+ }
305
+
306
+
307
+
308
+ def aggregate_usage_from_messages(messages: list[Any] | None) -> dict[str, int]:
309
+ """Sum usage_metadata across AI messages; track last call values.
310
+
311
+ Used when restoring a thread so the topbar can show historical totals
312
+ without waiting for a new live turn.
313
+ """
314
+ total_in = 0
315
+ total_out = 0
316
+ total_cache = 0
317
+ last_in = 0
318
+ last_out = 0
319
+ last_cache = 0
320
+ seen: set[str] = set()
321
+ for msg in messages or []:
322
+ if not _is_ai_message(msg):
323
+ continue
324
+ msg_id = getattr(msg, "id", None)
325
+ key = f"usage:{msg_id if msg_id else id(msg)}"
326
+ if key in seen:
327
+ continue
328
+ u = _extract_usage(msg)
329
+ if not (
330
+ u.get("input_tokens")
331
+ or u.get("output_tokens")
332
+ or u.get("cache_tokens")
333
+ ):
334
+ continue
335
+ seen.add(key)
336
+ total_in += int(u.get("input_tokens") or 0)
337
+ total_out += int(u.get("output_tokens") or 0)
338
+ total_cache += int(u.get("cache_tokens") or 0)
339
+ last_in = int(u.get("input_tokens") or 0)
340
+ last_out = int(u.get("output_tokens") or 0)
341
+ last_cache = int(u.get("cache_tokens") or 0)
342
+ return {
343
+ "input_tokens": total_in,
344
+ "output_tokens": total_out,
345
+ "cache_tokens": total_cache,
346
+ "last_input_tokens": last_in,
347
+ "last_output_tokens": last_out,
348
+ "last_cache_tokens": last_cache,
349
+ }
350
+
351
+
352
+
353
+ def _tool_call_name(call: Any) -> str:
354
+ if isinstance(call, dict):
355
+ return str(call.get("name") or "?")
356
+ return str(getattr(call, "name", "?"))
357
+
358
+
359
+ def _tool_call_args(call: Any) -> Any:
360
+ if isinstance(call, dict):
361
+ return call.get("args")
362
+ return getattr(call, "args", {})
363
+
364
+
365
+ def _tool_call_id(call: Any) -> str:
366
+ if isinstance(call, dict):
367
+ return str(call.get("id") or call.get("tool_call_id") or "")
368
+ return str(getattr(call, "id", None) or getattr(call, "tool_call_id", None) or "")
369
+
370
+
371
+ def human_tool_label(call: Any) -> str:
372
+ """Prefer model intent (via item_label) over raw tool name/args."""
373
+ name = _tool_call_name(call)
374
+ args = _tool_call_args(call)
375
+ label = item_label(name, args)
376
+ return " ".join(str(label or name).split()).strip() or name
377
+
378
+
379
+ def human_nested_tools_detail(calls: list[Any], *, limit: int = 5) -> str:
380
+ """Status text for concurrent nested tool calls."""
381
+ labels: list[str] = []
382
+ for call in calls[: max(1, limit)]:
383
+ labels.append(human_tool_label(call))
384
+ more = len(calls) - len(labels)
385
+ text = " · ".join(labels)
386
+ if more > 0:
387
+ text = f"{text} · +{more}"
388
+ return text
389
+ def _normalize_stream_item(item: Any) -> tuple[str, Any, tuple[str, ...]]:
390
+ ns: tuple[str, ...] = ()
391
+
392
+ if isinstance(item, dict) and "type" in item and "data" in item:
393
+ mode = str(item.get("type") or "updates")
394
+ data = item.get("data")
395
+ raw_ns = item.get("ns") or item.get("namespace") or ()
396
+ if raw_ns:
397
+ ns = tuple(str(x) for x in raw_ns)
398
+ return mode, data, ns
399
+
400
+ if isinstance(item, tuple):
401
+ if len(item) == 3:
402
+ maybe_ns, mode, data = item
403
+ if isinstance(maybe_ns, (tuple, list)):
404
+ return str(mode), data, tuple(str(x) for x in maybe_ns)
405
+ return str(maybe_ns), mode, ()
406
+ if len(item) == 2:
407
+ a, b = item
408
+ if isinstance(a, str) and a in {
409
+ "messages",
410
+ "updates",
411
+ "values",
412
+ "custom",
413
+ "events",
414
+ "debug",
415
+ }:
416
+ return a, b, ()
417
+ if isinstance(a, (tuple, list)):
418
+ return "updates", b, tuple(str(x) for x in a)
419
+ return str(a), b, ()
420
+
421
+ return "updates", item, ()
@@ -0,0 +1,252 @@
1
+ """LangGraph stream iteration and checkpointer compatibility runtime."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import queue
6
+ import threading
7
+ from collections.abc import Iterator
8
+ from typing import Any
9
+
10
+ from synapse.ui.stream_events import _normalize_stream_item
11
+
12
+
13
+ def checkpointer_supports_async(checkpointer: Any) -> bool:
14
+ """Whether a LangGraph checkpointer is safe for agent.astream.
15
+
16
+ Sync ``SqliteSaver`` raises RuntimeError under async graph methods.
17
+ """
18
+ if checkpointer is None:
19
+ return True
20
+ cls = type(checkpointer)
21
+ name = cls.__name__
22
+ module = cls.__module__ or ""
23
+ if name == "SqliteSaver" and ".aio" not in module:
24
+ return False
25
+ if name.startswith("Async") and "Saver" in name:
26
+ return True
27
+ # MemorySaver and most modern savers expose aget_tuple.
28
+ if callable(getattr(checkpointer, "aget_tuple", None)):
29
+ return True
30
+ if callable(getattr(checkpointer, "aget", None)):
31
+ return True
32
+ return True
33
+
34
+
35
+ def _bound_async_loop(agent: Any) -> asyncio.AbstractEventLoop | None:
36
+ """Event loop bound to AsyncSqliteSaver / agent async runtime, if any."""
37
+ runtime = getattr(agent, "_coding_async_runtime", None)
38
+ if runtime is not None:
39
+ loop = getattr(runtime, "loop", None)
40
+ if loop is not None:
41
+ try:
42
+ if loop.is_running():
43
+ return loop
44
+ except Exception: # noqa: BLE001
45
+ pass
46
+ cp = getattr(agent, "_coding_checkpointer", None)
47
+ loop = getattr(cp, "loop", None) if cp is not None else None
48
+ if loop is not None:
49
+ try:
50
+ if loop.is_running():
51
+ return loop
52
+ except Exception: # noqa: BLE001
53
+ pass
54
+ return None
55
+
56
+
57
+ def _is_sync_only_checkpointer_error(exc: BaseException) -> bool:
58
+ """True for SqliteSaver/async mismatch errors that should fall back to sync stream."""
59
+ msg = str(exc).lower()
60
+ if "does not support async" in msg:
61
+ return True
62
+ if "asyncsqlitesaver" in msg and "aiosqlite" in msg:
63
+ return True
64
+ if "sqlitesaver" in msg and "async" in msg:
65
+ return True
66
+ return False
67
+
68
+
69
+
70
+
71
+ def _iter_stream_events(
72
+ agent,
73
+ payload: Any,
74
+ config: dict[str, Any],
75
+ *,
76
+ token_stream: bool,
77
+ prefer_async: bool,
78
+ subgraphs: bool,
79
+ cancel_event: threading.Event | None = None,
80
+ ) -> Iterator[tuple[str, Any, tuple[str, ...]]]:
81
+ modes: list[str] = ["updates"]
82
+ if token_stream:
83
+ modes = ["messages", "updates"]
84
+
85
+ def _put_norm(q: queue.Queue[Any], item: Any) -> None:
86
+ q.put(_normalize_stream_item(item))
87
+
88
+ def _cancelled() -> bool:
89
+ return cancel_event is not None and cancel_event.is_set()
90
+
91
+ if prefer_async and hasattr(agent, "astream"):
92
+ q: queue.Queue[Any] = queue.Queue()
93
+ error_box: list[BaseException] = []
94
+ done_box: list[bool] = []
95
+
96
+ async def _astream_once(**kwargs: Any):
97
+ async for item in agent.astream(payload, config=config, **kwargs):
98
+ if _cancelled():
99
+ break
100
+ _put_norm(q, item)
101
+
102
+ async def _produce() -> None:
103
+ kwargs: dict[str, Any] = {
104
+ "stream_mode": modes,
105
+ "subgraphs": subgraphs,
106
+ }
107
+ try:
108
+ await _astream_once(version="v2", **kwargs)
109
+ except TypeError:
110
+ try:
111
+ await _astream_once(**kwargs)
112
+ except TypeError:
113
+ await _astream_once(stream_mode=modes)
114
+ except asyncio.CancelledError:
115
+ return
116
+ except BaseException as exc: # noqa: BLE001
117
+ error_box.append(exc)
118
+ finally:
119
+ q.put(None)
120
+
121
+ async def _main() -> None:
122
+ prod = asyncio.create_task(_produce())
123
+ if cancel_event is None:
124
+ await prod
125
+ return
126
+ # Poll cancel so ESC can interrupt long model/tool waits.
127
+ while not prod.done():
128
+ if cancel_event.is_set():
129
+ prod.cancel()
130
+ try:
131
+ await prod
132
+ except (asyncio.CancelledError, Exception): # noqa: BLE001
133
+ pass
134
+ # Ensure consumer unblocks even if finally was skipped.
135
+ try:
136
+ q.put_nowait(None)
137
+ except Exception: # noqa: BLE001
138
+ q.put(None)
139
+ return
140
+ await asyncio.sleep(0.05)
141
+ await prod
142
+
143
+ bound_loop = _bound_async_loop(agent)
144
+ worker_thread: threading.Thread | None = None
145
+ bound_future: Any | None = None
146
+
147
+ if bound_loop is not None and bound_loop.is_running():
148
+ # AsyncSqliteSaver path: schedule on the checkpointer's loop.
149
+ try:
150
+ bound_future = asyncio.run_coroutine_threadsafe(_main(), bound_loop)
151
+ except BaseException as exc: # noqa: BLE001
152
+ error_box.append(exc)
153
+ q.put(None)
154
+ else:
155
+ # MemorySaver / no bound loop: dedicated worker + asyncio.run.
156
+ def _runner() -> None:
157
+ try:
158
+ asyncio.run(_main())
159
+ except BaseException as exc: # noqa: BLE001
160
+ error_box.append(exc)
161
+ try:
162
+ q.put_nowait(None)
163
+ except Exception: # noqa: BLE001
164
+ q.put(None)
165
+ finally:
166
+ done_box.append(True)
167
+
168
+ worker_thread = threading.Thread(
169
+ target=_runner, name="agent-astream", daemon=True
170
+ )
171
+ worker_thread.start()
172
+
173
+ while True:
174
+ if _cancelled():
175
+ # Unblock promptly; producer task is being cancelled in parallel.
176
+ try:
177
+ item = q.get(timeout=0.15)
178
+ except queue.Empty:
179
+ yield "__cancelled__", None, ()
180
+ break
181
+ if item is None:
182
+ yield "__cancelled__", None, ()
183
+ break
184
+ yield item
185
+ continue
186
+ try:
187
+ item = q.get(timeout=0.2)
188
+ except queue.Empty:
189
+ # If bound future finished without sentinel, stop.
190
+ if bound_future is not None and bound_future.done() and q.empty():
191
+ break
192
+ yield "__heartbeat__", None, ()
193
+ continue
194
+ if item is None:
195
+ if _cancelled():
196
+ yield "__cancelled__", None, ()
197
+ break
198
+ yield item
199
+
200
+ if worker_thread is not None:
201
+ worker_thread.join(timeout=1.5)
202
+ if bound_future is not None:
203
+ try:
204
+ bound_future.result(timeout=1.5)
205
+ except Exception as exc: # noqa: BLE001
206
+ if not error_box and not _cancelled():
207
+ error_box.append(exc)
208
+ if error_box:
209
+ err = error_box[0]
210
+ # Cancellation-induced errors are expected; ignore soft failures.
211
+ if _cancelled() or isinstance(err, asyncio.CancelledError):
212
+ return
213
+ # Fall through to sync stream when:
214
+ # - TypeError: astream kwargs (version/subgraphs) not supported
215
+ # - sync-only checkpointer used under astream (SqliteSaver)
216
+ # Other runtime/API failures must still surface.
217
+ if isinstance(err, TypeError) or _is_sync_only_checkpointer_error(err):
218
+ if bool(getattr(agent, "_coding_async_only", False)):
219
+ raise err
220
+ else:
221
+ raise err
222
+ else:
223
+ return
224
+
225
+ def _sync_iter(**kwargs: Any):
226
+ return agent.stream(payload, config=config, **kwargs)
227
+
228
+ sync_errors: list[BaseException] = []
229
+ for attempt in (
230
+ {"stream_mode": modes, "subgraphs": subgraphs, "version": "v2"},
231
+ {"stream_mode": modes, "subgraphs": subgraphs},
232
+ {"stream_mode": modes, "version": "v2"},
233
+ {"stream_mode": modes},
234
+ {"stream_mode": "updates"},
235
+ ):
236
+ try:
237
+ for item in _sync_iter(**attempt):
238
+ if _cancelled():
239
+ yield "__cancelled__", None, ()
240
+ return
241
+ yield _normalize_stream_item(item)
242
+ return
243
+ except TypeError as exc:
244
+ sync_errors.append(exc)
245
+ continue
246
+ except asyncio.CancelledError:
247
+ if _cancelled():
248
+ yield "__cancelled__", None, ()
249
+ return
250
+ raise
251
+ if sync_errors:
252
+ raise sync_errors[-1]