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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- 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
|
+
)
|