ferrum-cli 0.1.0__tar.gz → 0.1.2__tar.gz
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.
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/PKG-INFO +1 -1
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum/__init__.py +1 -1
- ferrum_cli-0.1.2/ferrum/agent.py +320 -0
- ferrum_cli-0.1.2/ferrum/apply_patch.py +197 -0
- ferrum_cli-0.1.0/ferrum/tools.py → ferrum_cli-0.1.2/ferrum/builtin_tools.py +326 -296
- ferrum_cli-0.1.2/ferrum/cli.py +194 -0
- ferrum_cli-0.1.2/ferrum/commands/__init__.py +15 -0
- ferrum_cli-0.1.2/ferrum/commands/config.py +234 -0
- ferrum_cli-0.1.2/ferrum/commands/doctor.py +109 -0
- ferrum_cli-0.1.2/ferrum/commands/setup.py +191 -0
- ferrum_cli-0.1.2/ferrum/commands/task.py +186 -0
- ferrum_cli-0.1.2/ferrum/config.py +358 -0
- ferrum_cli-0.1.2/ferrum/config_store.py +149 -0
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum/context.py +295 -379
- ferrum_cli-0.1.2/ferrum/gitignore.py +131 -0
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum/model.py +129 -124
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum/prompts/system.md +7 -2
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum/safety.py +4 -2
- ferrum_cli-0.1.2/ferrum/toolcalls.py +105 -0
- ferrum_cli-0.1.2/ferrum/tools.py +112 -0
- ferrum_cli-0.1.2/ferrum/ui.py +196 -0
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum/verifier.py +21 -9
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum_cli.egg-info/PKG-INFO +1 -1
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum_cli.egg-info/SOURCES.txt +12 -0
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/pyproject.toml +11 -2
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/tests/test_agent.py +508 -455
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/tests/test_cli.py +395 -343
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/tests/test_config.py +143 -1
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/tests/test_context.py +32 -6
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/tests/test_model.py +538 -455
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/tests/test_tools.py +393 -268
- ferrum_cli-0.1.2/tests/test_ui.py +91 -0
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/tests/test_verifier.py +28 -7
- ferrum_cli-0.1.0/ferrum/agent.py +0 -387
- ferrum_cli-0.1.0/ferrum/cli.py +0 -479
- ferrum_cli-0.1.0/ferrum/config.py +0 -178
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/LICENSE +0 -0
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/README.md +0 -0
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum/patch.py +0 -0
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum_cli.egg-info/dependency_links.txt +0 -0
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum_cli.egg-info/entry_points.txt +0 -0
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum_cli.egg-info/top_level.txt +0 -0
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/setup.cfg +0 -0
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/tests/test_patch.py +0 -0
- {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/tests/test_safety.py +0 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""The agent loop: the model reads (and, in fix mode, patches) until done."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ferrum.apply_patch import ApplyPatch
|
|
13
|
+
from ferrum.config import Config
|
|
14
|
+
from ferrum.context import ProjectContext
|
|
15
|
+
from ferrum.model import ModelProvider, ModelResponse
|
|
16
|
+
from ferrum.toolcalls import MALFORMED_JSON, ToolCall, extract_tool_calls
|
|
17
|
+
from ferrum.tools import ToolRegistry, ToolResult
|
|
18
|
+
from ferrum.ui import Console
|
|
19
|
+
from ferrum.verifier import Verifier
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# Inside the package, so wheel installs ship the real prompt too.
|
|
24
|
+
PROMPT_PATH = Path(__file__).resolve().parent / "prompts" / "system.md"
|
|
25
|
+
FALLBACK_SYSTEM = (
|
|
26
|
+
"You are Ferrum, a coding harness for systems programming. "
|
|
27
|
+
"Inspect before you modify, cite file:line evidence, make the smallest "
|
|
28
|
+
"change, and never claim a fix is verified unless a build or test "
|
|
29
|
+
"actually passed."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
READ_ONLY_NOTE = (
|
|
33
|
+
"Read-only mode: investigate thoroughly before answering. A directory "
|
|
34
|
+
"listing alone is not an answer — read the relevant source files and "
|
|
35
|
+
"search for the relevant names; several tool calls are expected. You "
|
|
36
|
+
"cannot modify files, so do not call apply_patch; run_command is "
|
|
37
|
+
"available for builds, tests, and running the program."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
FIX_NOTE = (
|
|
41
|
+
"Fix mode: inspect thoroughly before patching — read the file you are "
|
|
42
|
+
"about to change and its callers; several tool calls are expected. "
|
|
43
|
+
"Reproduce the failure with run_command first when a build or test can "
|
|
44
|
+
"show it. Call apply_patch only with text you have read in this session."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
DRY_RUN_NOTE = (
|
|
48
|
+
"Dry run: apply_patch will display your proposed diff, but the harness "
|
|
49
|
+
"will not modify files, ask for confirmation, or run verification. "
|
|
50
|
+
"Propose the one correct patch and then summarize it for the user."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
NUDGE = (
|
|
54
|
+
"You have not called any tools yet. Investigate first: call list_files, "
|
|
55
|
+
"read_file or search_code and base your answer on what you read."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
READ_NUDGE = (
|
|
59
|
+
"A directory listing alone does not answer the question. Use read_file "
|
|
60
|
+
"on the most relevant source file (and search_code for names) before "
|
|
61
|
+
"you answer."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
MAX_NUDGES = 2
|
|
65
|
+
READ_TOOLS = ("read_file", "search_code", "apply_patch")
|
|
66
|
+
|
|
67
|
+
PROGRESS = {
|
|
68
|
+
"list_files": "inspecting the project",
|
|
69
|
+
"read_file": "reading",
|
|
70
|
+
"search_code": "searching",
|
|
71
|
+
"run_command": "running",
|
|
72
|
+
"apply_patch": "proposing a patch to",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class AgentResult:
|
|
78
|
+
text: str
|
|
79
|
+
turns: int
|
|
80
|
+
capped: bool = False
|
|
81
|
+
patched: bool = False
|
|
82
|
+
verified: bool | None = None
|
|
83
|
+
verify_report: str | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Agent:
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
config: Config,
|
|
90
|
+
provider: ModelProvider,
|
|
91
|
+
registry: ToolRegistry,
|
|
92
|
+
printer: Callable[[str], None] | None = None,
|
|
93
|
+
verifier: Verifier | None = None,
|
|
94
|
+
ui: Console | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
self.config = config
|
|
97
|
+
self.provider = provider
|
|
98
|
+
self.registry = registry
|
|
99
|
+
self.printer = printer
|
|
100
|
+
self.verifier = verifier
|
|
101
|
+
self.ui = ui
|
|
102
|
+
self.apply_tool: ApplyPatch | None = None
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def system_prompt() -> str:
|
|
106
|
+
try:
|
|
107
|
+
return PROMPT_PATH.read_text(encoding="utf-8")
|
|
108
|
+
except OSError:
|
|
109
|
+
return FALLBACK_SYSTEM
|
|
110
|
+
|
|
111
|
+
def run(
|
|
112
|
+
self,
|
|
113
|
+
task: str,
|
|
114
|
+
context: ProjectContext,
|
|
115
|
+
*,
|
|
116
|
+
edit: bool = False,
|
|
117
|
+
confirm: Callable[[str], bool] | None = None,
|
|
118
|
+
dry_run: bool = False,
|
|
119
|
+
) -> AgentResult:
|
|
120
|
+
if edit and "apply_patch" not in self.registry:
|
|
121
|
+
if confirm is None:
|
|
122
|
+
raise ValueError("edit mode requires a confirm callback")
|
|
123
|
+
self._register_apply(context, confirm, dry_run)
|
|
124
|
+
|
|
125
|
+
messages = self._messages(task, context, edit=edit, dry_run=dry_run)
|
|
126
|
+
state = _TurnState()
|
|
127
|
+
turns = 0
|
|
128
|
+
for turn in range(1, self.config.max_iterations + 1):
|
|
129
|
+
turns = turn
|
|
130
|
+
response = self._complete(messages)
|
|
131
|
+
calls = _merge_tool_calls(
|
|
132
|
+
response.tool_calls, extract_tool_calls(response.content)
|
|
133
|
+
)
|
|
134
|
+
if calls:
|
|
135
|
+
response = ModelResponse(response.content, calls)
|
|
136
|
+
if not response.tool_calls:
|
|
137
|
+
nudge = _nudge_for(response, state)
|
|
138
|
+
if nudge:
|
|
139
|
+
messages.append(_assistant_message(response))
|
|
140
|
+
messages.append({"role": "user", "content": nudge})
|
|
141
|
+
continue
|
|
142
|
+
return self._finish(response.content, turns, capped=False, edit=edit)
|
|
143
|
+
messages.append(_assistant_message(response))
|
|
144
|
+
messages.extend(self._run_tools(response, state))
|
|
145
|
+
return self._finish(
|
|
146
|
+
f"Stopped after {self.config.max_iterations} iterations "
|
|
147
|
+
"without a final answer.",
|
|
148
|
+
turns,
|
|
149
|
+
capped=True,
|
|
150
|
+
edit=edit,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def _run_tools(
|
|
154
|
+
self, response: ModelResponse, state: _TurnState
|
|
155
|
+
) -> list[dict[str, Any]]:
|
|
156
|
+
"""Run every requested tool, one tool message per call."""
|
|
157
|
+
state.used_tools = True
|
|
158
|
+
messages: list[dict[str, Any]] = []
|
|
159
|
+
for call in response.tool_calls:
|
|
160
|
+
if call.name in READ_TOOLS:
|
|
161
|
+
state.used_read = True
|
|
162
|
+
self._progress(call)
|
|
163
|
+
result = self._execute(call)
|
|
164
|
+
messages.append(
|
|
165
|
+
{
|
|
166
|
+
"role": "tool",
|
|
167
|
+
"tool_call_id": call.id,
|
|
168
|
+
"content": result.to_model_message(),
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
return messages
|
|
172
|
+
|
|
173
|
+
def _register_apply(
|
|
174
|
+
self,
|
|
175
|
+
context: ProjectContext,
|
|
176
|
+
confirm: Callable[[str], bool],
|
|
177
|
+
dry_run: bool,
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Give the model the apply_patch tool, wired to the user's answer."""
|
|
180
|
+
self.apply_tool = ApplyPatch(
|
|
181
|
+
context.root,
|
|
182
|
+
confirm,
|
|
183
|
+
ui=self.printer or print,
|
|
184
|
+
verifier=self.verifier,
|
|
185
|
+
dry_run=dry_run,
|
|
186
|
+
console=self.ui,
|
|
187
|
+
)
|
|
188
|
+
self.registry.register(self.apply_tool)
|
|
189
|
+
|
|
190
|
+
def _messages(
|
|
191
|
+
self, task: str, context: ProjectContext, *, edit: bool, dry_run: bool
|
|
192
|
+
) -> list[dict[str, Any]]:
|
|
193
|
+
"""The first two messages: who the model is, and what it is being asked."""
|
|
194
|
+
user = task
|
|
195
|
+
user += "\n\n" + (FIX_NOTE if edit else READ_ONLY_NOTE)
|
|
196
|
+
if dry_run:
|
|
197
|
+
user += "\n\n" + DRY_RUN_NOTE
|
|
198
|
+
user += "\n\nProject:\n" + context.summary()
|
|
199
|
+
return [
|
|
200
|
+
{"role": "system", "content": self.system_prompt()},
|
|
201
|
+
{"role": "user", "content": user},
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
def _complete(self, messages: list[dict[str, Any]]) -> ModelResponse:
|
|
205
|
+
"""One round trip, with a live line so the wait is never silent."""
|
|
206
|
+
tools = self.registry.schemas()
|
|
207
|
+
if self.ui is None:
|
|
208
|
+
return self.provider.complete(messages, tools)
|
|
209
|
+
model = getattr(self.provider, "model", "") or "the model"
|
|
210
|
+
with self.ui.live(f"asking {model}"):
|
|
211
|
+
return self.provider.complete(messages, tools)
|
|
212
|
+
|
|
213
|
+
def _execute(self, call: ToolCall) -> ToolResult:
|
|
214
|
+
if MALFORMED_JSON in call.arguments:
|
|
215
|
+
raw = str(call.arguments[MALFORMED_JSON])[:500]
|
|
216
|
+
return ToolResult.failure(
|
|
217
|
+
f"invalid JSON arguments for {call.name!r}: {raw!r}. "
|
|
218
|
+
"Send only the argument values as a JSON object "
|
|
219
|
+
'(e.g. {"path": "main.c"}); escape newlines as \\n.'
|
|
220
|
+
)
|
|
221
|
+
if not call.name:
|
|
222
|
+
return ToolResult.failure("tool call had no name")
|
|
223
|
+
return self.registry.execute(call.name, call.arguments)
|
|
224
|
+
|
|
225
|
+
def _progress(self, call: ToolCall) -> None:
|
|
226
|
+
label = PROGRESS.get(call.name, call.name)
|
|
227
|
+
if call.name in ("read_file", "apply_patch"):
|
|
228
|
+
detail = f" {call.arguments.get('path', '')}"
|
|
229
|
+
elif call.name == "search_code":
|
|
230
|
+
detail = f" {call.arguments.get('pattern', '')!r}"
|
|
231
|
+
elif call.name == "run_command":
|
|
232
|
+
detail = f" {str(call.arguments.get('command', ''))[:60]}"
|
|
233
|
+
else:
|
|
234
|
+
detail = ""
|
|
235
|
+
text = f"{label}{detail}"
|
|
236
|
+
if self.ui is not None:
|
|
237
|
+
self.ui.tool(text)
|
|
238
|
+
elif self.printer is not None:
|
|
239
|
+
self.printer(f"Ferrum > {text}...")
|
|
240
|
+
|
|
241
|
+
def _finish(
|
|
242
|
+
self, text: str, turns: int, *, capped: bool, edit: bool
|
|
243
|
+
) -> AgentResult:
|
|
244
|
+
if not text:
|
|
245
|
+
text = "The model returned no answer."
|
|
246
|
+
patched = bool(self.apply_tool and self.apply_tool.applied)
|
|
247
|
+
verified: bool | None = None
|
|
248
|
+
report: str | None = None
|
|
249
|
+
if patched and self.apply_tool and self.apply_tool.outcome:
|
|
250
|
+
verified = self.apply_tool.outcome.verified
|
|
251
|
+
report = self.apply_tool.outcome.report()
|
|
252
|
+
return AgentResult(
|
|
253
|
+
text=text,
|
|
254
|
+
turns=turns,
|
|
255
|
+
capped=capped,
|
|
256
|
+
patched=patched,
|
|
257
|
+
verified=verified,
|
|
258
|
+
verify_report=report,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@dataclass
|
|
263
|
+
class _TurnState:
|
|
264
|
+
"""What the loop has seen so far, for deciding whether to nudge."""
|
|
265
|
+
|
|
266
|
+
nudges: int = 0
|
|
267
|
+
read_nudges: int = 0
|
|
268
|
+
used_tools: bool = False
|
|
269
|
+
used_read: bool = False
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _nudge_for(response: ModelResponse, state: _TurnState) -> str | None:
|
|
273
|
+
"""The one-line push this reply deserves, if any.
|
|
274
|
+
|
|
275
|
+
Small models love answering from memory, so the first reply gets one
|
|
276
|
+
look; a directory listing alone is not reading, so push once more.
|
|
277
|
+
"""
|
|
278
|
+
if response.tool_calls:
|
|
279
|
+
return None
|
|
280
|
+
if not state.used_tools and state.nudges < MAX_NUDGES:
|
|
281
|
+
state.nudges += 1
|
|
282
|
+
return NUDGE
|
|
283
|
+
if state.used_tools and not state.used_read and state.read_nudges < MAX_NUDGES:
|
|
284
|
+
state.read_nudges += 1
|
|
285
|
+
return READ_NUDGE
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _merge_tool_calls(
|
|
290
|
+
native: list[ToolCall], extracted: list[ToolCall]
|
|
291
|
+
) -> list[ToolCall]:
|
|
292
|
+
"""Native calls first, then JSON-from-text calls that are not duplicates."""
|
|
293
|
+
seen = {(c.name, json.dumps(c.arguments, sort_keys=True)) for c in native}
|
|
294
|
+
merged = list(native)
|
|
295
|
+
for call in extracted:
|
|
296
|
+
key = (call.name, json.dumps(call.arguments, sort_keys=True))
|
|
297
|
+
if key not in seen:
|
|
298
|
+
seen.add(key)
|
|
299
|
+
merged.append(call)
|
|
300
|
+
return merged
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _assistant_message(response: ModelResponse) -> dict[str, Any]:
|
|
304
|
+
message: dict[str, Any] = {
|
|
305
|
+
"role": "assistant",
|
|
306
|
+
"content": response.content or "",
|
|
307
|
+
}
|
|
308
|
+
if response.tool_calls:
|
|
309
|
+
message["tool_calls"] = [
|
|
310
|
+
{
|
|
311
|
+
"id": call.id,
|
|
312
|
+
"type": "function",
|
|
313
|
+
"function": {
|
|
314
|
+
"name": call.name,
|
|
315
|
+
"arguments": json.dumps(call.arguments),
|
|
316
|
+
},
|
|
317
|
+
}
|
|
318
|
+
for call in response.tool_calls
|
|
319
|
+
]
|
|
320
|
+
return message
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""The apply_patch tool: guard the file, patch it, then verify the build.
|
|
2
|
+
|
|
3
|
+
Everything that touches the filesystem lives here so the agent loop stays a
|
|
4
|
+
conversation. The tool never writes without the user's yes (or, in dry-run,
|
|
5
|
+
never writes at all), and it reports what the build said afterwards.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable, Mapping
|
|
11
|
+
from contextlib import nullcontext
|
|
12
|
+
from pathlib import Path, PurePosixPath
|
|
13
|
+
from typing import Any, ClassVar
|
|
14
|
+
|
|
15
|
+
from ferrum.patch import Patch, format_unified_diff
|
|
16
|
+
from ferrum.safety import PathEscapeError, is_denied, safe_join
|
|
17
|
+
from ferrum.tools import Tool, ToolError, ToolResult, _require_string
|
|
18
|
+
from ferrum.ui import Console
|
|
19
|
+
from ferrum.verifier import Verifier, VerifyOutcome, run_command
|
|
20
|
+
|
|
21
|
+
DRY_RUN_RESULT = (
|
|
22
|
+
"Dry run: the patch was NOT applied to {rel}"
|
|
23
|
+
" — the file on disk is unchanged and no verification ran. "
|
|
24
|
+
"Summarize this proposed change for the user and stop."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
DECLINED_RESULT = (
|
|
28
|
+
"The user declined this patch. Do not apply it. Ask what they would prefer or stop."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
GIT_TIMEOUT = 15
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ApplyPatch(Tool):
|
|
35
|
+
name = "apply_patch"
|
|
36
|
+
description = (
|
|
37
|
+
"Replace exact text in a project file after showing the diff and "
|
|
38
|
+
"asking the user. The old text must match the file verbatim and "
|
|
39
|
+
"uniquely. After a successful patch the project is built and "
|
|
40
|
+
"tested; in a git work tree the result also carries git's status "
|
|
41
|
+
"and diff for the file, and the verification output comes back "
|
|
42
|
+
"afterwards."
|
|
43
|
+
)
|
|
44
|
+
parameters: ClassVar[dict[str, Any]] = {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"properties": {
|
|
47
|
+
"path": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"description": "File path relative to the project root.",
|
|
50
|
+
},
|
|
51
|
+
"old": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": "Exact text currently in the file.",
|
|
54
|
+
},
|
|
55
|
+
"new": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"description": "Replacement text.",
|
|
58
|
+
},
|
|
59
|
+
"description": {
|
|
60
|
+
"type": "string",
|
|
61
|
+
"description": "One line: why this change fixes the problem.",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
"required": ["path", "old", "new"],
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
root: Path,
|
|
70
|
+
confirm: Callable[[str], bool],
|
|
71
|
+
ui: Callable[[str], None] = print,
|
|
72
|
+
verifier: Verifier | None = None,
|
|
73
|
+
dry_run: bool = False,
|
|
74
|
+
console: Console | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
self.root = Path(root)
|
|
77
|
+
self.confirm = confirm
|
|
78
|
+
self.ui = ui
|
|
79
|
+
self.verifier = verifier
|
|
80
|
+
self.dry_run = dry_run
|
|
81
|
+
self.console = console
|
|
82
|
+
self.applied = False
|
|
83
|
+
self.outcome: VerifyOutcome | None = None
|
|
84
|
+
|
|
85
|
+
def execute(self, arguments: Mapping[str, Any]) -> ToolResult:
|
|
86
|
+
rel = _require_string(arguments, "path")
|
|
87
|
+
old = _require_string(arguments, "old")
|
|
88
|
+
new, description = _new_text(arguments)
|
|
89
|
+
|
|
90
|
+
target, current, crlf = _read_target(self.root, rel)
|
|
91
|
+
updated = _apply(rel, current, old, new, description)
|
|
92
|
+
self.ui(format_unified_diff(current, updated, rel))
|
|
93
|
+
|
|
94
|
+
if self.dry_run:
|
|
95
|
+
return ToolResult.success(DRY_RUN_RESULT.format(rel=rel))
|
|
96
|
+
if not self.confirm("Apply patch? [y/N] "):
|
|
97
|
+
return ToolResult.failure(DECLINED_RESULT)
|
|
98
|
+
|
|
99
|
+
_write(target, updated, crlf, rel)
|
|
100
|
+
self.applied = True
|
|
101
|
+
return ToolResult.success(self._report(rel, description))
|
|
102
|
+
|
|
103
|
+
def _report(self, rel: str, description: str) -> str:
|
|
104
|
+
"""The tool's answer: what changed, then what the build said."""
|
|
105
|
+
report = f"Patch applied to {rel}."
|
|
106
|
+
if description:
|
|
107
|
+
report += f" ({description})"
|
|
108
|
+
git_section = _git_report(self.root, rel)
|
|
109
|
+
if git_section:
|
|
110
|
+
report += "\n\n--- git ---\n" + git_section
|
|
111
|
+
if self.verifier is None:
|
|
112
|
+
return report + "\n(verification unavailable)"
|
|
113
|
+
live = (
|
|
114
|
+
self.console.live("building and testing")
|
|
115
|
+
if self.console is not None
|
|
116
|
+
else nullcontext()
|
|
117
|
+
)
|
|
118
|
+
with live:
|
|
119
|
+
self.outcome = self.verifier.verify()
|
|
120
|
+
return report + "\n\n--- verification ---\n" + self.outcome.report()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _new_text(arguments: Mapping[str, Any]) -> tuple[str, str]:
|
|
124
|
+
new = arguments.get("new")
|
|
125
|
+
if not isinstance(new, str):
|
|
126
|
+
raise ToolError("new is required and must be a string")
|
|
127
|
+
description = arguments.get("description") or ""
|
|
128
|
+
if not isinstance(description, str):
|
|
129
|
+
description = str(description)
|
|
130
|
+
return new, description
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _read_target(root: Path, rel: str) -> tuple[Path, str, bool]:
|
|
134
|
+
"""Resolve, guard, and read the file; returns text with LF endings."""
|
|
135
|
+
if is_denied(PurePosixPath(rel.replace("\\", "/"))):
|
|
136
|
+
raise ToolError(f"refused: {rel} is excluded (secret, binary, or ignored)")
|
|
137
|
+
try:
|
|
138
|
+
target = safe_join(root, rel)
|
|
139
|
+
except PathEscapeError as exc:
|
|
140
|
+
raise ToolError(str(exc)) from exc
|
|
141
|
+
if target.is_dir():
|
|
142
|
+
raise ToolError(f"is a directory: {rel}")
|
|
143
|
+
if not target.exists():
|
|
144
|
+
raise ToolError(f"file not found: {rel} (read it first)")
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
data = target.read_bytes()
|
|
148
|
+
except OSError as exc:
|
|
149
|
+
raise ToolError(f"cannot read {rel}: {exc}") from exc
|
|
150
|
+
if b"\x00" in data[:8192]:
|
|
151
|
+
raise ToolError(f"refused: {rel} looks like a binary file")
|
|
152
|
+
try:
|
|
153
|
+
text = data.decode("utf-8")
|
|
154
|
+
except UnicodeDecodeError as exc:
|
|
155
|
+
raise ToolError(f"not valid UTF-8 text: {rel}") from exc
|
|
156
|
+
|
|
157
|
+
crlf = "\r\n" in text
|
|
158
|
+
return target, text.replace("\r\n", "\n").replace("\r", "\n"), crlf
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _apply(rel: str, current: str, old: str, new: str, description: str) -> str:
|
|
162
|
+
patch = Patch(rel=rel, old=old, new=new, description=description)
|
|
163
|
+
try:
|
|
164
|
+
return patch.apply(current)
|
|
165
|
+
except Exception as exc:
|
|
166
|
+
raise ToolError(f"Patch rejected: {exc}. Re-read the file and retry.") from exc
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _write(target: Path, updated: str, crlf: bool, rel: str) -> None:
|
|
170
|
+
out = updated.replace("\n", "\r\n") if crlf else updated
|
|
171
|
+
try:
|
|
172
|
+
target.write_bytes(out.encode("utf-8"))
|
|
173
|
+
except OSError as exc:
|
|
174
|
+
raise ToolError(f"cannot write {rel}: {exc}") from exc
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _git_report(root: Path, rel: str) -> str | None:
|
|
178
|
+
"""What git sees for this file after the write.
|
|
179
|
+
|
|
180
|
+
Returns None when git is missing or root is not a work tree, so projects
|
|
181
|
+
outside version control never pay for the extra sections.
|
|
182
|
+
"""
|
|
183
|
+
probe = run_command(
|
|
184
|
+
["git", "rev-parse", "--is-inside-work-tree"], root, timeout=GIT_TIMEOUT
|
|
185
|
+
)
|
|
186
|
+
if not probe.ok or probe.stdout.strip() != "true":
|
|
187
|
+
return None
|
|
188
|
+
status = run_command(
|
|
189
|
+
["git", "status", "--porcelain", "--", rel], root, timeout=GIT_TIMEOUT
|
|
190
|
+
)
|
|
191
|
+
lines = [status.stdout.strip() or "(no changes against the index)"]
|
|
192
|
+
diff = run_command(
|
|
193
|
+
["git", "diff", "--no-color", "--", rel], root, timeout=GIT_TIMEOUT
|
|
194
|
+
)
|
|
195
|
+
if diff.stdout.strip():
|
|
196
|
+
lines.append(diff.stdout.strip())
|
|
197
|
+
return "\n".join(lines)
|