zjcode 0.0.1__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 (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,474 @@
1
+ """Protect machine-managed memory blocks from agent edits.
2
+
3
+ The onboarding flow writes the user's preferred name into the user `AGENTS.md`
4
+ inside a marker-delimited block (see `onboarding.ONBOARDING_NAME_MEMORY_START` /
5
+ `ONBOARDING_NAME_MEMORY_END`). `MemoryMiddleware` strips HTML comments before
6
+ injecting memory, so the model never sees those markers and has no way to know
7
+ the region is off-limits. Since the same prompt tells the model to `edit_file`
8
+ that file to persist learnings, nothing stops it from rewriting the managed
9
+ block.
10
+
11
+ This middleware intercepts `write_file`/`edit_file` calls targeting the guarded
12
+ file(s), and `delete` calls that would remove them. When a write or edit would
13
+ change or remove the managed block, the model's other edits are kept (though
14
+ surrounding whitespace may be normalized, and a fully removed block is
15
+ re-appended rather than restored in place) while the managed block is restored,
16
+ and an error is returned so the model learns the region is machine-managed. A
17
+ `delete` call that would remove an existing managed block is rejected before the
18
+ tool runs; a `delete` of a guarded file that exists but cannot be read is also
19
+ rejected, failing closed rather than removing a file we cannot inspect. When the
20
+ block was altered but the restore could not be completed, an error is still
21
+ returned so the failure is never silent.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import logging
28
+ import os
29
+ from difflib import SequenceMatcher
30
+ from enum import Enum
31
+ from pathlib import Path
32
+ from typing import TYPE_CHECKING, Any
33
+
34
+ from langchain.agents.middleware.types import AgentMiddleware
35
+ from langchain_core.messages import ToolMessage
36
+
37
+ from deepagents_code.onboarding import (
38
+ _upsert_onboarding_name_memory,
39
+ extract_onboarding_name_block,
40
+ strip_onboarding_name_markers,
41
+ )
42
+
43
+ if TYPE_CHECKING:
44
+ from collections.abc import Awaitable, Callable, Iterable
45
+
46
+ from langgraph.prebuilt.tool_node import ToolCallRequest
47
+ from langgraph.types import Command
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+ _GUARDED_TOOLS: frozenset[str] = frozenset({"write_file", "edit_file", "delete"})
52
+ """Tool names whose calls can mutate a guarded file and must be inspected."""
53
+
54
+ _REJECTION_MESSAGE = (
55
+ "The region between the `deepagents:onboarding-name:start` and "
56
+ "`deepagents:onboarding-name:end` markers in {path} is machine-managed and "
57
+ "must not be edited. Your other changes to the file were kept, but the "
58
+ "managed block was restored to its previous content. Do not modify content "
59
+ "between those markers."
60
+ )
61
+ """Error returned when a managed-block edit was reverted (`{path}` formatted in)."""
62
+
63
+ _RESTORE_FAILED_MESSAGE = (
64
+ "The region between the `deepagents:onboarding-name:start` and "
65
+ "`deepagents:onboarding-name:end` markers in {path} is machine-managed and "
66
+ "must not be edited. Your edit changed it and the previous content could "
67
+ "not be restored, so the managed block may now be corrupted. Do not modify "
68
+ "content between those markers, and do not rely on this edit having "
69
+ "succeeded."
70
+ )
71
+ """Error returned when a managed-block edit could not be reverted."""
72
+
73
+ _DELETE_REJECTION_MESSAGE = (
74
+ "The guarded memory file {path} contains a machine-managed region between "
75
+ "the `deepagents:onboarding-name:start` and `deepagents:onboarding-name:end` "
76
+ "markers and must not be deleted. Do not delete this file or a parent "
77
+ "directory that contains it."
78
+ )
79
+ """Error returned when a delete would remove a managed memory block."""
80
+
81
+
82
+ class _RestoreOutcome(Enum):
83
+ """Result of attempting to restore a managed block after a tool call."""
84
+
85
+ UNCHANGED = "unchanged"
86
+ """The managed block was not altered; nothing to restore."""
87
+
88
+ RESTORED = "restored"
89
+ """The managed block was altered and successfully restored."""
90
+
91
+ FAILED = "failed"
92
+ """The managed block was altered but could not be restored."""
93
+
94
+
95
+ class ManagedMemoryGuardMiddleware(AgentMiddleware):
96
+ """Revert agent edits to the managed onboarding-name memory block.
97
+
98
+ Guards the managed onboarding-name block in a fixed set of memory files. A
99
+ `write_file`/`edit_file` that leaves the managed block untouched passes
100
+ through; one that alters or drops it has the block restored (other edits
101
+ kept) and returns an error. A `delete` targeting a guarded file (or a parent
102
+ directory that contains one) is rejected outright before the tool runs when
103
+ the file holds a managed block or exists but cannot be read. If the restore
104
+ itself fails, an error is still returned so the failure is never silent.
105
+ """
106
+
107
+ def __init__(self, guarded_paths: Iterable[str | Path]) -> None:
108
+ """Initialize the guard with the memory files to protect.
109
+
110
+ Args:
111
+ guarded_paths: Paths whose managed onboarding-name block must be
112
+ protected from agent edits. Resolved to absolute form for
113
+ matching; unresolvable entries are skipped.
114
+ """
115
+ super().__init__()
116
+ requested = list(guarded_paths)
117
+ resolved: set[Path] = set()
118
+ for raw in requested:
119
+ try:
120
+ resolved.add(Path(raw).expanduser().resolve())
121
+ except (OSError, RuntimeError, ValueError):
122
+ logger.warning(
123
+ "Could not resolve guarded memory path %r", raw, exc_info=True
124
+ )
125
+ self._guarded: frozenset[Path] = frozenset(resolved)
126
+ if requested and not self._guarded:
127
+ # Every configured path failed to resolve, so this guard now
128
+ # protects nothing. That nullifies an integrity control, so surface
129
+ # it loudly rather than letting protection silently disappear.
130
+ logger.error(
131
+ "ManagedMemoryGuardMiddleware resolved no guarded paths from %r; "
132
+ "managed memory-block protection is disabled",
133
+ requested,
134
+ )
135
+
136
+ def _guarded_path(self, request: ToolCallRequest) -> Path | None:
137
+ """Return the resolved guarded path targeted by the call, if any.
138
+
139
+ Returns:
140
+ The matching guarded `Path`, or `None` when the call is unrelated.
141
+ """
142
+ tool_name = request.tool_call["name"]
143
+ if tool_name not in _GUARDED_TOOLS:
144
+ return None
145
+ args = request.tool_call.get("args") or {}
146
+ file_path = args.get("file_path")
147
+ if not isinstance(file_path, str) or not file_path:
148
+ return None
149
+ try:
150
+ resolved = Path(file_path).expanduser().resolve()
151
+ except (OSError, RuntimeError, ValueError):
152
+ # A guarded-tool call whose path won't resolve could be an attempt
153
+ # to slip past the set-membership match, so leave a trail.
154
+ logger.warning(
155
+ "Could not resolve target path %r for %s",
156
+ file_path,
157
+ tool_name,
158
+ exc_info=True,
159
+ )
160
+ return None
161
+ if tool_name == "delete":
162
+ # `is_relative_to` is True when the guarded file is the delete
163
+ # target itself or lives under a directory being deleted.
164
+ for guarded in self._guarded:
165
+ if guarded.is_relative_to(resolved):
166
+ return guarded
167
+ return None
168
+ return resolved if resolved in self._guarded else None
169
+
170
+ @staticmethod
171
+ def _read(path: Path) -> str | None:
172
+ """Read `path` as UTF-8, returning `None` on failure.
173
+
174
+ Returns:
175
+ File content, or `None` when the file is missing or unreadable.
176
+ """
177
+ try:
178
+ fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
179
+ with os.fdopen(fd, "r", encoding="utf-8") as f:
180
+ return f.read()
181
+ except FileNotFoundError:
182
+ # Expected when the guarded file has not been created yet.
183
+ return None
184
+ except (OSError, UnicodeDecodeError):
185
+ # An existing-but-unreadable guarded file would otherwise silently
186
+ # disable protection for this call, so make it visible.
187
+ logger.warning("Could not read guarded memory file %s", path, exc_info=True)
188
+ return None
189
+
190
+ @staticmethod
191
+ def _write(path: Path, content: str) -> None:
192
+ """Write `content` to `path` without following symlinks."""
193
+ flags = os.O_WRONLY | os.O_TRUNC
194
+ if hasattr(os, "O_NOFOLLOW"):
195
+ flags |= os.O_NOFOLLOW
196
+ fd = os.open(path, flags)
197
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
198
+ f.write(content)
199
+
200
+ @staticmethod
201
+ def _line_range_for_block(before: str, before_block: str) -> tuple[int, int] | None:
202
+ """Return the line range occupied by `before_block` in `before`.
203
+
204
+ Returns:
205
+ A `(start, end)` line range, or `None` when the block is absent.
206
+ """
207
+ block_start = before.find(before_block)
208
+ if block_start == -1:
209
+ return None
210
+ block_end = block_start + len(before_block)
211
+ start_line: int | None = None
212
+ end_line: int | None = None
213
+ offset = 0
214
+ for line_number, line in enumerate(before.splitlines(keepends=True)):
215
+ line_end = offset + len(line)
216
+ if start_line is None and offset <= block_start < line_end:
217
+ start_line = line_number
218
+ if offset < block_end <= line_end:
219
+ end_line = line_number + 1
220
+ break
221
+ offset = line_end
222
+ if start_line is None or end_line is None:
223
+ return None
224
+ return start_line, end_line
225
+
226
+ @staticmethod
227
+ def _without_managed_block_edits(
228
+ before: str, after: str, before_block: str
229
+ ) -> str | None:
230
+ """Remove post-edit lines that originated from the managed block.
231
+
232
+ Returns:
233
+ `after` with lines mapped from `before_block` removed, or `None` when
234
+ the old block cannot be located in `before`.
235
+ """
236
+ block_range = ManagedMemoryGuardMiddleware._line_range_for_block(
237
+ before, before_block
238
+ )
239
+ if block_range is None:
240
+ return None
241
+ block_start, block_end = block_range
242
+ # Use line-level matching so a damaged marker cannot leave the old
243
+ # managed memory body behind as regular user-editable memory.
244
+ before_lines = before.splitlines(keepends=True)
245
+ after_lines = after.splitlines(keepends=True)
246
+ ranges: list[tuple[int, int]] = []
247
+ matcher = SequenceMatcher(None, before_lines, after_lines, autojunk=False)
248
+ for (
249
+ tag,
250
+ before_start,
251
+ before_end,
252
+ after_start,
253
+ after_end,
254
+ ) in matcher.get_opcodes():
255
+ overlaps = before_start < block_end and block_start < before_end
256
+ if tag == "insert":
257
+ if block_start < before_start < block_end:
258
+ ranges.append((after_start, after_end))
259
+ continue
260
+ if not overlaps or tag == "delete":
261
+ continue
262
+ if tag == "equal":
263
+ start = max(before_start, block_start)
264
+ end = min(before_end, block_end)
265
+ ranges.append(
266
+ (
267
+ after_start + start - before_start,
268
+ after_start + end - before_start,
269
+ )
270
+ )
271
+ else:
272
+ ranges.append((after_start, after_end))
273
+
274
+ if not ranges:
275
+ return after
276
+ parts: list[str] = []
277
+ cursor = 0
278
+ for start, end in sorted(ranges):
279
+ range_start = start
280
+ range_end = end
281
+ if range_start < cursor:
282
+ range_end = max(range_end, cursor)
283
+ range_start = cursor
284
+ parts.extend(after_lines[cursor:range_start])
285
+ cursor = range_end
286
+ parts.extend(after_lines[cursor:])
287
+ return "".join(parts)
288
+
289
+ def _restore(self, path: Path, before: str, before_block: str) -> _RestoreOutcome:
290
+ """Re-apply `before_block` into `path`, preserving other edits.
291
+
292
+ The restored content is verified before it is written, so a malformed
293
+ re-insertion (for example from a partially deleted block) is reported as
294
+ a failure instead of being persisted.
295
+
296
+ Returns:
297
+ `UNCHANGED` when the block was untouched, `RESTORED` when it was
298
+ altered and successfully restored, or `FAILED` when it was
299
+ altered but could not be restored.
300
+ """
301
+ after = self._read(path)
302
+ if after is None:
303
+ # The file vanished or became unreadable after the edit, so the
304
+ # block cannot be restored. Treat as a failure rather than passing
305
+ # the clobbering edit through as a success.
306
+ logger.warning(
307
+ "Guarded memory file %s is unreadable after edit; "
308
+ "cannot restore managed block",
309
+ path,
310
+ )
311
+ return _RestoreOutcome.FAILED
312
+ block_after = extract_onboarding_name_block(after)
313
+ if block_after == before_block:
314
+ return _RestoreOutcome.UNCHANGED
315
+ if block_after is not None:
316
+ source = after
317
+ else:
318
+ source = self._without_managed_block_edits(before, after, before_block)
319
+ if source is None:
320
+ logger.error(
321
+ "Could not locate previous managed block in %s; leaving the "
322
+ "edited file untouched",
323
+ path,
324
+ )
325
+ return _RestoreOutcome.FAILED
326
+ source = strip_onboarding_name_markers(source)
327
+ restored = _upsert_onboarding_name_memory(source, before_block)
328
+ if extract_onboarding_name_block(restored) != before_block:
329
+ logger.error(
330
+ "Restored content for %s did not reproduce the managed block; "
331
+ "leaving the edited file untouched",
332
+ path,
333
+ )
334
+ return _RestoreOutcome.FAILED
335
+ try:
336
+ self._write(path, restored)
337
+ except (OSError, UnicodeEncodeError):
338
+ logger.warning(
339
+ "Could not restore managed memory block at %s", path, exc_info=True
340
+ )
341
+ return _RestoreOutcome.FAILED
342
+ return _RestoreOutcome.RESTORED
343
+
344
+ @staticmethod
345
+ def _error(
346
+ request: ToolCallRequest, path: Path, *, restore_failed: bool
347
+ ) -> ToolMessage:
348
+ """Build the error result returned after a managed-block edit.
349
+
350
+ Returns:
351
+ An error-status `ToolMessage` explaining the managed region.
352
+ """
353
+ template = _RESTORE_FAILED_MESSAGE if restore_failed else _REJECTION_MESSAGE
354
+ return ToolMessage(
355
+ content=template.format(path=path),
356
+ name=request.tool_call["name"],
357
+ tool_call_id=request.tool_call["id"],
358
+ status="error",
359
+ )
360
+
361
+ @staticmethod
362
+ def _reject_delete(path: Path, before: str | None) -> bool:
363
+ """Return whether a delete targeting a guarded path must be rejected.
364
+
365
+ The caller has already matched `path` as a guarded file (or a file
366
+ inside a directory being deleted). The delete is rejected when:
367
+
368
+ - the guarded file currently holds a managed block, or
369
+ - the guarded file exists but its content could not be read.
370
+
371
+ The second case fails closed on purpose: `_read` returns `None` for
372
+ both a missing file and an existing-but-unreadable one (a permission
373
+ error, or a symlink swap caught by `O_NOFOLLOW`). A missing guarded
374
+ file has nothing to protect and is safe to remove, but an existing
375
+ file we cannot inspect must not be deleted irreversibly on the
376
+ assumption that it lacks a managed block.
377
+
378
+ Returns:
379
+ `True` when the delete must be blocked, `False` when it may proceed.
380
+ """
381
+ if before is not None:
382
+ return extract_onboarding_name_block(before) is not None
383
+ return path.exists()
384
+
385
+ @staticmethod
386
+ def _delete_error(request: ToolCallRequest, path: Path) -> ToolMessage:
387
+ """Build the error result returned when a delete would remove memory.
388
+
389
+ Returns:
390
+ An error-status `ToolMessage` explaining the protected file.
391
+ """
392
+ return ToolMessage(
393
+ content=_DELETE_REJECTION_MESSAGE.format(path=path),
394
+ name=request.tool_call["name"],
395
+ tool_call_id=request.tool_call["id"],
396
+ status="error",
397
+ )
398
+
399
+ def _result_after_restore(
400
+ self,
401
+ request: ToolCallRequest,
402
+ path: Path,
403
+ before: str,
404
+ before_block: str,
405
+ result: ToolMessage | Command[Any],
406
+ ) -> ToolMessage | Command[Any]:
407
+ """Restore the managed block and pick the result to return.
408
+
409
+ Returns:
410
+ The original `result` when the block was untouched, otherwise an
411
+ error `ToolMessage` describing the restore.
412
+ """
413
+ outcome = self._restore(path, before, before_block)
414
+ if outcome is _RestoreOutcome.UNCHANGED:
415
+ return result
416
+ return self._error(
417
+ request, path, restore_failed=outcome is _RestoreOutcome.FAILED
418
+ )
419
+
420
+ def wrap_tool_call(
421
+ self,
422
+ request: ToolCallRequest,
423
+ handler: Callable[[ToolCallRequest], ToolMessage | Command[Any]],
424
+ ) -> ToolMessage | Command[Any]:
425
+ """Restore the managed block when a sync edit would change it.
426
+
427
+ Returns:
428
+ The tool result, or an error `ToolMessage` when the managed block
429
+ was altered.
430
+ """
431
+ path = self._guarded_path(request)
432
+ if path is None:
433
+ return handler(request)
434
+ before = self._read(path)
435
+ if request.tool_call["name"] == "delete":
436
+ if self._reject_delete(path, before):
437
+ return self._delete_error(request, path)
438
+ return handler(request)
439
+ before_block = (
440
+ extract_onboarding_name_block(before) if before is not None else None
441
+ )
442
+ if before is None or before_block is None:
443
+ return handler(request)
444
+ result = handler(request)
445
+ return self._result_after_restore(request, path, before, before_block, result)
446
+
447
+ async def awrap_tool_call(
448
+ self,
449
+ request: ToolCallRequest,
450
+ handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]],
451
+ ) -> ToolMessage | Command[Any]:
452
+ """Restore the managed block when an async edit would change it.
453
+
454
+ Returns:
455
+ The tool result, or an error `ToolMessage` when the managed block
456
+ was altered.
457
+ """
458
+ path = await asyncio.to_thread(self._guarded_path, request)
459
+ if path is None:
460
+ return await handler(request)
461
+ before = await asyncio.to_thread(self._read, path)
462
+ if request.tool_call["name"] == "delete":
463
+ if await asyncio.to_thread(self._reject_delete, path, before):
464
+ return self._delete_error(request, path)
465
+ return await handler(request)
466
+ before_block = (
467
+ extract_onboarding_name_block(before) if before is not None else None
468
+ )
469
+ if before is None or before_block is None:
470
+ return await handler(request)
471
+ result = await handler(request)
472
+ return await asyncio.to_thread(
473
+ self._result_after_restore, request, path, before, before_block, result
474
+ )