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,249 @@
1
+ """System prompts for the coding agent.
2
+
3
+ Default body ships in English. Prefer loading an external markdown file:
4
+
5
+ 1. ``<workspace>/.synapse/system_prompt.md`` (project override)
6
+ 2. ``~/.synapse/system_prompt.md`` (user global)
7
+
8
+ If the user file is missing, it is created from the built-in default on first use.
9
+ The workspace footer is always appended in code.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ from synapse.settings.config_paths import project_config_dir, user_config_dir
17
+
18
+ SYSTEM_PROMPT_FILENAME = "system_prompt.md"
19
+
20
+ # Built-in default (English). External config may override this body.
21
+ DEFAULT_CODING_SYSTEM_PROMPT = """\
22
+ You are a senior software engineering agent working in a local workspace.
23
+
24
+ ## Language and style
25
+
26
+ * Think and reason in Chinese.
27
+ * Reply to the user in Chinese.
28
+ * Preserve code identifiers, paths, commands, logs, and API names as written.
29
+ * Use no emoji or emoticons. Use plain text markers such as `-`, `[x]`, `OK`, and `FAIL`.
30
+ * Keep responses concise and technical. Lead with the conclusion.
31
+
32
+ ## Goal
33
+
34
+ Help implement features, fix bugs, refactor code, write tests, inspect repositories,
35
+ and verify changes.
36
+
37
+ ## Effort calibration
38
+
39
+ Match effort to the request.
40
+
41
+ For greetings, connectivity checks, meaningless input, or casual conversation:
42
+
43
+ * Reply briefly.
44
+ * Do not call tools, scan files, create todos, or launch subagents.
45
+
46
+ Do not inspect or modify the repository unless the user clearly requests
47
+ implementation, debugging, review, testing, or repository-specific information.
48
+
49
+ When intent is unclear but may represent a real task, ask for clarification
50
+ in one or two short sentences. Do not substitute clarification with exploratory commands.
51
+
52
+ For clear tasks, use the smallest targeted exploration necessary.
53
+ Never scan the entire repository without a specific reason.
54
+
55
+ ## Virtual filesystem
56
+
57
+ File tools such as `ls`, `glob`, `grep`, `read_file`, `edit_file`, and `write_file`
58
+ operate on a virtual filesystem rooted at `/`.
59
+
60
+ Valid paths:
61
+
62
+ * `/`
63
+ * `/README.md`
64
+ * `/src/app.py`
65
+ * `/tests`
66
+
67
+ File-tool paths must:
68
+
69
+ * Start with `/`.
70
+ * Be relative to the workspace root.
71
+ * Never use Windows drive paths, host absolute paths, or paths without a leading slash.
72
+
73
+ The real host workspace path may only be used by shell or git commands.
74
+
75
+ Use `ls /` only when a concrete file task exists and the target path is unknown.
76
+ If a virtual-path error occurs, convert the path to `/...`;
77
+ do not retry a host or Windows path.
78
+
79
+ ## Workspace rules
80
+
81
+ * Stay within the workspace unless explicitly asked otherwise.
82
+ * Read relevant files before editing when practical.
83
+ * Never invent file contents or repository facts.
84
+ * Prefer small, reversible changes over broad rewrites.
85
+ * Never expose secrets, credentials, private keys, or `.env` contents.
86
+ * Avoid destructive actions unless explicitly requested.
87
+
88
+ ## Workflow
89
+
90
+ For a clear coding or debugging task:
91
+
92
+ 1. Understand the request and clarify only critical ambiguity.
93
+ 2. Locate the relevant files with targeted searches.
94
+ 3. Read only the necessary files or line ranges.
95
+ 4. Use `write_todos` only for genuinely multi-step, multi-file work likely to span multiple turns.
96
+ 5. Apply focused edits.
97
+ 6. Run the narrowest useful test, lint, typecheck, or build command.
98
+ 7. Diagnose failures and iterate until successful or clearly blocked.
99
+ 8. Finish with:
100
+
101
+ * What changed
102
+ * How it was verified
103
+ * Remaining risks, if any
104
+
105
+ Prefer the repository's existing package and test commands.
106
+
107
+ ## Tool usage
108
+
109
+ Every tool call must include a short English `intent` describing its purpose, for example:
110
+
111
+ * `locate authentication handler`
112
+ * `inspect pytest configuration`
113
+ * `run narrow regression test`
114
+
115
+ Do not use generic intent values such as `run tool` or `read_file`.
116
+
117
+ Search only when required by a clear task.
118
+ Keep searches targeted and avoid unnecessary full-file output.
119
+
120
+ For `glob` and `grep` tools, automatically exclude common build artifacts and caches:
121
+ * `target/`
122
+ * `.venv/`
123
+ * `.node_modules/`
124
+ * `__pycache__/`
125
+ * `.git/`
126
+ * `*.pyc`
127
+
128
+ For large files, read only relevant ranges. After editing, re-read changed regions when useful.
129
+
130
+ `list_sessions` and `read_session` are forbidden unless the user explicitly
131
+ asks to inspect or compare other sessions.
132
+
133
+ Use direct repository tools by default. Use `task` subagents only for large work
134
+ that genuinely benefits from isolation; never use them for small tasks,
135
+ ordinary exploration, or conversation.
136
+
137
+ ## Parallel tool calls
138
+
139
+ Run independent tool calls in parallel within the same turn.
140
+
141
+ Parallelize when arguments are already known and results do not depend on each other, including:
142
+
143
+ * Multiple file reads
144
+ * `glob` and related file reads
145
+ * `execute` and related reads
146
+ * Edits to different files
147
+ * Independent test or lint commands
148
+
149
+ Serialize only when:
150
+
151
+ * A later path, command, or patch depends on an earlier result.
152
+ * The same file must be read before it can be edited.
153
+ * Verification depends on edits being completed.
154
+
155
+ Do not spread known independent reads or edits across multiple turns.
156
+ Parallelism must remain relevant to the current task.
157
+
158
+ ## Output format
159
+
160
+ Keep user-facing responses brief.
161
+
162
+ Preferred structure:
163
+
164
+ 1. Conclusion in one or two sentences
165
+ 2. Short list of key changes or findings
166
+ 3. Verification and risks, when applicable
167
+
168
+ Do not expose internal reasoning or paste long tool logs.
169
+ For casual input, reply in one or two sentences.
170
+ """
171
+
172
+ # Backward-compatible alias used by older imports/tests.
173
+ CODING_SYSTEM_PROMPT = DEFAULT_CODING_SYSTEM_PROMPT
174
+
175
+
176
+ def user_system_prompt_path() -> Path:
177
+ """``~/.synapse/system_prompt.md``."""
178
+ return user_config_dir() / SYSTEM_PROMPT_FILENAME
179
+
180
+
181
+ def project_system_prompt_path(workspace: Path | str | None = None) -> Path:
182
+ """``<workspace>/.synapse/system_prompt.md``."""
183
+ return project_config_dir(workspace) / SYSTEM_PROMPT_FILENAME
184
+
185
+
186
+ def ensure_user_system_prompt(*, force: bool = False) -> Path:
187
+ """Ensure the user global prompt file exists; seed from built-in default.
188
+
189
+ Returns the user prompt path. Does not overwrite an existing file unless
190
+ ``force=True``.
191
+ """
192
+ path = user_system_prompt_path()
193
+ path.parent.mkdir(parents=True, exist_ok=True)
194
+ if force or not path.is_file():
195
+ path.write_text(DEFAULT_CODING_SYSTEM_PROMPT.strip() + "\n", encoding="utf-8")
196
+ return path
197
+
198
+
199
+ def resolve_system_prompt_path(workspace: Path | str | None = None) -> Path | None:
200
+ """Return the first existing external prompt file (project, then user)."""
201
+ candidates = [
202
+ project_system_prompt_path(workspace),
203
+ user_system_prompt_path(),
204
+ ]
205
+ for candidate in candidates:
206
+ try:
207
+ if candidate.is_file():
208
+ return candidate.resolve()
209
+ except OSError:
210
+ continue
211
+ return None
212
+
213
+
214
+ def load_coding_system_prompt(
215
+ workspace: Path | str | None = None,
216
+ *,
217
+ ensure_user_file: bool = False,
218
+ ) -> str:
219
+ """Load prompt body from config file, else built-in default.
220
+
221
+ When ``ensure_user_file`` is True and neither project nor user file exists,
222
+ seed ``~/.synapse/system_prompt.md`` and load it.
223
+ """
224
+ path = resolve_system_prompt_path(workspace)
225
+ if path is None and ensure_user_file:
226
+ path = ensure_user_system_prompt()
227
+ if path is not None:
228
+ try:
229
+ text = path.read_text(encoding="utf-8").strip()
230
+ if text:
231
+ return text
232
+ except OSError:
233
+ pass
234
+ return DEFAULT_CODING_SYSTEM_PROMPT.strip()
235
+
236
+
237
+ def build_system_prompt(workspace: Path, *, ensure_user_file: bool = False) -> str:
238
+ """Build a system prompt with workspace context."""
239
+ root = Path(workspace).resolve()
240
+ body = load_coding_system_prompt(root, ensure_user_file=ensure_user_file)
241
+ return (
242
+ f"{body}\n\n"
243
+ f"## Current workspace\n"
244
+ f"- Host root (shell/git only): `{root}`\n"
245
+ f"- File-tool virtual root: `/` maps to the host root above\n"
246
+ f"- Mapping example: `{root / 'README.md'}` -> `/README.md`\n"
247
+ f"- Shell commands run on the host, inside the workspace root.\n"
248
+ f"- Again: thinking and final replies must be Chinese; keep user-facing output concise.\n"
249
+ )
@@ -0,0 +1,128 @@
1
+ """Discover local Agent Skills and memory files for /skills /memory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ _FRONT_MATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?", re.S)
11
+
12
+
13
+ @dataclass
14
+ class SkillInfo:
15
+ name: str
16
+ description: str = ""
17
+ path: str = ""
18
+ source: str = ""
19
+
20
+
21
+ def _parse_frontmatter(text: str) -> dict[str, str]:
22
+ m = _FRONT_MATTER_RE.match(text or "")
23
+ if not m:
24
+ return {}
25
+ data: dict[str, str] = {}
26
+ for line in m.group(1).splitlines():
27
+ if ":" not in line:
28
+ continue
29
+ key, val = line.split(":", 1)
30
+ data[key.strip().casefold()] = val.strip().strip("\"'")
31
+ return data
32
+
33
+
34
+ def discover_skills(skills_paths: list[str] | None) -> list[SkillInfo]:
35
+ """Scan skills directories for SKILL.md files."""
36
+ found: list[SkillInfo] = []
37
+ seen: set[str] = set()
38
+ for raw in skills_paths or []:
39
+ root = Path(raw).expanduser()
40
+ if not root.exists():
41
+ continue
42
+ if root.is_file() and root.name.upper() == "SKILL.MD":
43
+ candidates = [root]
44
+ source = str(root.parent)
45
+ else:
46
+ candidates = sorted(root.rglob("SKILL.md"))
47
+ source = str(root.resolve())
48
+ for skill_md in candidates:
49
+ try:
50
+ text = skill_md.read_text(encoding="utf-8")
51
+ except Exception: # noqa: BLE001
52
+ continue
53
+ meta = _parse_frontmatter(text)
54
+ name = meta.get("name") or skill_md.parent.name
55
+ desc = meta.get("description") or ""
56
+ key = str(skill_md.resolve())
57
+ if key in seen:
58
+ continue
59
+ seen.add(key)
60
+ found.append(
61
+ SkillInfo(
62
+ name=name,
63
+ description=desc,
64
+ path=str(skill_md.resolve()),
65
+ source=source,
66
+ )
67
+ )
68
+ return found
69
+
70
+
71
+ def format_skills_lines(skills: list[SkillInfo]) -> list[str]:
72
+ if not skills:
73
+ return ["skills: (none found)", "tip: put SKILL.md under skills/<name>/"]
74
+ lines = [f"skills: {len(skills)}"]
75
+ for s in skills:
76
+ desc = s.description
77
+ if len(desc) > 70:
78
+ desc = desc[:69] + "…"
79
+ lines.append(f" - {s.name}: {desc or '(no description)'}")
80
+ lines.append(f" {s.path}")
81
+ return lines
82
+
83
+
84
+ def list_memory_files(memory_paths: list[str] | None) -> list[tuple[str, bool, int]]:
85
+ """Return (path, exists, size_bytes)."""
86
+ out: list[tuple[str, bool, int]] = []
87
+ for raw in memory_paths or []:
88
+ p = Path(raw).expanduser()
89
+ if p.exists() and p.is_file():
90
+ try:
91
+ size = p.stat().st_size
92
+ except Exception: # noqa: BLE001
93
+ size = 0
94
+ out.append((str(p.resolve()), True, size))
95
+ else:
96
+ out.append((str(p), False, 0))
97
+ return out
98
+
99
+
100
+ def format_memory_lines(entries: list[tuple[str, bool, int]]) -> list[str]:
101
+ if not entries:
102
+ return ["memory: (no paths configured)"]
103
+ lines = [f"memory files: {len(entries)}"]
104
+ for path, exists, size in entries:
105
+ if exists:
106
+ lines.append(f" - {path} ({size} bytes)")
107
+ else:
108
+ lines.append(f" - {path} (missing)")
109
+ lines.append("note: existing files are injected via create_deep_agent(memory=...)")
110
+ return lines
111
+
112
+
113
+ def skills_paths_from_settings(settings: Any, project_root: Path | None = None) -> list[str]:
114
+ root = project_root or Path.cwd()
115
+ fn = getattr(settings, "resolved_skills_paths", None)
116
+ if callable(fn):
117
+ return list(fn(root) or [])
118
+ raw = getattr(settings, "skills_paths", None) or []
119
+ return [str((root / p).resolve()) if not Path(p).is_absolute() else p for p in raw]
120
+
121
+
122
+ def memory_paths_from_settings(settings: Any, project_root: Path | None = None) -> list[str]:
123
+ root = project_root or Path.cwd()
124
+ fn = getattr(settings, "resolved_memory_paths", None)
125
+ if callable(fn):
126
+ return list(fn(root) or [])
127
+ raw = getattr(settings, "memory_paths", None) or []
128
+ return [str((root / p).resolve()) if not Path(p).is_absolute() else p for p in raw]
@@ -0,0 +1 @@
1
+ """External service and provider integrations."""
@@ -0,0 +1,281 @@
1
+ """Fail-closed terminal checkpoint seeding for projected Codex text.
2
+
3
+ This module is deliberately narrow. It writes an already validated visible-text
4
+ snapshot into a fresh Synapse thread, then seals the graph at ``END`` so the
5
+ thread has no pending tasks. It does not resume Codex runtime state, perform
6
+ source discovery, create session metadata, or implement import idempotency.
7
+
8
+ The implementation relies only on public LangGraph graph APIs, but its
9
+ ``model`` node behavior is compatibility-gated to the installed DeepAgents and
10
+ LangGraph versions. Upgrade those dependencies only together with the contract
11
+ tests in ``tests/test_checkpoint_seed.py``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Sequence
17
+ from dataclasses import dataclass
18
+ from importlib.metadata import PackageNotFoundError, version
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from langchain_core.messages import AIMessage, HumanMessage
22
+ from langgraph.graph import END
23
+
24
+ from synapse.integrations.codex_history import PARSER_VERSION, PROJECTION_KIND
25
+
26
+ if TYPE_CHECKING:
27
+ from langchain_core.messages import BaseMessage
28
+
29
+ from synapse.integrations.codex_history import CodexTextSnapshot
30
+
31
+ SUPPORTED_LANGGRAPH_VERSION = "1.2.9"
32
+ SUPPORTED_DEEPAGENTS_VERSION = "0.6.12"
33
+
34
+
35
+ class CheckpointSeedError(RuntimeError):
36
+ """The requested checkpoint seed was rejected or could not be verified."""
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class CheckpointSeedResult:
41
+ """Verified terminal checkpoint location for a newly seeded thread."""
42
+
43
+ thread_id: str
44
+ config: dict[str, Any]
45
+ message_count: int
46
+
47
+
48
+ class CheckpointSeeder:
49
+ """Seed a validated visible-text snapshot into a fresh, terminal thread.
50
+
51
+ The seed operation has one intentionally narrow compatibility contract:
52
+
53
+ - LangGraph 1.2.9 and DeepAgents 0.6.12 only.
54
+ - A root-namespace graph with a ``model`` node and a ``messages`` channel.
55
+ - An empty target thread only; a future import ledger owns idempotency.
56
+ - User/assistant text only, represented as deterministic message IDs.
57
+
58
+ Any failed write is compensated with ``delete_thread(thread_id)`` after the
59
+ target was proven empty before seeding. A cleanup failure is surfaced rather
60
+ than silently claiming a safe result.
61
+ """
62
+
63
+ def __init__(self, agent: Any) -> None:
64
+ self._agent = agent
65
+
66
+ def seed_snapshot(
67
+ self,
68
+ thread_id: str,
69
+ snapshot: CodexTextSnapshot,
70
+ ) -> CheckpointSeedResult:
71
+ """Write one safe Codex snapshot and verify that it is terminal."""
72
+ messages = _messages_from_snapshot(snapshot)
73
+ return self.seed_messages(thread_id, messages)
74
+
75
+ def verify_snapshot(
76
+ self,
77
+ thread_id: str,
78
+ snapshot: CodexTextSnapshot,
79
+ ) -> CheckpointSeedResult:
80
+ """Verify that a thread exactly contains a terminal snapshot seed."""
81
+ normalized_thread_id = _validate_thread_id(thread_id)
82
+ expected = _messages_from_snapshot(snapshot)
83
+ self._validate_compatibility()
84
+ saver = self._checkpointer()
85
+ config = {"configurable": {"thread_id": normalized_thread_id, "checkpoint_ns": ""}}
86
+ self._verify_terminal(config, expected, saver)
87
+ state = self._agent.get_state(config)
88
+ return CheckpointSeedResult(
89
+ thread_id=normalized_thread_id,
90
+ config=_copy_config(state.config),
91
+ message_count=len(expected),
92
+ )
93
+
94
+ def has_thread(self, thread_id: str) -> bool:
95
+ """Return whether the root checkpoint namespace has any saved state."""
96
+ normalized_thread_id = _validate_thread_id(thread_id)
97
+ config = {"configurable": {"thread_id": normalized_thread_id, "checkpoint_ns": ""}}
98
+ return self._checkpointer().get_tuple(config) is not None
99
+
100
+ def delete_thread(self, thread_id: str) -> None:
101
+ """Delete a thread only for a failed import compensation path."""
102
+ self._checkpointer().delete_thread(_validate_thread_id(thread_id))
103
+
104
+ def seed_messages(
105
+ self,
106
+ thread_id: str,
107
+ messages: Sequence[HumanMessage | AIMessage],
108
+ ) -> CheckpointSeedResult:
109
+ """Write explicit safe messages into a new terminal graph thread.
110
+
111
+ This lower-level entry point is retained for future non-Codex importers,
112
+ but it accepts only plain ``HumanMessage`` and ``AIMessage`` values with
113
+ stable unique IDs. It rejects tool calls and all metadata-bearing input.
114
+ """
115
+ normalized_thread_id = _validate_thread_id(thread_id)
116
+ expected = _validate_messages(messages)
117
+ self._validate_compatibility()
118
+ saver = self._checkpointer()
119
+ config = {"configurable": {"thread_id": normalized_thread_id, "checkpoint_ns": ""}}
120
+ if saver.get_tuple(config) is not None:
121
+ raise CheckpointSeedError("target thread already has a checkpoint")
122
+
123
+ attempted_write = False
124
+ try:
125
+ attempted_write = True
126
+ seeded_config = self._agent.update_state(
127
+ config,
128
+ {"messages": expected},
129
+ as_node="model",
130
+ )
131
+ terminal_config = self._agent.update_state(seeded_config, None, as_node=END)
132
+ self._verify_terminal(terminal_config, expected, saver)
133
+ except Exception as exc:
134
+ if attempted_write:
135
+ self._compensate(saver, normalized_thread_id, exc)
136
+ if isinstance(exc, CheckpointSeedError):
137
+ raise
138
+ raise CheckpointSeedError("checkpoint seed failed") from exc
139
+
140
+ return CheckpointSeedResult(
141
+ thread_id=normalized_thread_id,
142
+ config=_copy_config(terminal_config),
143
+ message_count=len(expected),
144
+ )
145
+
146
+ def _validate_compatibility(self) -> None:
147
+ installed = _framework_versions()
148
+ if installed != (SUPPORTED_LANGGRAPH_VERSION, SUPPORTED_DEEPAGENTS_VERSION):
149
+ raise CheckpointSeedError(
150
+ "checkpoint seeding is unsupported for installed framework versions"
151
+ )
152
+ nodes = getattr(self._agent, "nodes", None)
153
+ channels = getattr(self._agent, "channels", None)
154
+ if not isinstance(nodes, dict) or "model" not in nodes:
155
+ raise CheckpointSeedError("checkpoint seeding requires a graph model node")
156
+ if not isinstance(channels, dict) or "messages" not in channels:
157
+ raise CheckpointSeedError("checkpoint seeding requires a messages channel")
158
+ for name in ("update_state", "get_state"):
159
+ if not callable(getattr(self._agent, name, None)):
160
+ raise CheckpointSeedError(f"checkpoint seeding requires agent.{name}")
161
+
162
+ def _checkpointer(self) -> Any:
163
+ saver = getattr(self._agent, "_coding_checkpointer", None)
164
+ if saver is None:
165
+ saver = getattr(self._agent, "checkpointer", None)
166
+ if not callable(getattr(saver, "get_tuple", None)):
167
+ raise CheckpointSeedError("checkpoint seeding requires a readable checkpointer")
168
+ if not callable(getattr(saver, "delete_thread", None)):
169
+ raise CheckpointSeedError("checkpoint seeding requires checkpoint compensation")
170
+ return saver
171
+
172
+ def _verify_terminal(
173
+ self,
174
+ config: dict[str, Any],
175
+ expected: Sequence[HumanMessage | AIMessage],
176
+ saver: Any,
177
+ ) -> None:
178
+ state = self._agent.get_state(config)
179
+ if tuple(getattr(state, "next", ()) or ()):
180
+ raise CheckpointSeedError("seeded checkpoint still has pending graph tasks")
181
+ if tuple(getattr(state, "interrupts", ()) or ()):
182
+ raise CheckpointSeedError("seeded checkpoint has pending interrupts")
183
+ actual = list((getattr(state, "values", None) or {}).get("messages") or [])
184
+ if not _messages_match(actual, expected):
185
+ raise CheckpointSeedError("seeded checkpoint messages did not round-trip exactly")
186
+ checkpoint_tuple = saver.get_tuple(config)
187
+ if checkpoint_tuple is None:
188
+ raise CheckpointSeedError("seeded checkpoint could not be read back")
189
+ if getattr(checkpoint_tuple, "pending_writes", ()):
190
+ raise CheckpointSeedError("seeded checkpoint still has pending writes")
191
+
192
+ @staticmethod
193
+ def _compensate(saver: Any, thread_id: str, cause: Exception) -> None:
194
+ try:
195
+ saver.delete_thread(thread_id)
196
+ except Exception as cleanup_error: # noqa: BLE001
197
+ raise CheckpointSeedError(
198
+ "checkpoint seed failed and cleanup was unsuccessful"
199
+ ) from cleanup_error
200
+
201
+
202
+ def _framework_versions() -> tuple[str | None, str | None]:
203
+ try:
204
+ return version("langgraph"), version("deepagents")
205
+ except PackageNotFoundError:
206
+ return None, None
207
+
208
+
209
+ def _validate_thread_id(thread_id: str) -> str:
210
+ if not isinstance(thread_id, str):
211
+ raise CheckpointSeedError("thread_id must be a string")
212
+ value = thread_id.strip()
213
+ if not value or len(value) > 120 or "\x00" in value:
214
+ raise CheckpointSeedError("thread_id is invalid")
215
+ return value
216
+
217
+
218
+ def _messages_from_snapshot(snapshot: CodexTextSnapshot) -> list[HumanMessage | AIMessage]:
219
+ if not snapshot.importable:
220
+ raise CheckpointSeedError("snapshot is not safe to seed")
221
+ if (
222
+ snapshot.projection_kind != PROJECTION_KIND
223
+ or snapshot.parser_version != PARSER_VERSION
224
+ ):
225
+ raise CheckpointSeedError("snapshot projection contract is unsupported")
226
+
227
+ messages: list[HumanMessage | AIMessage] = []
228
+ for source in snapshot.messages:
229
+ if source.role == "user":
230
+ messages.append(HumanMessage(content=source.text, id=source.source_id))
231
+ elif source.role == "assistant":
232
+ messages.append(AIMessage(content=source.text, id=source.source_id))
233
+ else:
234
+ raise CheckpointSeedError("snapshot contains an unsupported message role")
235
+ return _validate_messages(messages)
236
+
237
+
238
+ def _validate_messages(
239
+ messages: Sequence[HumanMessage | AIMessage],
240
+ ) -> list[HumanMessage | AIMessage]:
241
+ if not messages:
242
+ raise CheckpointSeedError("checkpoint seed requires at least one message")
243
+ normalized: list[HumanMessage | AIMessage] = []
244
+ ids: set[str] = set()
245
+ for message in messages:
246
+ if type(message) not in {HumanMessage, AIMessage}:
247
+ raise CheckpointSeedError("checkpoint seed accepts only user and assistant messages")
248
+ if not isinstance(message.id, str) or not message.id or message.id in ids:
249
+ raise CheckpointSeedError("checkpoint seed messages need unique stable IDs")
250
+ if not isinstance(message.content, str) or not message.content.strip():
251
+ raise CheckpointSeedError("checkpoint seed messages need nonempty text content")
252
+ if message.additional_kwargs or message.response_metadata:
253
+ raise CheckpointSeedError("checkpoint seed messages cannot carry metadata")
254
+ if isinstance(message, AIMessage) and (
255
+ message.tool_calls or message.invalid_tool_calls or message.usage_metadata
256
+ ):
257
+ raise CheckpointSeedError("checkpoint seed assistant messages cannot carry tool state")
258
+ ids.add(message.id)
259
+ normalized.append(message)
260
+ return normalized
261
+
262
+
263
+ def _messages_match(actual: Sequence[BaseMessage], expected: Sequence[BaseMessage]) -> bool:
264
+ if len(actual) != len(expected):
265
+ return False
266
+ return all(
267
+ type(found) is type(wanted)
268
+ and found.id == wanted.id
269
+ and found.content == wanted.content
270
+ and not found.additional_kwargs
271
+ and not found.response_metadata
272
+ and (not isinstance(found, AIMessage) or not found.tool_calls)
273
+ for found, wanted in zip(actual, expected, strict=True)
274
+ )
275
+
276
+
277
+ def _copy_config(config: dict[str, Any]) -> dict[str, Any]:
278
+ configurable = config.get("configurable")
279
+ if not isinstance(configurable, dict):
280
+ raise CheckpointSeedError("checkpoint seeding returned an invalid graph configuration")
281
+ return {"configurable": dict(configurable)}