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
|
@@ -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]
|