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,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
|