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,458 @@
|
|
|
1
|
+
"""Backend factory for the coding agent.
|
|
2
|
+
|
|
3
|
+
No remote sandbox. Local host filesystem + shell only.
|
|
4
|
+
|
|
5
|
+
``LocalShellBackend.execute`` uses ``text=True`` without an explicit encoding, which
|
|
6
|
+
on Chinese Windows often decodes with GBK and crashes on UTF-8 tool output
|
|
7
|
+
(``UnicodeDecodeError: 'gbk' codec can't decode ...``).
|
|
8
|
+
|
|
9
|
+
This module subclasses ``LocalShellBackend`` and reimplements ``execute`` with:
|
|
10
|
+
- configurable output encoding (default UTF-8 + replace)
|
|
11
|
+
- default shell ``pwsh`` (PowerShell 7+), with cmd/bash/system overrides
|
|
12
|
+
- UTF-8-friendly env defaults for child Python processes
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from deepagents.backends import LocalShellBackend
|
|
25
|
+
from deepagents.backends.protocol import ExecuteResponse
|
|
26
|
+
|
|
27
|
+
from synapse.runtime.execute_capture import capture_execute_output
|
|
28
|
+
from synapse.runtime.tool_ignore import ToolIgnoreMatcher, relative_to_root
|
|
29
|
+
from synapse.settings import Settings
|
|
30
|
+
|
|
31
|
+
# Default shell for this project: pwsh on Windows, bash elsewhere.
|
|
32
|
+
DEFAULT_SHELL_EXECUTABLE = "pwsh" if sys.platform == "win32" else "bash"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _basename(path_or_name: str) -> str:
|
|
36
|
+
return Path(path_or_name).name.lower()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_shell_invocation(
|
|
40
|
+
command: str,
|
|
41
|
+
shell_executable: str | None,
|
|
42
|
+
) -> tuple[str | list[str], bool, str | None]:
|
|
43
|
+
"""Map configured shell name/path to subprocess ``(args, shell, executable)``.
|
|
44
|
+
|
|
45
|
+
Notes
|
|
46
|
+
-----
|
|
47
|
+
On Windows, ``shell=True`` + ``executable=pwsh`` is unreliable because Python
|
|
48
|
+
still builds a ``cmd /c ...`` command line. Known shells are therefore invoked
|
|
49
|
+
as argument lists with ``shell=False``.
|
|
50
|
+
"""
|
|
51
|
+
raw = (shell_executable or DEFAULT_SHELL_EXECUTABLE).strip()
|
|
52
|
+
key = raw.lower()
|
|
53
|
+
base = _basename(raw)
|
|
54
|
+
|
|
55
|
+
# System / cmd: classic shell=True (Windows COMSPEC / Unix /bin/sh)
|
|
56
|
+
if key in {"", "system", "default", "cmd", "cmd.exe"} or base in {"cmd", "cmd.exe"}:
|
|
57
|
+
if base in {"cmd", "cmd.exe"} and key not in {"system", "default", ""}:
|
|
58
|
+
exe = raw if Path(raw).is_file() else shutil.which(raw) or shutil.which("cmd")
|
|
59
|
+
return command, True, exe
|
|
60
|
+
return command, True, None
|
|
61
|
+
|
|
62
|
+
# PowerShell 7+ / Windows PowerShell
|
|
63
|
+
if base in {"pwsh", "pwsh.exe", "powershell", "powershell.exe"}:
|
|
64
|
+
exe = raw if Path(raw).is_file() else shutil.which(raw)
|
|
65
|
+
if exe is None and base.startswith("pwsh"):
|
|
66
|
+
# Prefer pwsh; fall back to Windows PowerShell if Core is missing.
|
|
67
|
+
exe = shutil.which("pwsh") or shutil.which("powershell")
|
|
68
|
+
if exe is None:
|
|
69
|
+
if sys.platform != "win32":
|
|
70
|
+
# Non-Windows: pwsh not available, fall back to bash.
|
|
71
|
+
exe = shutil.which("bash") or shutil.which("sh") or "bash"
|
|
72
|
+
return [exe, "-lc", command], False, None
|
|
73
|
+
exe = raw
|
|
74
|
+
return [exe, "-NoProfile", "-NonInteractive", "-Command", command], False, None
|
|
75
|
+
|
|
76
|
+
# bash / sh
|
|
77
|
+
if base in {"bash", "bash.exe", "sh", "sh.exe"}:
|
|
78
|
+
exe = raw if Path(raw).is_file() else shutil.which(raw) or raw
|
|
79
|
+
return [exe, "-lc", command], False, None
|
|
80
|
+
|
|
81
|
+
# Unknown absolute/custom binary: treat as shell program with shell=True.
|
|
82
|
+
return command, True, raw
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _kill_process_tree(proc: subprocess.Popen[Any] | None) -> None:
|
|
86
|
+
"""Kill a process and all its children (best-effort on Windows).
|
|
87
|
+
|
|
88
|
+
On Windows, ``proc.kill()`` only terminates the direct process; child
|
|
89
|
+
processes survive and hold pipe handles open, which causes
|
|
90
|
+
``communicate()`` to hang. Use ``taskkill /T`` to terminate the whole
|
|
91
|
+
process tree, then drain pipes with a short timeout.
|
|
92
|
+
"""
|
|
93
|
+
if proc is None:
|
|
94
|
+
return
|
|
95
|
+
try:
|
|
96
|
+
pid = proc.pid
|
|
97
|
+
except Exception: # noqa: BLE001
|
|
98
|
+
return
|
|
99
|
+
if pid is None:
|
|
100
|
+
return
|
|
101
|
+
if os.name == "nt":
|
|
102
|
+
try:
|
|
103
|
+
subprocess.run(
|
|
104
|
+
["taskkill", "/T", "/F", "/PID", str(pid)],
|
|
105
|
+
capture_output=True,
|
|
106
|
+
timeout=10,
|
|
107
|
+
check=False,
|
|
108
|
+
)
|
|
109
|
+
except Exception: # noqa: BLE001
|
|
110
|
+
pass
|
|
111
|
+
else:
|
|
112
|
+
try:
|
|
113
|
+
proc.kill()
|
|
114
|
+
except Exception: # noqa: BLE001
|
|
115
|
+
pass
|
|
116
|
+
# Drain pipes with a short timeout so we don't hang forever if child
|
|
117
|
+
# processes still hold them open.
|
|
118
|
+
try:
|
|
119
|
+
proc.communicate(timeout=3)
|
|
120
|
+
except Exception: # noqa: BLE001
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _timeout_msg(effective_timeout: int, per_call_timeout: int | None) -> str:
|
|
125
|
+
if per_call_timeout is not None:
|
|
126
|
+
return (
|
|
127
|
+
f"Error: Command timed out after {effective_timeout} seconds "
|
|
128
|
+
"(custom timeout). The command may be stuck or require more time."
|
|
129
|
+
)
|
|
130
|
+
return (
|
|
131
|
+
f"Error: Command timed out after {effective_timeout} seconds. "
|
|
132
|
+
"For long-running commands, re-run using the timeout parameter."
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class CodingLocalShellBackend(LocalShellBackend):
|
|
137
|
+
"""Local shell backend with explicit encoding and configurable shell."""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
*args: Any,
|
|
142
|
+
shell_executable: str | None = DEFAULT_SHELL_EXECUTABLE,
|
|
143
|
+
shell_encoding: str = "utf-8",
|
|
144
|
+
shell_encoding_errors: str = "replace",
|
|
145
|
+
deny_paths: list[str] | None = None,
|
|
146
|
+
**kwargs: Any,
|
|
147
|
+
) -> None:
|
|
148
|
+
super().__init__(*args, **kwargs)
|
|
149
|
+
self._shell_executable = (shell_executable or DEFAULT_SHELL_EXECUTABLE).strip()
|
|
150
|
+
self._shell_encoding = shell_encoding or "utf-8"
|
|
151
|
+
self._shell_encoding_errors = shell_encoding_errors or "replace"
|
|
152
|
+
|
|
153
|
+
# Prefer UTF-8 for Python children; does not fix every native CLI, but helps.
|
|
154
|
+
self._env.setdefault("PYTHONUTF8", "1")
|
|
155
|
+
self._env.setdefault("PYTHONIOENCODING", "utf-8")
|
|
156
|
+
|
|
157
|
+
if deny_paths:
|
|
158
|
+
matcher = ToolIgnoreMatcher.from_patterns(deny_paths)
|
|
159
|
+
self._tool_ignore_dedicated = True
|
|
160
|
+
else:
|
|
161
|
+
try:
|
|
162
|
+
root = Path(self.cwd).resolve()
|
|
163
|
+
matcher = ToolIgnoreMatcher.from_workspace(root)
|
|
164
|
+
except Exception: # noqa: BLE001
|
|
165
|
+
matcher = ToolIgnoreMatcher([])
|
|
166
|
+
self._tool_ignore_dedicated = False
|
|
167
|
+
self._tool_ignore: ToolIgnoreMatcher = matcher
|
|
168
|
+
|
|
169
|
+
def _ripgrep_search(
|
|
170
|
+
self,
|
|
171
|
+
pattern: str,
|
|
172
|
+
base_full: Path,
|
|
173
|
+
include_glob: str | None,
|
|
174
|
+
) -> dict[str, list[tuple[int, str]]] | None:
|
|
175
|
+
"""UTF-8-safe ripgrep search (avoids Windows GBK decode crashes).
|
|
176
|
+
|
|
177
|
+
Upstream ``FilesystemBackend._ripgrep_search`` uses ``text=True`` without
|
|
178
|
+
``encoding``, which on Chinese Windows can raise / leave ``stdout=None``
|
|
179
|
+
and then crash on ``stdout.splitlines()``.
|
|
180
|
+
"""
|
|
181
|
+
import json
|
|
182
|
+
|
|
183
|
+
import deepagents.backends.filesystem as fs
|
|
184
|
+
|
|
185
|
+
rg_path = fs._resolve_ripgrep_path()
|
|
186
|
+
if rg_path is None:
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
cmd = [rg_path, "--json", "-F"]
|
|
190
|
+
if include_glob:
|
|
191
|
+
cmd.extend(["--glob", include_glob])
|
|
192
|
+
|
|
193
|
+
rg_cwd: str | None = None
|
|
194
|
+
if base_full.is_dir():
|
|
195
|
+
cmd.extend(["--", pattern, "."])
|
|
196
|
+
rg_cwd = str(base_full)
|
|
197
|
+
else:
|
|
198
|
+
cmd.extend(["--", pattern, str(base_full)])
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
proc = subprocess.run( # noqa: S603
|
|
202
|
+
cmd,
|
|
203
|
+
capture_output=True,
|
|
204
|
+
text=True,
|
|
205
|
+
encoding=self._shell_encoding,
|
|
206
|
+
errors=self._shell_encoding_errors,
|
|
207
|
+
timeout=fs.DEFAULT_GREP_TIMEOUT,
|
|
208
|
+
check=False,
|
|
209
|
+
cwd=rg_cwd,
|
|
210
|
+
)
|
|
211
|
+
except subprocess.TimeoutExpired:
|
|
212
|
+
fs.logger.warning(
|
|
213
|
+
"ripgrep timed out after %ds; using Python grep fallback",
|
|
214
|
+
fs.DEFAULT_GREP_TIMEOUT,
|
|
215
|
+
)
|
|
216
|
+
return None
|
|
217
|
+
except (FileNotFoundError, PermissionError, NotADirectoryError, OSError) as e:
|
|
218
|
+
fs.logger.warning(
|
|
219
|
+
"ripgrep subprocess failed (%s: %s); using Python grep fallback",
|
|
220
|
+
type(e).__name__,
|
|
221
|
+
e,
|
|
222
|
+
)
|
|
223
|
+
try:
|
|
224
|
+
fs._resolve_ripgrep_path.cache_clear()
|
|
225
|
+
except Exception: # noqa: BLE001
|
|
226
|
+
pass
|
|
227
|
+
return None
|
|
228
|
+
except UnicodeDecodeError as e:
|
|
229
|
+
# Should be unreachable with errors=replace; keep fallback safety.
|
|
230
|
+
fs.logger.warning(
|
|
231
|
+
"ripgrep decode failed (%s); using Python grep fallback", e
|
|
232
|
+
)
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
if proc.returncode not in (0, 1):
|
|
236
|
+
stderr = (proc.stderr or "").strip()[:500]
|
|
237
|
+
fs.logger.warning(
|
|
238
|
+
"ripgrep exited %d (stderr=%r); using Python grep fallback",
|
|
239
|
+
proc.returncode,
|
|
240
|
+
stderr,
|
|
241
|
+
)
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
stdout = proc.stdout or ""
|
|
245
|
+
results: dict[str, list[tuple[int, str]]] = {}
|
|
246
|
+
try:
|
|
247
|
+
base_resolved = base_full.resolve()
|
|
248
|
+
except OSError:
|
|
249
|
+
base_resolved = base_full
|
|
250
|
+
|
|
251
|
+
for line in stdout.splitlines():
|
|
252
|
+
try:
|
|
253
|
+
data = json.loads(line)
|
|
254
|
+
except json.JSONDecodeError:
|
|
255
|
+
continue
|
|
256
|
+
data_type = data.get("type")
|
|
257
|
+
if data_type == "error":
|
|
258
|
+
continue
|
|
259
|
+
if data_type != "match":
|
|
260
|
+
continue
|
|
261
|
+
pdata = data.get("data", {}) or {}
|
|
262
|
+
ftext = (pdata.get("path") or {}).get("text")
|
|
263
|
+
if not ftext:
|
|
264
|
+
continue
|
|
265
|
+
raw = Path(ftext)
|
|
266
|
+
p = raw if raw.is_absolute() else (base_full / raw)
|
|
267
|
+
try:
|
|
268
|
+
p.resolve().relative_to(base_resolved)
|
|
269
|
+
except (ValueError, OSError):
|
|
270
|
+
continue
|
|
271
|
+
if self.virtual_mode:
|
|
272
|
+
try:
|
|
273
|
+
virt = self._to_virtual_path(p)
|
|
274
|
+
except (ValueError, OSError, RuntimeError):
|
|
275
|
+
continue
|
|
276
|
+
else:
|
|
277
|
+
virt = str(p)
|
|
278
|
+
ln = pdata.get("line_number")
|
|
279
|
+
lt = (pdata.get("lines") or {}).get("text", "").rstrip("\n")
|
|
280
|
+
if ln is None:
|
|
281
|
+
continue
|
|
282
|
+
results.setdefault(virt, []).append((int(ln), lt))
|
|
283
|
+
|
|
284
|
+
return results
|
|
285
|
+
|
|
286
|
+
# --- filesystem tool overrides: respect gitignore / deny paths ---
|
|
287
|
+
|
|
288
|
+
def grep(
|
|
289
|
+
self,
|
|
290
|
+
pattern: str,
|
|
291
|
+
path: str | None = None,
|
|
292
|
+
glob: str | None = None,
|
|
293
|
+
) -> Any:
|
|
294
|
+
result = super().grep(pattern, path=path, glob=glob)
|
|
295
|
+
if not self._tool_ignore.has_rules or not getattr(result, "matches", None):
|
|
296
|
+
return result
|
|
297
|
+
root = Path(self.cwd).resolve()
|
|
298
|
+
kept = [
|
|
299
|
+
m
|
|
300
|
+
for m in result.matches
|
|
301
|
+
if not self._tool_ignore.is_ignored(
|
|
302
|
+
relative_to_root(m["path"], root)
|
|
303
|
+
)
|
|
304
|
+
]
|
|
305
|
+
result.matches = kept
|
|
306
|
+
return result
|
|
307
|
+
|
|
308
|
+
def glob(self, pattern: str, path: str | None = None) -> Any:
|
|
309
|
+
result = super().glob(pattern, path=path)
|
|
310
|
+
if not self._tool_ignore.has_rules or not getattr(result, "matches", None):
|
|
311
|
+
return result
|
|
312
|
+
root = Path(self.cwd).resolve()
|
|
313
|
+
kept = [
|
|
314
|
+
f
|
|
315
|
+
for f in result.matches
|
|
316
|
+
if not self._tool_ignore.is_ignored(
|
|
317
|
+
relative_to_root(f["path"], root)
|
|
318
|
+
)
|
|
319
|
+
]
|
|
320
|
+
result.matches = kept
|
|
321
|
+
return result
|
|
322
|
+
|
|
323
|
+
def execute(
|
|
324
|
+
self,
|
|
325
|
+
command: str,
|
|
326
|
+
*,
|
|
327
|
+
timeout: int | None = None,
|
|
328
|
+
) -> ExecuteResponse:
|
|
329
|
+
"""Run a host shell command with safe text decoding.
|
|
330
|
+
|
|
331
|
+
Same contract as ``LocalShellBackend.execute``, but never relies on the
|
|
332
|
+
process locale (e.g. GBK) for stdout/stderr decoding.
|
|
333
|
+
"""
|
|
334
|
+
if not command or not isinstance(command, str):
|
|
335
|
+
return ExecuteResponse(
|
|
336
|
+
output="Error: Command must be a non-empty string.",
|
|
337
|
+
exit_code=1,
|
|
338
|
+
truncated=False,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
effective_timeout = timeout if timeout is not None else self._default_timeout
|
|
342
|
+
if effective_timeout <= 0:
|
|
343
|
+
msg = f"timeout must be positive, got {effective_timeout}"
|
|
344
|
+
raise ValueError(msg)
|
|
345
|
+
|
|
346
|
+
args, use_shell, executable = resolve_shell_invocation(
|
|
347
|
+
command, self._shell_executable
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
run_kwargs: dict[str, Any] = {
|
|
351
|
+
"args": args,
|
|
352
|
+
"shell": use_shell,
|
|
353
|
+
"stdout": subprocess.PIPE,
|
|
354
|
+
"stderr": subprocess.PIPE,
|
|
355
|
+
"stdin": subprocess.DEVNULL,
|
|
356
|
+
"text": True,
|
|
357
|
+
"encoding": self._shell_encoding,
|
|
358
|
+
"errors": self._shell_encoding_errors,
|
|
359
|
+
"env": self._env,
|
|
360
|
+
"cwd": str(self.cwd),
|
|
361
|
+
}
|
|
362
|
+
if executable:
|
|
363
|
+
run_kwargs["executable"] = executable
|
|
364
|
+
|
|
365
|
+
proc = None
|
|
366
|
+
try:
|
|
367
|
+
# Use Popen directly instead of subprocess.run because run()
|
|
368
|
+
# calls communicate() a second time after TimeoutExpired, which hangs
|
|
369
|
+
# on Windows when the shell spawns child processes that survive the
|
|
370
|
+
# kill and hold pipe handles open.
|
|
371
|
+
proc = subprocess.Popen(**run_kwargs) # noqa: S602
|
|
372
|
+
stdout, stderr = proc.communicate(timeout=effective_timeout)
|
|
373
|
+
returncode = proc.returncode
|
|
374
|
+
|
|
375
|
+
except subprocess.TimeoutExpired:
|
|
376
|
+
_kill_process_tree(proc)
|
|
377
|
+
msg = _timeout_msg(effective_timeout, timeout)
|
|
378
|
+
return ExecuteResponse(
|
|
379
|
+
output=msg,
|
|
380
|
+
exit_code=124,
|
|
381
|
+
truncated=False,
|
|
382
|
+
)
|
|
383
|
+
except Exception as e: # noqa: BLE001
|
|
384
|
+
if proc is not None:
|
|
385
|
+
try:
|
|
386
|
+
proc.kill()
|
|
387
|
+
except Exception: # noqa: BLE001
|
|
388
|
+
pass
|
|
389
|
+
return ExecuteResponse(
|
|
390
|
+
output=f"Error executing command ({type(e).__name__}): {e}",
|
|
391
|
+
exit_code=1,
|
|
392
|
+
truncated=False,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
output_parts: list[str] = []
|
|
396
|
+
if stdout:
|
|
397
|
+
output_parts.append(stdout)
|
|
398
|
+
if stderr:
|
|
399
|
+
stderr_lines = stderr.strip().split("\n")
|
|
400
|
+
output_parts.extend(f"[stderr] {line}" for line in stderr_lines)
|
|
401
|
+
|
|
402
|
+
full_output = "\n".join(output_parts) if output_parts else "<no output>"
|
|
403
|
+
|
|
404
|
+
truncated = False
|
|
405
|
+
output = full_output
|
|
406
|
+
if len(output) > self._max_output_bytes:
|
|
407
|
+
output = output[: self._max_output_bytes]
|
|
408
|
+
output += f"\n\n... Output truncated at {self._max_output_bytes} bytes."
|
|
409
|
+
truncated = True
|
|
410
|
+
|
|
411
|
+
if returncode != 0:
|
|
412
|
+
full_output = f"{full_output.rstrip()}\n\nExit code: {returncode}"
|
|
413
|
+
output = f"{output.rstrip()}\n\nExit code: {returncode}"
|
|
414
|
+
|
|
415
|
+
capture_execute_output(
|
|
416
|
+
full_output=full_output,
|
|
417
|
+
displayed_output=output,
|
|
418
|
+
truncated=truncated,
|
|
419
|
+
)
|
|
420
|
+
return ExecuteResponse(
|
|
421
|
+
output=output,
|
|
422
|
+
exit_code=returncode,
|
|
423
|
+
truncated=truncated,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def build_backend(settings: Settings) -> CodingLocalShellBackend:
|
|
428
|
+
"""Create a coding shell backend rooted at the workspace.
|
|
429
|
+
|
|
430
|
+
Notes
|
|
431
|
+
-----
|
|
432
|
+
- No sandbox isolation (by design for this project).
|
|
433
|
+
- ``inherit_env=True`` so host tools (python/uv/git/node) remain available.
|
|
434
|
+
- ``virtual_mode=True`` improves path semantics for file tools (shell is still unrestricted).
|
|
435
|
+
- Default shell is ``pwsh``; override with ``SHELL_EXECUTABLE`` (cmd/bash/path).
|
|
436
|
+
- Shell output is decoded with ``shell_encoding`` (default UTF-8) to avoid GBK crashes.
|
|
437
|
+
"""
|
|
438
|
+
root = Path(settings.workspace).resolve()
|
|
439
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
440
|
+
|
|
441
|
+
executable = settings.shell_executable
|
|
442
|
+
if executable is not None:
|
|
443
|
+
executable = executable.strip() or None
|
|
444
|
+
if not executable:
|
|
445
|
+
executable = DEFAULT_SHELL_EXECUTABLE
|
|
446
|
+
|
|
447
|
+
return CodingLocalShellBackend(
|
|
448
|
+
root_dir=root,
|
|
449
|
+
virtual_mode=settings.virtual_mode,
|
|
450
|
+
timeout=settings.shell_timeout,
|
|
451
|
+
max_output_bytes=settings.max_output_bytes,
|
|
452
|
+
inherit_env=settings.inherit_env,
|
|
453
|
+
env=None,
|
|
454
|
+
shell_executable=executable,
|
|
455
|
+
shell_encoding=settings.shell_encoding,
|
|
456
|
+
shell_encoding_errors=settings.shell_encoding_errors,
|
|
457
|
+
deny_paths=settings.deny_fs_paths or None,
|
|
458
|
+
)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Context compaction helpers (deepagents SummarizationToolMiddleware).
|
|
2
|
+
|
|
3
|
+
create_deep_agent already installs automatic SummarizationMiddleware.
|
|
4
|
+
This module adds the manual ``compact_conversation`` tool layer and product
|
|
5
|
+
helpers for /compact + observability.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_compact_tool_middleware(model: Any, backend: Any) -> Any:
|
|
14
|
+
"""Return SummarizationToolMiddleware (manual compact_conversation tool)."""
|
|
15
|
+
from deepagents.middleware.summarization import create_summarization_tool_middleware
|
|
16
|
+
|
|
17
|
+
return create_summarization_tool_middleware(model, backend)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def extract_summarization_event(state: Any) -> dict[str, Any] | None:
|
|
21
|
+
"""Best-effort pull of ``_summarization_event`` from graph state/update."""
|
|
22
|
+
if state is None:
|
|
23
|
+
return None
|
|
24
|
+
if isinstance(state, dict):
|
|
25
|
+
event = state.get("_summarization_event")
|
|
26
|
+
if isinstance(event, dict):
|
|
27
|
+
return event
|
|
28
|
+
values = state.get("values")
|
|
29
|
+
if isinstance(values, dict):
|
|
30
|
+
event = values.get("_summarization_event")
|
|
31
|
+
if isinstance(event, dict):
|
|
32
|
+
return event
|
|
33
|
+
return None
|
|
34
|
+
values = getattr(state, "values", None)
|
|
35
|
+
if isinstance(values, dict):
|
|
36
|
+
event = values.get("_summarization_event")
|
|
37
|
+
if isinstance(event, dict):
|
|
38
|
+
return event
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def format_summarization_event(event: dict[str, Any] | None) -> str | None:
|
|
43
|
+
"""One-line UI notice for a compaction event."""
|
|
44
|
+
if not event:
|
|
45
|
+
return None
|
|
46
|
+
# Event shape varies; keep display defensive.
|
|
47
|
+
cutoff = event.get("cutoff") or event.get("cutoff_index")
|
|
48
|
+
path = event.get("file_path") or event.get("path") or event.get("history_path")
|
|
49
|
+
summary = event.get("summary")
|
|
50
|
+
bits: list[str] = ["context compacted"]
|
|
51
|
+
if cutoff is not None:
|
|
52
|
+
bits.append(f"cutoff={cutoff}")
|
|
53
|
+
if path:
|
|
54
|
+
bits.append(f"offload={path}")
|
|
55
|
+
if isinstance(summary, str) and summary.strip():
|
|
56
|
+
one = " ".join(summary.strip().split())
|
|
57
|
+
if len(one) > 80:
|
|
58
|
+
one = one[:79] + "…"
|
|
59
|
+
bits.append(f"summary={one}")
|
|
60
|
+
return " | ".join(bits)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def force_compact_via_agent(
|
|
64
|
+
agent: Any,
|
|
65
|
+
*,
|
|
66
|
+
thread_id: str,
|
|
67
|
+
config: dict[str, Any] | None = None,
|
|
68
|
+
) -> tuple[bool, list[str]]:
|
|
69
|
+
"""Ask the agent to call ``compact_conversation`` now.
|
|
70
|
+
|
|
71
|
+
Returns (ok, status_lines). The tool may refuse when under its eligibility
|
|
72
|
+
gate (~50% of auto-summarization trigger).
|
|
73
|
+
"""
|
|
74
|
+
if agent is None or not thread_id:
|
|
75
|
+
return False, ["compact failed: missing agent/thread_id"]
|
|
76
|
+
|
|
77
|
+
run_config = dict(config or {})
|
|
78
|
+
cfg = dict(run_config.get("configurable") or {})
|
|
79
|
+
cfg["thread_id"] = thread_id
|
|
80
|
+
run_config["configurable"] = cfg
|
|
81
|
+
|
|
82
|
+
prompt = (
|
|
83
|
+
"System instruction for this turn only: call the `compact_conversation` "
|
|
84
|
+
"tool immediately to compact the conversation context. Do not call other "
|
|
85
|
+
"tools. After the tool returns, reply with one short status line only "
|
|
86
|
+
"(success, refused/not eligible, or error)."
|
|
87
|
+
)
|
|
88
|
+
payload = {"messages": [{"role": "user", "content": prompt}]}
|
|
89
|
+
try:
|
|
90
|
+
ainvoke = getattr(agent, "ainvoke", None)
|
|
91
|
+
runtime = getattr(agent, "_coding_async_runtime", None)
|
|
92
|
+
if callable(ainvoke) and runtime is not None:
|
|
93
|
+
result = runtime.run(ainvoke(payload, run_config))
|
|
94
|
+
else:
|
|
95
|
+
result = agent.invoke(payload, run_config)
|
|
96
|
+
except Exception as exc: # noqa: BLE001
|
|
97
|
+
return False, [f"compact failed: {exc}"]
|
|
98
|
+
|
|
99
|
+
lines: list[str] = []
|
|
100
|
+
event = extract_summarization_event(result)
|
|
101
|
+
note = format_summarization_event(event)
|
|
102
|
+
if note:
|
|
103
|
+
lines.append(note)
|
|
104
|
+
|
|
105
|
+
# Pull last AI / tool text for user feedback.
|
|
106
|
+
messages = []
|
|
107
|
+
if isinstance(result, dict):
|
|
108
|
+
messages = list(result.get("messages") or [])
|
|
109
|
+
text_bits: list[str] = []
|
|
110
|
+
for msg in reversed(messages[-8:]):
|
|
111
|
+
role = getattr(msg, "type", None) or getattr(msg, "role", None) or ""
|
|
112
|
+
name = getattr(msg, "name", None) or ""
|
|
113
|
+
content = getattr(msg, "content", None)
|
|
114
|
+
if content is None:
|
|
115
|
+
continue
|
|
116
|
+
body = content if isinstance(content, str) else str(content)
|
|
117
|
+
body = body.strip()
|
|
118
|
+
if not body:
|
|
119
|
+
continue
|
|
120
|
+
if str(role).lower() in {"tool", "toolmessage"} or name == "compact_conversation":
|
|
121
|
+
text_bits.append(body[:300])
|
|
122
|
+
break
|
|
123
|
+
if str(role).lower() in {"ai", "assistant"}:
|
|
124
|
+
text_bits.append(body[:300])
|
|
125
|
+
break
|
|
126
|
+
if text_bits:
|
|
127
|
+
lines.append(text_bits[0])
|
|
128
|
+
if not lines:
|
|
129
|
+
lines.append("compact requested (no status detail)")
|
|
130
|
+
ok = any(
|
|
131
|
+
"compact" in (ln or "").casefold()
|
|
132
|
+
or "summar" in (ln or "").casefold()
|
|
133
|
+
or "nothing to compact" in (ln or "").casefold()
|
|
134
|
+
or "not eligible" in (ln or "").casefold()
|
|
135
|
+
or "success" in (ln or "").casefold()
|
|
136
|
+
for ln in lines
|
|
137
|
+
)
|
|
138
|
+
return True if lines else ok, lines
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def context_status_lines(agent: Any, thread_id: str) -> list[str]:
|
|
142
|
+
"""Report message count + last summarization event for current thread."""
|
|
143
|
+
lines: list[str] = [f"thread_id={thread_id}"]
|
|
144
|
+
if agent is None:
|
|
145
|
+
return lines + ["agent: none"]
|
|
146
|
+
get_state = getattr(agent, "get_state", None)
|
|
147
|
+
if not callable(get_state):
|
|
148
|
+
return lines + ["state: unavailable"]
|
|
149
|
+
try:
|
|
150
|
+
state = get_state({"configurable": {"thread_id": thread_id}})
|
|
151
|
+
except Exception as exc: # noqa: BLE001
|
|
152
|
+
return lines + [f"state error: {exc}"]
|
|
153
|
+
values = getattr(state, "values", None) or {}
|
|
154
|
+
messages = values.get("messages") if isinstance(values, dict) else None
|
|
155
|
+
n = len(messages or [])
|
|
156
|
+
lines.append(f"messages={n}")
|
|
157
|
+
event = extract_summarization_event(state)
|
|
158
|
+
note = format_summarization_event(event)
|
|
159
|
+
if note:
|
|
160
|
+
lines.append(note)
|
|
161
|
+
else:
|
|
162
|
+
lines.append("summarization: none yet (auto middleware still active)")
|
|
163
|
+
return lines
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# --- UI filters: hide SESSION INTENT / SUMMARY compaction text from timeline ---
|
|
167
|
+
|
|
168
|
+
_SUMMARY_MARKERS = (
|
|
169
|
+
"## SESSION INTENT",
|
|
170
|
+
"SESSION INTENT",
|
|
171
|
+
"## SUMMARY",
|
|
172
|
+
)
|
|
173
|
+
_WRAPPER_PREFIXES = (
|
|
174
|
+
"Here is a summary of the conversation to date:",
|
|
175
|
+
"You are in the middle of a conversation that has been summarized",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def is_lc_summarization_message(msg: Any) -> bool:
|
|
180
|
+
"""True when message was tagged as summarization middleware output."""
|
|
181
|
+
ak = getattr(msg, "additional_kwargs", None)
|
|
182
|
+
if isinstance(ak, dict) and ak.get("lc_source") == "summarization":
|
|
183
|
+
return True
|
|
184
|
+
if isinstance(msg, dict):
|
|
185
|
+
ak2 = msg.get("additional_kwargs") or {}
|
|
186
|
+
if isinstance(ak2, dict) and ak2.get("lc_source") == "summarization":
|
|
187
|
+
return True
|
|
188
|
+
md = msg.get("response_metadata") or msg.get("metadata") or {}
|
|
189
|
+
if isinstance(md, dict) and md.get("lc_source") == "summarization":
|
|
190
|
+
return True
|
|
191
|
+
md2 = getattr(msg, "response_metadata", None)
|
|
192
|
+
if isinstance(md2, dict) and md2.get("lc_source") == "summarization":
|
|
193
|
+
return True
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def is_stream_meta_summarization(meta: Any) -> bool:
|
|
198
|
+
"""True when LangGraph messages-stream meta marks a summarization invoke."""
|
|
199
|
+
if not isinstance(meta, dict):
|
|
200
|
+
return False
|
|
201
|
+
for key in ("lc_source", "source"):
|
|
202
|
+
if meta.get(key) == "summarization":
|
|
203
|
+
return True
|
|
204
|
+
nested = meta.get("metadata") or meta.get("ls_metadata") or {}
|
|
205
|
+
if isinstance(nested, dict) and nested.get("lc_source") == "summarization":
|
|
206
|
+
return True
|
|
207
|
+
tags = meta.get("tags") or (
|
|
208
|
+
nested.get("tags") if isinstance(nested, dict) else None
|
|
209
|
+
)
|
|
210
|
+
if isinstance(tags, (list, tuple, set)):
|
|
211
|
+
if "summarization" in tags or "lc:summarization" in tags:
|
|
212
|
+
return True
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def is_context_compact_text(text: str | None) -> bool:
|
|
217
|
+
"""Heuristic: body looks like a context-compaction summary, not a user reply."""
|
|
218
|
+
body = (text or "").strip()
|
|
219
|
+
if not body:
|
|
220
|
+
return False
|
|
221
|
+
head = body[:800]
|
|
222
|
+
for p in _WRAPPER_PREFIXES:
|
|
223
|
+
if body.startswith(p) or p in head:
|
|
224
|
+
if any(
|
|
225
|
+
m in body
|
|
226
|
+
for m in ("SESSION INTENT", "SUMMARY", "ARTIFACTS", "NEXT STEPS")
|
|
227
|
+
):
|
|
228
|
+
return True
|
|
229
|
+
if body.startswith(p):
|
|
230
|
+
return True
|
|
231
|
+
|
|
232
|
+
# DEFAULT_SUMMARY_PROMPT sections (with or without markdown ##).
|
|
233
|
+
has_intent = (
|
|
234
|
+
"## SESSION INTENT" in body
|
|
235
|
+
or body.lstrip("# ").startswith("SESSION INTENT")
|
|
236
|
+
or "\nSESSION INTENT\n" in f"\n{body}\n"
|
|
237
|
+
)
|
|
238
|
+
has_summary = (
|
|
239
|
+
"## SUMMARY" in body
|
|
240
|
+
or "\n## SUMMARY" in body
|
|
241
|
+
or "\nSUMMARY\n" in f"\n{body}\n"
|
|
242
|
+
or "\n# SUMMARY\n" in f"\n{body}\n"
|
|
243
|
+
)
|
|
244
|
+
if has_intent and has_summary:
|
|
245
|
+
return True
|
|
246
|
+
if has_intent and ("ARTIFACTS" in body or "NEXT STEPS" in body):
|
|
247
|
+
return True
|
|
248
|
+
return False
|
|
249
|
+
|