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