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,754 @@
1
+ """Load conversation transcript from LangGraph checkpointer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sqlite3
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from synapse.runtime.context_compact import (
12
+ is_context_compact_text,
13
+ is_lc_summarization_message,
14
+ )
15
+
16
+
17
+ def _message_role(msg: Any) -> str:
18
+ t = getattr(msg, "type", None) or getattr(msg, "role", None)
19
+ if t:
20
+ return str(t).lower()
21
+ if isinstance(msg, dict):
22
+ return str(msg.get("type") or msg.get("role") or "unknown").lower()
23
+ cls = msg.__class__.__name__.lower()
24
+ if "human" in cls:
25
+ return "human"
26
+ if "ai" in cls or "assistant" in cls:
27
+ return "ai"
28
+ if "system" in cls:
29
+ return "system"
30
+ if "tool" in cls:
31
+ return "tool"
32
+ return "unknown"
33
+
34
+
35
+ _NON_TEXT_BLOCK_TYPES = frozenset(
36
+ {
37
+ "tool_use",
38
+ "tool_call",
39
+ "tool_result",
40
+ "input_json",
41
+ "input_json_delta",
42
+ "function_call",
43
+ "server_tool_use",
44
+ "mcp_tool_use",
45
+ "mcp_tool_result",
46
+ "image",
47
+ "image_url",
48
+ "file",
49
+ "document",
50
+ "reasoning",
51
+ "thinking",
52
+ "redacted_thinking",
53
+ }
54
+ )
55
+
56
+
57
+ def _looks_like_tool_payload(text: str) -> bool:
58
+ """Heuristic: string is a serialized tool_use / tool_calls blob."""
59
+ one = (text or "").strip()
60
+ if not one:
61
+ return False
62
+ if '"type": "tool_use"' in one or '"type":"tool_use"' in one:
63
+ return True
64
+ if '"partial_json"' in one and '"name"' in one:
65
+ return True
66
+ if one.startswith("{") and '"tool_use"' in one and '"input"' in one:
67
+ return True
68
+ if one.startswith("{") and '"todos"' in one and '"status"' in one and len(one) > 80:
69
+ try:
70
+ data = json.loads(one)
71
+ except Exception: # noqa: BLE001
72
+ return False
73
+ return isinstance(data, dict) and "todos" in data
74
+ return False
75
+
76
+
77
+ def _message_content(msg: Any) -> str:
78
+ """Extract human-visible text only (never dump tool_use / JSON blocks)."""
79
+ content = getattr(msg, "content", None)
80
+ if content is None and isinstance(msg, dict):
81
+ content = msg.get("content")
82
+ if content is None:
83
+ return ""
84
+ if isinstance(content, str):
85
+ if _looks_like_tool_payload(content):
86
+ return ""
87
+ return content
88
+ if isinstance(content, list):
89
+ parts: list[str] = []
90
+ for block in content:
91
+ if isinstance(block, str):
92
+ if not _looks_like_tool_payload(block):
93
+ parts.append(block)
94
+ continue
95
+ if isinstance(block, dict):
96
+ btype = str(block.get("type") or "").casefold()
97
+ if btype in _NON_TEXT_BLOCK_TYPES:
98
+ continue
99
+ if block.get("name") and (
100
+ "input" in block or "partial_json" in block or btype == "tool_use"
101
+ ):
102
+ continue
103
+ if btype in {"text", "output_text", "input_text"} or "text" in block:
104
+ text = block.get("text")
105
+ if text:
106
+ parts.append(str(text))
107
+ continue
108
+ # Unknown dict: never json-dump (restore leak source).
109
+ continue
110
+ text = getattr(block, "text", None)
111
+ btype = str(getattr(block, "type", "") or "").casefold()
112
+ if btype in _NON_TEXT_BLOCK_TYPES:
113
+ continue
114
+ if text:
115
+ parts.append(str(text))
116
+ return "\n".join(parts)
117
+ return str(content)
118
+
119
+
120
+ def _message_reasoning(msg: Any) -> str:
121
+ """Best-effort reasoning/thinking extraction for replay."""
122
+ parts: list[str] = []
123
+ for src in (
124
+ getattr(msg, "additional_kwargs", None),
125
+ getattr(msg, "response_metadata", None),
126
+ ):
127
+ if not isinstance(src, dict):
128
+ continue
129
+ for key in ("reasoning_content", "reasoning", "thinking", "thought"):
130
+ val = src.get(key)
131
+ if val:
132
+ parts.append(str(val))
133
+ content = getattr(msg, "content", None)
134
+ if content is None and isinstance(msg, dict):
135
+ content = msg.get("content")
136
+ if isinstance(content, list):
137
+ for block in content:
138
+ if not isinstance(block, dict):
139
+ continue
140
+ btype = str(block.get("type") or "")
141
+ if btype in {"reasoning", "thinking"}:
142
+ parts.append(str(block.get("text") or block.get("reasoning") or ""))
143
+ for key in ("reasoning_content", "reasoning"):
144
+ val = getattr(msg, key, None)
145
+ if val:
146
+ parts.append(str(val))
147
+ # de-dupe preserve order
148
+ seen: set[str] = set()
149
+ out: list[str] = []
150
+ for p in parts:
151
+ if p and p not in seen:
152
+ seen.add(p)
153
+ out.append(p)
154
+ return "".join(out)
155
+
156
+
157
+ def _parse_tool_args(raw_args: Any) -> dict[str, Any]:
158
+ if raw_args is None:
159
+ return {}
160
+ if isinstance(raw_args, dict):
161
+ return raw_args
162
+ if isinstance(raw_args, str):
163
+ text = raw_args.strip()
164
+ if not text:
165
+ return {}
166
+ try:
167
+ data = json.loads(text)
168
+ return data if isinstance(data, dict) else {"value": data}
169
+ except Exception: # noqa: BLE001
170
+ return {"arguments": raw_args}
171
+ return {"value": raw_args}
172
+
173
+
174
+ def _tool_calls_from_content_blocks(content: Any) -> list[dict[str, Any]]:
175
+ """Anthropic-style tool_use blocks live inside message.content list."""
176
+ if not isinstance(content, list):
177
+ return []
178
+ out: list[dict[str, Any]] = []
179
+ for i, block in enumerate(content):
180
+ if isinstance(block, dict):
181
+ btype = str(block.get("type") or "").casefold()
182
+ name = block.get("name")
183
+ if btype not in {"tool_use", "tool_call", "function_call", "server_tool_use"}:
184
+ # Some serializers omit type but keep tool shape.
185
+ if not (name and ("input" in block or "partial_json" in block)):
186
+ continue
187
+ cid = str(block.get("id") or block.get("tool_use_id") or f"block-{i}")
188
+ tname = str(name or block.get("function", {}).get("name") or "?")
189
+ args = block.get("input")
190
+ if args is None:
191
+ args = block.get("args")
192
+ if args is None and block.get("partial_json"):
193
+ args = _parse_tool_args(block.get("partial_json"))
194
+ else:
195
+ args = _parse_tool_args(args)
196
+ out.append({"id": cid, "name": tname, "args": args})
197
+ continue
198
+ btype = str(getattr(block, "type", "") or "").casefold()
199
+ if btype not in {"tool_use", "tool_call", "function_call"}:
200
+ continue
201
+ cid = str(getattr(block, "id", None) or f"block-{i}")
202
+ tname = str(getattr(block, "name", None) or "?")
203
+ args = getattr(block, "input", None)
204
+ if args is None:
205
+ args = getattr(block, "args", None)
206
+ out.append({"id": cid, "name": tname, "args": _parse_tool_args(args)})
207
+ return out
208
+
209
+
210
+ def _tool_calls(msg: Any) -> list[dict[str, Any]]:
211
+ raw = getattr(msg, "tool_calls", None)
212
+ if raw is None and isinstance(msg, dict):
213
+ raw = msg.get("tool_calls")
214
+ if not raw:
215
+ # OpenAI-style additional_kwargs
216
+ ak = getattr(msg, "additional_kwargs", None) or {}
217
+ if isinstance(ak, dict):
218
+ raw = ak.get("tool_calls") or []
219
+ out: list[dict[str, Any]] = []
220
+ for i, call in enumerate(raw or []):
221
+ if isinstance(call, dict):
222
+ cid = str(call.get("id") or call.get("tool_call_id") or f"call-{i}")
223
+ name = str(call.get("name") or call.get("function", {}).get("name") or "?")
224
+ args = call.get("args")
225
+ if args is None:
226
+ args = call.get("input")
227
+ if args is None:
228
+ fn = call.get("function") or {}
229
+ args = fn.get("arguments") if isinstance(fn, dict) else None
230
+ out.append({"id": cid, "name": name, "args": _parse_tool_args(args)})
231
+ continue
232
+ cid = str(getattr(call, "id", None) or f"call-{i}")
233
+ name = str(getattr(call, "name", None) or "?")
234
+ args = getattr(call, "args", None)
235
+ if args is None:
236
+ args = getattr(call, "input", None)
237
+ out.append({"id": cid, "name": name, "args": _parse_tool_args(args)})
238
+
239
+ # Merge Anthropic content-block tool_use (avoid duplicates by id).
240
+ content = getattr(msg, "content", None)
241
+ if content is None and isinstance(msg, dict):
242
+ content = msg.get("content")
243
+ seen = {str(c.get("id") or "") for c in out}
244
+ for call in _tool_calls_from_content_blocks(content):
245
+ cid = str(call.get("id") or "")
246
+ if cid and cid in seen:
247
+ continue
248
+ out.append(call)
249
+ if cid:
250
+ seen.add(cid)
251
+ return out
252
+
253
+
254
+ def message_to_export_dict(msg: Any) -> dict[str, Any]:
255
+ if isinstance(msg, dict):
256
+ role = str(msg.get("type") or msg.get("role") or "unknown")
257
+ content = msg.get("content", "")
258
+ if not isinstance(content, str):
259
+ content = _message_content(msg)
260
+ return {"role": role, "content": content}
261
+ return {
262
+ "role": _message_role(msg),
263
+ "content": _message_content(msg),
264
+ "id": getattr(msg, "id", None),
265
+ "name": getattr(msg, "name", None),
266
+ }
267
+
268
+
269
+ def load_messages_from_checkpointer(
270
+ checkpointer: Any,
271
+ thread_id: str,
272
+ *,
273
+ max_parents: int = 50,
274
+ ) -> list[Any]:
275
+ """从 LangGraph checkpointer 加载 thread 的消息。
276
+
277
+ 先从最新 checkpoint 的 channel_values 中取 messages。
278
+ 若不存在(上下文压缩后),沿 parent_config 链向上回退,
279
+ 找到第一个包含 messages 的 checkpoint 后返回。
280
+
281
+ Args:
282
+ checkpointer: LangGraph checkpointer 实例
283
+ thread_id: 会话 ID
284
+ max_parents: 最多回退的父 checkpoint 数,防止无限循环
285
+ """
286
+ if checkpointer is None or not thread_id:
287
+ return []
288
+ get_tuple = getattr(checkpointer, "get_tuple", None)
289
+ if not callable(get_tuple):
290
+ return []
291
+
292
+ config: dict[str, Any] = {"configurable": {"thread_id": thread_id}}
293
+ for _ in range(max(1, max_parents + 1)):
294
+ try:
295
+ tup = get_tuple(config)
296
+ except Exception: # noqa: BLE001
297
+ return []
298
+ if tup is None:
299
+ return []
300
+
301
+ checkpoint = getattr(tup, "checkpoint", None) or {}
302
+ values = checkpoint.get("channel_values") or {}
303
+ messages = values.get("messages")
304
+ if messages:
305
+ return list(messages)
306
+
307
+ # 当前 checkpoint 无 messages(被压缩),向上追溯
308
+ parent = getattr(tup, "parent_config", None)
309
+ if parent is None:
310
+ return []
311
+ config = parent
312
+
313
+ return []
314
+
315
+
316
+ def load_messages_from_sqlite_file(
317
+ checkpoint_path: Path | str,
318
+ thread_id: str,
319
+ *,
320
+ max_parents: int = 50,
321
+ ) -> list[Any]:
322
+ """从 SqliteSaver 数据库加载 thread 的消息。
323
+
324
+ 当最新 checkpoint 的消息已被上下文压缩清空时,沿父 checkpoint 链
325
+ 向上回退,直到找到包含完整 messages 的快照。
326
+
327
+ Args:
328
+ checkpoint_path: checkpoints.sqlite 路径
329
+ thread_id: 会话 ID
330
+ max_parents: 最多回退的父 checkpoint 数
331
+ """
332
+ path = Path(checkpoint_path).expanduser()
333
+ if not path.is_file():
334
+ return []
335
+ try:
336
+ from langgraph.checkpoint.sqlite import SqliteSaver
337
+ except Exception: # noqa: BLE001
338
+ return []
339
+
340
+ conn = sqlite3.connect(str(path), check_same_thread=False)
341
+ try:
342
+ saver = SqliteSaver(conn)
343
+ messages = load_messages_from_checkpointer(
344
+ saver, thread_id, max_parents=max_parents
345
+ )
346
+ if messages:
347
+ return messages
348
+
349
+ # 最终回退:解析 deepagents 压缩导出的 conversation_history Markdown
350
+ history_md = path.parent / ".." / "conversation_history" / f"{thread_id}.md"
351
+ try:
352
+ resolved = history_md.resolve()
353
+ except Exception: # noqa: BLE001
354
+ resolved = None
355
+ if resolved and resolved.is_file():
356
+ return parse_conversation_history_md(resolved)
357
+ return []
358
+ except Exception: # noqa: BLE001
359
+ return []
360
+ finally:
361
+ conn.close()
362
+
363
+
364
+ def load_messages_from_agent(agent: Any, thread_id: str) -> list[Any]:
365
+ """Load messages via agent.get_state when available."""
366
+ if agent is None or not thread_id:
367
+ return []
368
+ get_state = getattr(agent, "get_state", None)
369
+ if not callable(get_state):
370
+ # Some compiled graphs expose this on the runnable.
371
+ return []
372
+ try:
373
+ state = get_state({"configurable": {"thread_id": thread_id}})
374
+ values = getattr(state, "values", None) or {}
375
+ if isinstance(values, dict):
376
+ messages = values.get("messages") or []
377
+ return list(messages)
378
+ except Exception: # noqa: BLE001
379
+ return []
380
+ return []
381
+
382
+
383
+ def load_thread_messages(
384
+ *,
385
+ agent: Any = None,
386
+ settings: Any = None,
387
+ thread_id: str,
388
+ checkpointer: Any = None,
389
+ ) -> list[Any]:
390
+ """Load thread messages: agent state → checkpointer → sqlite file."""
391
+ messages = load_messages_from_agent(agent, thread_id)
392
+ if messages:
393
+ return messages
394
+ cp = checkpointer or getattr(agent, "_coding_checkpointer", None)
395
+ messages = load_messages_from_checkpointer(cp, thread_id)
396
+ if messages:
397
+ return messages
398
+ if settings is not None:
399
+ path = getattr(settings, "checkpoint_path", None)
400
+ backend = getattr(settings, "checkpoint_backend", "sqlite")
401
+ if path is not None and backend == "sqlite":
402
+ return load_messages_from_sqlite_file(path, thread_id)
403
+ return []
404
+
405
+
406
+ @dataclass
407
+ class UiTranscriptEvent:
408
+ """One renderable unit for TUI/history replay."""
409
+
410
+ kind: str # user | answer | thought | tools | meta
411
+ text: str = ""
412
+ tool_calls: list[dict[str, Any]] = field(default_factory=list)
413
+ tool_results: list[dict[str, Any]] = field(default_factory=list)
414
+ # Optional inline images for user turns: (raw_bytes, mime)
415
+ images: list[tuple[bytes, str]] = field(default_factory=list)
416
+
417
+
418
+
419
+ def _message_images(msg: Any) -> list[tuple[bytes, str]]:
420
+ """Best-effort image bytes from a human multimodal message."""
421
+ content = getattr(msg, "content", None)
422
+ if content is None and isinstance(msg, dict):
423
+ content = msg.get("content")
424
+ try:
425
+ from synapse.content.multimodal import extract_image_payloads
426
+ except Exception: # noqa: BLE001
427
+ return []
428
+ try:
429
+ return extract_image_payloads(content)
430
+ except Exception: # noqa: BLE001
431
+ return []
432
+
433
+
434
+ def fold_messages_for_ui(messages: list[Any]) -> list[UiTranscriptEvent]:
435
+ """Collapse LangChain messages into TUI-friendly events."""
436
+ events: list[UiTranscriptEvent] = []
437
+ pending_calls: list[dict[str, Any]] = []
438
+ pending_results: dict[str, dict[str, Any]] = {}
439
+
440
+ def flush_tools() -> None:
441
+ nonlocal pending_calls, pending_results
442
+ if not pending_calls and not pending_results:
443
+ return
444
+ results = list(pending_results.values())
445
+ # Keep result order aligned with call order when possible.
446
+ ordered: list[dict[str, Any]] = []
447
+ seen: set[str] = set()
448
+ for call in pending_calls:
449
+ cid = str(call.get("id") or "")
450
+ if cid and cid in pending_results:
451
+ ordered.append(pending_results[cid])
452
+ seen.add(cid)
453
+ for cid, res in pending_results.items():
454
+ if cid not in seen:
455
+ ordered.append(res)
456
+ events.append(
457
+ UiTranscriptEvent(
458
+ kind="tools",
459
+ tool_calls=list(pending_calls),
460
+ tool_results=ordered or results,
461
+ )
462
+ )
463
+ pending_calls = []
464
+ pending_results = {}
465
+
466
+ for msg in messages or []:
467
+ role = _message_role(msg)
468
+ if role in {"human", "user"}:
469
+ flush_tools()
470
+ # Context-compaction wrappers are for the model only.
471
+ if is_lc_summarization_message(msg):
472
+ continue
473
+ # Mid-run steer is model-only chrome; never paint in the transcript.
474
+ try:
475
+ from synapse.runtime.steer import is_steer_message
476
+
477
+ if is_steer_message(msg):
478
+ continue
479
+ except Exception: # noqa: BLE001
480
+ pass
481
+ text = _message_content(msg).strip()
482
+ if is_context_compact_text(text):
483
+ continue
484
+ try:
485
+ from synapse.runtime.steer import is_steer_message as _is_steer
486
+
487
+ if _is_steer(text=text):
488
+ continue
489
+ except Exception: # noqa: BLE001
490
+ pass
491
+ images = _message_images(msg)
492
+ if text or images:
493
+ events.append(
494
+ UiTranscriptEvent(
495
+ kind="user",
496
+ text=text or ("(image)" if images else ""),
497
+ images=images,
498
+ )
499
+ )
500
+ continue
501
+
502
+ if role == "system":
503
+ continue
504
+
505
+ if role == "tool":
506
+ cid = str(
507
+ getattr(msg, "tool_call_id", None)
508
+ or (msg.get("tool_call_id") if isinstance(msg, dict) else None)
509
+ or ""
510
+ )
511
+ name = str(
512
+ getattr(msg, "name", None)
513
+ or (msg.get("name") if isinstance(msg, dict) else None)
514
+ or "tool"
515
+ )
516
+ content = _message_content(msg)
517
+ artifact = getattr(msg, "artifact", None)
518
+ if artifact is None and isinstance(msg, dict):
519
+ artifact = msg.get("artifact")
520
+ if isinstance(artifact, dict) and artifact.get("tool_result_ref"):
521
+ ref = str(artifact["tool_result_ref"])
522
+ content = f"{content}\n\n[full result: {ref}]"
523
+ status = "error" if _looks_error(content) else "ok"
524
+ key = cid or f"anon-{len(pending_results)}"
525
+ pending_results[key] = {
526
+ "id": key,
527
+ "name": name,
528
+ "content": content,
529
+ "status": status,
530
+ }
531
+ continue
532
+
533
+ if role in {"ai", "assistant"}:
534
+ reasoning = _message_reasoning(msg).strip()
535
+ text = _message_content(msg).strip()
536
+ calls = _tool_calls(msg)
537
+ if reasoning:
538
+ # Thought before tools/answer for this model turn.
539
+ events.append(UiTranscriptEvent(kind="thought", text=reasoning))
540
+ if calls:
541
+ pending_calls.extend(calls)
542
+ if text and not _looks_like_tool_payload(text):
543
+ if is_lc_summarization_message(msg) or is_context_compact_text(text):
544
+ continue
545
+ flush_tools()
546
+ events.append(UiTranscriptEvent(kind="answer", text=text))
547
+ continue
548
+
549
+ # Unknown role: ignore noise
550
+ continue
551
+
552
+ flush_tools()
553
+ return events
554
+
555
+
556
+ def _looks_error(content: str) -> bool:
557
+ low = (content or "").casefold()
558
+ return low.startswith("error") or "traceback" in low or "exception:" in low
559
+
560
+
561
+ def export_transcript_markdown(
562
+ *,
563
+ thread_id: str,
564
+ title: str | None = None,
565
+ model: str | None = None,
566
+ messages: list[Any] | None = None,
567
+ ) -> str:
568
+ lines = [
569
+ f"# {title or thread_id}",
570
+ "",
571
+ f"- thread_id: `{thread_id}`",
572
+ f"- model: `{model or '-'}`",
573
+ f"- messages: {len(messages or [])}",
574
+ "",
575
+ "## Transcript",
576
+ "",
577
+ ]
578
+ if not messages:
579
+ lines.append("(no checkpoint messages found)")
580
+ lines.append("")
581
+ return "\n".join(lines)
582
+
583
+ for i, msg in enumerate(messages, 1):
584
+ item = message_to_export_dict(msg)
585
+ role = item.get("role") or "unknown"
586
+ content = (item.get("content") or "").rstrip()
587
+ lines.append(f"### {i}. {role}")
588
+ lines.append("")
589
+ lines.append(content if content else "(empty)")
590
+ lines.append("")
591
+ return "\n".join(lines)
592
+
593
+
594
+ def export_transcript_json(
595
+ *,
596
+ thread_id: str,
597
+ title: str | None = None,
598
+ model: str | None = None,
599
+ messages: list[Any] | None = None,
600
+ meta: dict[str, Any] | None = None,
601
+ ) -> dict[str, Any]:
602
+ return {
603
+ "thread_id": thread_id,
604
+ "title": title,
605
+ "model": model,
606
+ "meta": meta or {},
607
+ "messages": [message_to_export_dict(m) for m in (messages or [])],
608
+ }
609
+
610
+
611
+ def split_messages_by_turns(messages: list[Any]) -> list[list[Any]]:
612
+ """按 HumanMessage 边界切分为轮次列表。
613
+
614
+ 每轮 = [user_msg, *后续非 human 消息],以 human/user 为边界切分。
615
+ 入参为空或无 human 消息时返回空列表。
616
+ """
617
+ if not messages:
618
+ return []
619
+ turns: list[list[Any]] = []
620
+ current: list[Any] = []
621
+ for msg in messages:
622
+ role = _message_role(msg)
623
+ if role in {"human", "user"}:
624
+ if current:
625
+ turns.append(current)
626
+ current = [msg]
627
+ else:
628
+ if current:
629
+ current.append(msg)
630
+ if current:
631
+ turns.append(current)
632
+ return turns
633
+
634
+
635
+ def format_turns_as_text(
636
+ turns: list[list[Any]],
637
+ *,
638
+ max_turns: int = 0,
639
+ max_chars_per_turn: int = 8000,
640
+ ) -> str:
641
+ """将轮次列表格式化为可读文本。
642
+
643
+ Args:
644
+ turns: split_messages_by_turns 的输出
645
+ max_turns: 0 = 全量,N = 仅最后 N 轮
646
+ max_chars_per_turn: 每轮最大字符数,超出截断
647
+ """
648
+ if not turns:
649
+ return "(无对话内容)"
650
+
651
+ target = turns
652
+ if max_turns > 0:
653
+ target = turns[-max_turns:]
654
+
655
+ lines: list[str] = []
656
+ total_turns = len(turns)
657
+ if 0 < max_turns < total_turns:
658
+ lines.append(f"[共 {total_turns} 轮,以下为最后 {max_turns} 轮]\n")
659
+
660
+ for i, turn in enumerate(target):
661
+ turn_idx = (
662
+ total_turns - len(target) + i + 1
663
+ if max_turns and max_turns < total_turns
664
+ else i + 1
665
+ )
666
+ lines.append(f"--- 第 {turn_idx} 轮 ---")
667
+ for msg in turn:
668
+ item = message_to_export_dict(msg)
669
+ role = item["role"].upper()
670
+ content = (item.get("content") or "").strip()
671
+ if not content:
672
+ continue
673
+ if len(content) > max_chars_per_turn:
674
+ content = content[:max_chars_per_turn] + "\n...[截断]..."
675
+ lines.append(f"[{role}] {content}")
676
+ lines.append("")
677
+ return "\n".join(lines)
678
+
679
+
680
+ def parse_conversation_history_md(
681
+ path: Path | str,
682
+ ) -> list[dict[str, Any]]:
683
+ """解析 deepagents 压缩导出的 conversation_history Markdown 文件。
684
+
685
+ 返回消息 dict 列表,格式与 message_to_export_dict 兼容,
686
+ 可直接传给 split_messages_by_turns / format_turns_as_text。
687
+
688
+ 文件格式:
689
+ <message type="human">用户文本</message>
690
+ <message type="ai">
691
+ 可选文本
692
+ <tool_call id=".." name="..">{json_args}</tool_call>
693
+ </message>
694
+ <message type="tool">工具返回内容</message>
695
+ """
696
+ import re
697
+
698
+ file_path = Path(path).expanduser()
699
+ if not file_path.is_file():
700
+ return []
701
+
702
+ text = file_path.read_text(encoding="utf-8", errors="replace")
703
+
704
+ # 按 <message type=...> 切分
705
+ msg_pattern = re.compile(
706
+ r"<message\s+type=\"(human|ai|tool)\">(.*?)</message>",
707
+ re.DOTALL,
708
+ )
709
+ tool_call_pattern = re.compile(
710
+ r'<tool_call\s+id="([^"]+)"\s+name="([^"]+)">(.*?)</tool_call>',
711
+ re.DOTALL,
712
+ )
713
+
714
+ messages: list[dict[str, Any]] = []
715
+ for m in msg_pattern.finditer(text):
716
+ mtype = m.group(1)
717
+ body = m.group(2)
718
+
719
+ if mtype == "ai":
720
+ # 提取 tool_calls 标签,其余为文本
721
+ tool_calls: list[dict[str, Any]] = []
722
+ parts: list[str] = []
723
+ last_end = 0
724
+ for tc in tool_call_pattern.finditer(body):
725
+ prefix = body[last_end : tc.start()].strip()
726
+ if prefix:
727
+ parts.append(prefix)
728
+ args_str = tc.group(3).strip()
729
+ try:
730
+ import json as _json
731
+ args = _json.loads(args_str)
732
+ except Exception: # noqa: BLE001
733
+ args = {"raw": args_str}
734
+ tool_calls.append({
735
+ "id": tc.group(1),
736
+ "name": tc.group(2),
737
+ "args": args,
738
+ })
739
+ last_end = tc.end()
740
+ suffix = body[last_end:].strip()
741
+ if suffix:
742
+ parts.append(suffix)
743
+ content = "\n".join(parts)
744
+ msg = {"role": "ai", "content": content}
745
+ if tool_calls:
746
+ msg["tool_calls"] = tool_calls
747
+ elif mtype == "tool":
748
+ msg = {"role": "tool", "content": body.strip()}
749
+ else:
750
+ msg = {"role": "human", "content": body.strip()}
751
+
752
+ messages.append(msg)
753
+
754
+ return messages