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