commamatrix 0.1.0__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 (126) hide show
  1. commamatrix/__init__.py +16 -0
  2. commamatrix/builtin/__init__.py +42 -0
  3. commamatrix/builtin/apply_patch.py +614 -0
  4. commamatrix/builtin/codeact/__init__.py +37 -0
  5. commamatrix/builtin/codeact/executor/__init__.py +13 -0
  6. commamatrix/builtin/codeact/executor/backend.py +73 -0
  7. commamatrix/builtin/codeact/executor/docker.py +32 -0
  8. commamatrix/builtin/codeact/executor/subproc.py +242 -0
  9. commamatrix/builtin/codeact/executor/systemd.py +36 -0
  10. commamatrix/builtin/codeact/executor/worker.py +425 -0
  11. commamatrix/builtin/codeact/hooks.py +35 -0
  12. commamatrix/builtin/codeact/info.md +430 -0
  13. commamatrix/builtin/codeact/instructions.py +69 -0
  14. commamatrix/builtin/codeact/rpc/__init__.py +17 -0
  15. commamatrix/builtin/codeact/rpc/protocol.py +48 -0
  16. commamatrix/builtin/codeact/rpc/server.py +124 -0
  17. commamatrix/builtin/codeact/rpc/tcp.py +150 -0
  18. commamatrix/builtin/codeact/rpc/transport.py +23 -0
  19. commamatrix/builtin/codeact/search/__init__.py +8 -0
  20. commamatrix/builtin/codeact/search/api.py +51 -0
  21. commamatrix/builtin/codeact/search/bm25.py +78 -0
  22. commamatrix/builtin/codeact/service.py +140 -0
  23. commamatrix/builtin/codeact/tools.py +77 -0
  24. commamatrix/builtin/data_tools.py +318 -0
  25. commamatrix/builtin/filesystem.py +39 -0
  26. commamatrix/builtin/http_connector/__init__.py +41 -0
  27. commamatrix/builtin/http_connector/auth.py +326 -0
  28. commamatrix/builtin/http_connector/connector.py +1091 -0
  29. commamatrix/builtin/http_connector/ui/index.html +149 -0
  30. commamatrix/builtin/http_connector/ui/logo.svg +1 -0
  31. commamatrix/builtin/instructions/__init__.py +25 -0
  32. commamatrix/builtin/instructions/automation.py +23 -0
  33. commamatrix/builtin/instructions/coding.py +23 -0
  34. commamatrix/builtin/instructions/data_analysis.py +23 -0
  35. commamatrix/builtin/instructions/deep_research.py +22 -0
  36. commamatrix/builtin/instructions/default_instruction.py +16 -0
  37. commamatrix/builtin/instructions/roleplay.py +21 -0
  38. commamatrix/builtin/llm_http_adapter/__init__.py +30 -0
  39. commamatrix/builtin/llm_http_adapter/adapter.py +267 -0
  40. commamatrix/builtin/llm_http_adapter/anthropic_messages.py +343 -0
  41. commamatrix/builtin/llm_http_adapter/chat_completions.py +408 -0
  42. commamatrix/builtin/llm_http_adapter/codec.py +280 -0
  43. commamatrix/builtin/llm_http_adapter/responses.py +392 -0
  44. commamatrix/builtin/mcp/__init__.py +43 -0
  45. commamatrix/builtin/mcp/config.py +155 -0
  46. commamatrix/builtin/mcp/hooks.py +21 -0
  47. commamatrix/builtin/mcp/instructions.py +28 -0
  48. commamatrix/builtin/mcp/loader.py +90 -0
  49. commamatrix/builtin/mcp/manager.py +164 -0
  50. commamatrix/builtin/mcp/result.py +53 -0
  51. commamatrix/builtin/mcp/runtime.py +222 -0
  52. commamatrix/builtin/mcp/server.py +126 -0
  53. commamatrix/builtin/mcp/source.py +110 -0
  54. commamatrix/builtin/multi_dialog.py +254 -0
  55. commamatrix/builtin/multi_user.py +153 -0
  56. commamatrix/builtin/planner/__init__.py +49 -0
  57. commamatrix/builtin/planner/decorators.py +57 -0
  58. commamatrix/builtin/planner/service.py +236 -0
  59. commamatrix/builtin/self_extension/__init__.py +12 -0
  60. commamatrix/builtin/self_extension/guides/codeact.md +63 -0
  61. commamatrix/builtin/self_extension/guides/configuration.md +62 -0
  62. commamatrix/builtin/self_extension/guides/connectors.md +92 -0
  63. commamatrix/builtin/self_extension/guides/dialog.md +63 -0
  64. commamatrix/builtin/self_extension/guides/hooks.md +107 -0
  65. commamatrix/builtin/self_extension/guides/http.md +63 -0
  66. commamatrix/builtin/self_extension/guides/instructions.md +46 -0
  67. commamatrix/builtin/self_extension/guides/lifecycle.md +62 -0
  68. commamatrix/builtin/self_extension/guides/main.md +196 -0
  69. commamatrix/builtin/self_extension/guides/mcp.md +128 -0
  70. commamatrix/builtin/self_extension/guides/planner.md +48 -0
  71. commamatrix/builtin/self_extension/guides/providers.md +59 -0
  72. commamatrix/builtin/self_extension/guides/runtime.md +86 -0
  73. commamatrix/builtin/self_extension/guides/security.md +54 -0
  74. commamatrix/builtin/self_extension/guides/services.md +78 -0
  75. commamatrix/builtin/self_extension/guides/tables.md +71 -0
  76. commamatrix/builtin/self_extension/guides/tools.md +119 -0
  77. commamatrix/builtin/self_extension/tools.py +102 -0
  78. commamatrix/builtin/simple_fs.py +72 -0
  79. commamatrix/builtin/sql/__init__.py +22 -0
  80. commamatrix/builtin/sql/postgres_storage.py +105 -0
  81. commamatrix/builtin/sql/sql_storage.py +473 -0
  82. commamatrix/builtin/sql/sqlite_storage.py +78 -0
  83. commamatrix/builtin/storage_utils.py +48 -0
  84. commamatrix/builtin/subagent/__init__.py +17 -0
  85. commamatrix/builtin/subagent/connector.py +107 -0
  86. commamatrix/builtin/subagent/hooks.py +71 -0
  87. commamatrix/builtin/subagent/policy.py +42 -0
  88. commamatrix/builtin/subagent/service.py +42 -0
  89. commamatrix/builtin/subagent/submit.py +139 -0
  90. commamatrix/builtin/subagent/tools.py +43 -0
  91. commamatrix/builtin/web_utils/__init__.py +22 -0
  92. commamatrix/builtin/web_utils/instructions.py +18 -0
  93. commamatrix/builtin/web_utils/security.py +50 -0
  94. commamatrix/builtin/web_utils/tools.py +134 -0
  95. commamatrix/components/__init__.py +272 -0
  96. commamatrix/components/config.py +128 -0
  97. commamatrix/components/connector.py +170 -0
  98. commamatrix/components/dialog.py +127 -0
  99. commamatrix/components/file_storage.py +287 -0
  100. commamatrix/components/hook.py +478 -0
  101. commamatrix/components/http_client.py +71 -0
  102. commamatrix/components/instruction.py +237 -0
  103. commamatrix/components/llm_adapter.py +327 -0
  104. commamatrix/components/server.py +234 -0
  105. commamatrix/components/storage.py +94 -0
  106. commamatrix/components/table.py +214 -0
  107. commamatrix/components/tool.py +559 -0
  108. commamatrix/core/__init__.py +63 -0
  109. commamatrix/core/agent/__init__.py +17 -0
  110. commamatrix/core/agent/agent.py +822 -0
  111. commamatrix/core/agent/lifecycle.py +291 -0
  112. commamatrix/core/agent/runner.py +65 -0
  113. commamatrix/core/classes/__init__.py +57 -0
  114. commamatrix/core/classes/descriptor.py +34 -0
  115. commamatrix/core/classes/lifecycle_registry.py +78 -0
  116. commamatrix/core/classes/manager.py +457 -0
  117. commamatrix/core/classes/ordering.py +160 -0
  118. commamatrix/core/classes/service.py +70 -0
  119. commamatrix/core/classes/source.py +142 -0
  120. commamatrix/core/extensions.py +265 -0
  121. commamatrix/presets.py +82 -0
  122. commamatrix/utils.py +261 -0
  123. commamatrix-0.1.0.dist-info/METADATA +321 -0
  124. commamatrix-0.1.0.dist-info/RECORD +126 -0
  125. commamatrix-0.1.0.dist-info/WHEEL +4 -0
  126. commamatrix-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,16 @@
1
+ # __init__.py
2
+
3
+ from . import builtin as builtin
4
+ from . import components as _components
5
+ from . import core as _core
6
+ from . import utils as utils
7
+ from . import presets as presets
8
+ from .components import *
9
+ from .core import *
10
+ from .utils import *
11
+
12
+ __all__ = list(
13
+ dict.fromkeys(
14
+ [*_components.__all__, *_core.__all__, *utils.__all__, "builtin", "presets", "utils"]
15
+ )
16
+ )
@@ -0,0 +1,42 @@
1
+ # builtin/__init__.py
2
+
3
+ """Optional built-in plugins loaded explicitly by importing their modules."""
4
+
5
+ from importlib import import_module
6
+ from types import ModuleType
7
+
8
+
9
+ _MODULES = {
10
+ "apply_patch": "apply_patch",
11
+ "codeact": "codeact",
12
+ "data_tools": "data_tools",
13
+ "filesystem": "filesystem",
14
+ "http_connector": "http_connector",
15
+ "instructions": "instructions",
16
+ "llm_http_adapter": "llm_http_adapter",
17
+ "mcp": "mcp",
18
+ "multi_dialog": "multi_dialog",
19
+ "multi_user": "multi_user",
20
+ "planner": "planner",
21
+ "self_extension": "self_extension",
22
+ "simple_fs": "simple_fs",
23
+ "sql": "sql",
24
+ "storage_utils": "storage_utils",
25
+ "subagent": "subagent",
26
+ "web_utils": "web_utils",
27
+ }
28
+
29
+ __all__ = list(_MODULES)
30
+
31
+
32
+ def __getattr__(name: str) -> ModuleType:
33
+ module_name = _MODULES.get(name)
34
+ if module_name is None:
35
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
36
+ module = import_module(f"{__name__}.{module_name}")
37
+ globals()[name] = module
38
+ return module
39
+
40
+
41
+ def __dir__() -> list[str]:
42
+ return sorted({*globals(), *__all__})
@@ -0,0 +1,614 @@
1
+ # builtin/apply_patch.py
2
+
3
+ """Text-file patch tool for coding agents.
4
+
5
+ The patch syntax intentionally follows the familiar Begin/End Patch format,
6
+ while the implementation keeps workspace policy, text format preservation and
7
+ per-operation results local to CommaMatrix.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import hashlib
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Literal
17
+
18
+ from ..components.config import ConfigField
19
+ from ..components.hook import BeforeToolCallCtx
20
+ from ..components.instruction import InstructionCtx, instruction
21
+ from ..components.tool import tool
22
+ from ..utils import (
23
+ PathResolutionError,
24
+ TextFileFormat,
25
+ allow_absolute_paths,
26
+ read_text_file,
27
+ resolve_path,
28
+ write_text_file,
29
+ )
30
+
31
+
32
+ class PatchError(Exception):
33
+ """Raised for malformed patches or unusable patch operations."""
34
+
35
+
36
+ class PatchOperationError(PatchError):
37
+ def __init__(self, operation_index: int, message: str) -> None:
38
+ super().__init__(message)
39
+ self.operation_index = operation_index
40
+
41
+
42
+ @dataclass(slots=True)
43
+ class Hunk:
44
+ context_lines: list[str] = field(default_factory=list)
45
+ lines: list[tuple[str, str]] = field(default_factory=list)
46
+ end_of_file: bool = False
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class FileOp:
51
+ action: Literal["add", "update", "delete"]
52
+ path: str
53
+ move_to: str | None = None
54
+ content: str | None = None
55
+ hunks: list[Hunk] = field(default_factory=list)
56
+
57
+
58
+ @dataclass(slots=True)
59
+ class PatchOperationResult:
60
+ path: str
61
+ action: str
62
+ status: Literal["applied", "not applied", "failed", "would apply"]
63
+ message: str = ""
64
+ move_to: str | None = None
65
+
66
+ def render(self) -> str:
67
+ location = self.path
68
+ if self.move_to:
69
+ location += f" -> {self.move_to}"
70
+ suffix = f": {self.message}" if self.message else ""
71
+ return f"{self.status.upper()}: {location}{suffix}"
72
+
73
+
74
+ @dataclass(slots=True)
75
+ class PatchResult:
76
+ operations: list[PatchOperationResult]
77
+ error: str | None = None
78
+
79
+ @property
80
+ def succeeded(self) -> bool:
81
+ return self.error is None and all(
82
+ operation.status in {"applied", "would apply"}
83
+ for operation in self.operations
84
+ )
85
+
86
+ def render(self) -> str:
87
+ lines = [operation.render() for operation in self.operations]
88
+ if self.error:
89
+ lines.append(f"PATCH ERROR: {self.error}")
90
+ return "\n".join(lines)
91
+
92
+
93
+ @dataclass(slots=True)
94
+ class _PlannedOperation:
95
+ operation: FileOp
96
+ source: Path
97
+ destination: Path | None = None
98
+ original_digest: str | None = None
99
+ original_mode: int | None = None
100
+ content: str | None = None
101
+ file_format: TextFileFormat | None = None
102
+
103
+
104
+ max_patch_chars = ConfigField[int](
105
+ name="max_patch_chars",
106
+ default=2_000_000,
107
+ description="Maximum text size accepted by the apply_patch tool.",
108
+ )
109
+
110
+
111
+ @instruction(priority=-120)
112
+ def apply_patch_guidance(_ctx: InstructionCtx) -> str:
113
+ """Recommend the patch tool for code edits and multi-file changes."""
114
+ return """
115
+ # Code editing
116
+ When editing code, prefer the `code_apply_patch` tool (named `tools.code.apply_patch` in CodeAct) over other I/O methods such as shell redirection or direct file writes. This is especially important when a change affects multiple files: combine related operations into one patch when practical, keep the patch focused, and inspect the affected code before applying it.
117
+ """
118
+
119
+
120
+ # --------------------------------------------------------------------------
121
+ # Patch parsing
122
+ # --------------------------------------------------------------------------
123
+
124
+
125
+ def parse_patch(patch_text: str) -> list[FileOp]:
126
+ lines = patch_text.splitlines()
127
+
128
+ if lines and lines[0].strip() == "*** Begin Patch":
129
+ lines = lines[1:]
130
+ else:
131
+ raise PatchError("patch must start with '*** Begin Patch'")
132
+
133
+ if lines and lines[-1].strip() == "*** End Patch":
134
+ lines = lines[:-1]
135
+ else:
136
+ raise PatchError("patch must end with '*** End Patch'")
137
+
138
+ operations: list[FileOp] = []
139
+ index = 0
140
+ total = len(lines)
141
+
142
+ def is_new_section(_line: str) -> bool:
143
+ return (
144
+ _line.startswith("*** Add File: ")
145
+ or _line.startswith("*** Update File: ")
146
+ or _line.startswith("*** Delete File: ")
147
+ )
148
+
149
+ while index < total:
150
+ line = lines[index]
151
+
152
+ if line.startswith("*** Add File: "):
153
+ path = line[len("*** Add File: "):].strip()
154
+ if not path:
155
+ raise PatchError("Add File path must not be empty")
156
+ index += 1
157
+ content_lines: list[str] = []
158
+ while index < total and not is_new_section(lines[index]):
159
+ content_line = lines[index]
160
+ if content_line.startswith("+"):
161
+ content_lines.append(content_line[1:])
162
+ elif content_line.strip() == "":
163
+ # Keep the parser forgiving for hand-written patches.
164
+ content_lines.append("")
165
+ else:
166
+ raise PatchError(
167
+ f"invalid line in 'Add File: {path}': {content_line!r}"
168
+ )
169
+ index += 1
170
+ content = "\n".join(content_lines)
171
+ if content_lines:
172
+ content += "\n"
173
+ operations.append(FileOp(action="add", path=path, content=content))
174
+ continue
175
+
176
+ if line.startswith("*** Delete File: "):
177
+ path = line[len("*** Delete File: "):].strip()
178
+ if not path:
179
+ raise PatchError("Delete File path must not be empty")
180
+ index += 1
181
+ operations.append(FileOp(action="delete", path=path))
182
+ continue
183
+
184
+ if line.startswith("*** Update File: "):
185
+ path = line[len("*** Update File: "):].strip()
186
+ if not path:
187
+ raise PatchError("Update File path must not be empty")
188
+ index += 1
189
+ move_to: str | None = None
190
+ if index < total and lines[index].startswith("*** Move to: "):
191
+ move_to = lines[index][len("*** Move to: "):].strip()
192
+ if not move_to:
193
+ raise PatchError(f"Move to path for {path!r} must not be empty")
194
+ index += 1
195
+
196
+ hunks: list[Hunk] = []
197
+ current: Hunk | None = None
198
+ while index < total and not is_new_section(lines[index]):
199
+ hunk_line = lines[index]
200
+ if hunk_line.startswith("@@"):
201
+ if current is not None:
202
+ hunks.append(current)
203
+ current = Hunk()
204
+ context = hunk_line[2:].strip()
205
+ if context:
206
+ current.context_lines.append(context)
207
+ elif hunk_line.strip() == "*** End of File":
208
+ if current is None:
209
+ current = Hunk()
210
+ current.end_of_file = True
211
+ else:
212
+ if current is None:
213
+ current = Hunk()
214
+ if hunk_line.startswith("+"):
215
+ current.lines.append(("+", hunk_line[1:]))
216
+ elif hunk_line.startswith("-"):
217
+ current.lines.append(("-", hunk_line[1:]))
218
+ elif hunk_line.startswith(" "):
219
+ current.lines.append((" ", hunk_line[1:]))
220
+ elif hunk_line.strip() == "":
221
+ current.lines.append((" ", ""))
222
+ else:
223
+ raise PatchError(
224
+ f"invalid line in 'Update File: {path}': {hunk_line!r}"
225
+ )
226
+ index += 1
227
+
228
+ if current is not None:
229
+ hunks.append(current)
230
+ if not hunks:
231
+ raise PatchError(f"Update File {path!r} has no hunks")
232
+ operations.append(
233
+ FileOp(action="update", path=path, move_to=move_to, hunks=hunks)
234
+ )
235
+ continue
236
+
237
+ if line.strip() == "":
238
+ index += 1
239
+ continue
240
+ raise PatchError(f"unexpected line in patch: {line!r}")
241
+
242
+ if not operations:
243
+ raise PatchError("patch contains no file operations")
244
+ return operations
245
+
246
+
247
+ # --------------------------------------------------------------------------
248
+ # Applying text hunks
249
+ # --------------------------------------------------------------------------
250
+
251
+
252
+ def _find_hunk_position(file_lines: list[str], before: list[str], start: int, context_hint: list[str] | None = None) -> int:
253
+ """Find one exact hunk match, with trailing-whitespace fallback."""
254
+ if not before:
255
+ return min(start, len(file_lines))
256
+
257
+ line_count = len(file_lines)
258
+ before_count = len(before)
259
+ exact = [
260
+ index
261
+ for index in range(start, line_count - before_count + 1)
262
+ if file_lines[index: index + before_count] == before
263
+ ]
264
+ if len(exact) == 1:
265
+ return exact[0]
266
+ if len(exact) > 1:
267
+ raise PatchError(_ambiguous_hunk_message(before, context_hint))
268
+
269
+ before_rstrip = [line.rstrip() for line in before]
270
+ fallback = [
271
+ index
272
+ for index in range(start, line_count - before_count + 1)
273
+ if [line.rstrip() for line in file_lines[index: index + before_count]]
274
+ == before_rstrip
275
+ ]
276
+ if len(fallback) == 1:
277
+ return fallback[0]
278
+ if len(fallback) > 1:
279
+ raise PatchError(_ambiguous_hunk_message(before, context_hint))
280
+
281
+ hint = ""
282
+ if context_hint:
283
+ hint = f"\nHunk hint: {context_hint[0]}"
284
+ snippet = "\n".join(before[:3])
285
+ raise PatchError(
286
+ "could not find hunk context; the file does not match the expected "
287
+ f"content.\nExpected fragment:\n{snippet}{hint}"
288
+ )
289
+
290
+
291
+ def _ambiguous_hunk_message(before: list[str], context_hint: list[str] | None) -> str:
292
+ hint = f" Hunk hint: {context_hint[0]}" if context_hint else ""
293
+ snippet = "\n".join(before[:3])
294
+ return (
295
+ "hunk context is ambiguous; add more context to identify one location."
296
+ f"{hint}\nCandidate fragment:\n{snippet}"
297
+ )
298
+
299
+
300
+ def _apply_hunk(file_lines: list[str], hunk: Hunk, search_from: int) -> tuple[list[str], int]:
301
+ def apply_lines(lines: list[tuple[str, str]]) -> tuple[list[str], int]:
302
+ before = [text for operation, text in lines if operation in {" ", "-"}]
303
+ after = [text for operation, text in lines if operation in {" ", "+"}]
304
+
305
+ if hunk.end_of_file and not before:
306
+ position = len(file_lines)
307
+ else:
308
+ position = _find_hunk_position(
309
+ file_lines,
310
+ before,
311
+ search_from,
312
+ context_hint=hunk.context_lines,
313
+ )
314
+ if hunk.end_of_file and position + len(before) != len(file_lines):
315
+ raise PatchError("hunk marked '*** End of File' does not reach the file end")
316
+
317
+ new_lines = file_lines[:position] + after + file_lines[position + len(before):]
318
+ return new_lines, position + len(after)
319
+
320
+ try:
321
+ return apply_lines(hunk.lines)
322
+ except PatchError as exc:
323
+ if not str(exc).startswith("could not find hunk context;"):
324
+ raise
325
+ mismatch = exc
326
+
327
+ # Tolerate an extra indentation space on context lines from CodeAct output.
328
+ relaxed_lines = [
329
+ (
330
+ operation,
331
+ text[1:] if operation == " " and text.startswith(" ") else text,
332
+ )
333
+ for operation, text in hunk.lines
334
+ ]
335
+ if relaxed_lines == hunk.lines:
336
+ raise mismatch
337
+ return apply_lines(relaxed_lines)
338
+
339
+
340
+ def apply_update(original_text: str, hunks: list[Hunk]) -> str:
341
+ trailing_newline = original_text.endswith("\n")
342
+ file_lines = original_text.split("\n")
343
+ if trailing_newline and file_lines and file_lines[-1] == "":
344
+ file_lines.pop()
345
+
346
+ search_from = 0
347
+ for hunk in hunks:
348
+ file_lines, search_from = _apply_hunk(file_lines, hunk, search_from)
349
+
350
+ result = "\n".join(file_lines)
351
+ if trailing_newline or (hunks and hunks[-1].end_of_file):
352
+ result += "\n"
353
+ return result
354
+
355
+
356
+ # --------------------------------------------------------------------------
357
+ # Planning and committing operations
358
+ # --------------------------------------------------------------------------
359
+
360
+
361
+ def _exists(path: Path) -> bool:
362
+ return path.exists() or path.is_symlink()
363
+
364
+
365
+ def _digest(path: Path) -> str:
366
+ return hashlib.sha256(path.read_bytes()).hexdigest()
367
+
368
+
369
+ def _operation_result(operation: FileOp, status: Literal["applied", "not applied", "failed", "would apply"], message: str = "") -> PatchOperationResult:
370
+ return PatchOperationResult(
371
+ path=operation.path,
372
+ action=operation.action,
373
+ status=status,
374
+ message=message,
375
+ move_to=operation.move_to,
376
+ )
377
+
378
+
379
+ def _not_applied_results(operations: list[FileOp], failed_index: int, message: str) -> list[PatchOperationResult]:
380
+ results: list[PatchOperationResult] = []
381
+ for index, operation in enumerate(operations):
382
+ if index == failed_index:
383
+ results.append(_operation_result(operation, "failed", message))
384
+ else:
385
+ results.append(
386
+ _operation_result(
387
+ operation,
388
+ "not applied",
389
+ "not applied because patch validation failed",
390
+ )
391
+ )
392
+ return results
393
+
394
+
395
+ def _plan_operations(operations: list[FileOp], *, root: Path, allow_absolute: bool) -> list[_PlannedOperation]:
396
+ planned: list[_PlannedOperation] = []
397
+ touched: dict[Path, str] = {}
398
+
399
+ for operation_index, operation in enumerate(operations):
400
+ try:
401
+ source = resolve_path(
402
+ operation.path,
403
+ root=root,
404
+ allow_absolute=allow_absolute,
405
+ )
406
+ destination = None
407
+ if operation.move_to:
408
+ destination = resolve_path(
409
+ operation.move_to,
410
+ root=root,
411
+ allow_absolute=allow_absolute,
412
+ )
413
+ if destination == source:
414
+ raise PatchError("Move to destination must differ from source")
415
+
416
+ paths = [source] + ([destination] if destination is not None else [])
417
+ for path in paths:
418
+ assert path is not None
419
+ if path in touched:
420
+ raise PatchError(
421
+ f"path {path} is used by multiple patch operations: "
422
+ f"{touched[path]} and {operation.path}"
423
+ )
424
+ touched[source] = operation.path
425
+ if destination is not None:
426
+ touched[destination] = operation.move_to or operation.path
427
+
428
+ if operation.action == "add":
429
+ if _exists(source):
430
+ raise PatchError(f"file already exists: {operation.path}")
431
+ planned.append(
432
+ _PlannedOperation(
433
+ operation=operation,
434
+ source=source,
435
+ content=operation.content or "",
436
+ file_format=TextFileFormat(),
437
+ )
438
+ )
439
+ continue
440
+
441
+ if not _exists(source):
442
+ raise PatchError(f"file not found: {operation.path}")
443
+ if not source.is_file():
444
+ raise PatchError(f"path is not a regular file: {operation.path}")
445
+
446
+ if operation.action == "delete":
447
+ planned.append(
448
+ _PlannedOperation(
449
+ operation=operation,
450
+ source=source,
451
+ original_digest=_digest(source),
452
+ original_mode=source.stat().st_mode,
453
+ )
454
+ )
455
+ continue
456
+
457
+ if operation.action == "update":
458
+ snapshot = read_text_file(source)
459
+ updated = apply_update(snapshot.content, operation.hunks)
460
+ if destination is not None and _exists(destination):
461
+ raise PatchError(f"move destination already exists: {operation.move_to}")
462
+ planned.append(
463
+ _PlannedOperation(
464
+ operation=operation,
465
+ source=source,
466
+ destination=destination,
467
+ original_digest=snapshot.digest,
468
+ original_mode=snapshot.mode,
469
+ content=updated,
470
+ file_format=snapshot.file_format,
471
+ )
472
+ )
473
+ continue
474
+
475
+ raise PatchError(f"unknown patch action: {operation.action}")
476
+ except PathResolutionError as exc:
477
+ raise PatchOperationError(operation_index, str(exc)) from exc
478
+ except (OSError, UnicodeError) as exc:
479
+ raise PatchOperationError(
480
+ operation_index,
481
+ f"could not inspect {operation.path}: {exc}",
482
+ ) from exc
483
+ except PatchError as exc:
484
+ raise PatchOperationError(operation_index, str(exc)) from exc
485
+
486
+ return planned
487
+
488
+
489
+ def _verify_original(planned: _PlannedOperation) -> None:
490
+ if planned.original_digest is None:
491
+ if _exists(planned.source):
492
+ raise PatchError(f"file appeared while patch was prepared: {planned.operation.path}")
493
+ return
494
+ if not _exists(planned.source):
495
+ raise PatchError(f"file disappeared while patch was prepared: {planned.operation.path}")
496
+ if _digest(planned.source) != planned.original_digest:
497
+ raise PatchError(f"file changed while patch was prepared: {planned.operation.path}")
498
+
499
+
500
+ def apply_patch_text(patch_text: str, *, root: str | Path | None = None, allow_absolute: bool = False, dry_run: bool = False) -> PatchResult:
501
+ """Apply a patch and return one result record per file operation."""
502
+ operations = parse_patch(patch_text)
503
+ root_path = Path(root or Path.cwd()).resolve()
504
+
505
+ try:
506
+ planned = _plan_operations(
507
+ operations,
508
+ root=root_path,
509
+ allow_absolute=allow_absolute,
510
+ )
511
+ except PatchError as exc:
512
+ error = str(exc)
513
+ failed_index = (
514
+ exc.operation_index if isinstance(exc, PatchOperationError) else 0
515
+ )
516
+ return PatchResult(
517
+ operations=_not_applied_results(operations, failed_index, error),
518
+ error=error,
519
+ )
520
+
521
+ if dry_run:
522
+ return PatchResult(
523
+ operations=[
524
+ _operation_result(operation.operation, "would apply")
525
+ for operation in planned
526
+ ]
527
+ )
528
+
529
+ results: list[PatchOperationResult] = []
530
+ for index, item in enumerate(planned):
531
+ operation = item.operation
532
+ try:
533
+ _verify_original(item)
534
+ if operation.action == "add":
535
+ write_text_file(
536
+ item.source,
537
+ item.content or "",
538
+ file_format=item.file_format,
539
+ )
540
+ elif operation.action == "delete":
541
+ item.source.unlink()
542
+ elif operation.action == "update":
543
+ if item.destination is not None:
544
+ if _exists(item.destination):
545
+ raise PatchError(
546
+ f"move destination appeared while patch was prepared: "
547
+ f"{operation.move_to}"
548
+ )
549
+ write_text_file(
550
+ item.destination,
551
+ item.content or "",
552
+ file_format=item.file_format,
553
+ mode=item.original_mode,
554
+ )
555
+ item.source.unlink()
556
+ else:
557
+ write_text_file(
558
+ item.source,
559
+ item.content or "",
560
+ file_format=item.file_format,
561
+ mode=item.original_mode,
562
+ )
563
+ else:
564
+ raise PatchError(f"unknown patch action: {operation.action}")
565
+ results.append(_operation_result(operation, "applied"))
566
+ except (PatchError, OSError, UnicodeError) as exc:
567
+ message = str(exc)
568
+ results.append(_operation_result(operation, "failed", message))
569
+ for remaining in planned[index + 1:]:
570
+ results.append(
571
+ _operation_result(
572
+ remaining.operation,
573
+ "not applied",
574
+ "not applied because an earlier operation failed",
575
+ )
576
+ )
577
+ return PatchResult(operations=results, error=message)
578
+
579
+ return PatchResult(operations=results)
580
+
581
+
582
+ @tool(alias="code", filesystem=True)
583
+ async def apply_patch(patch: str, *, ctx: BeforeToolCallCtx) -> str:
584
+ """Apply a text patch to files under the agent's current working directory."""
585
+ max_chars = ctx.run.agent.config.get(max_patch_chars)
586
+ if len(patch) > max_chars:
587
+ return f"PATCH ERROR: patch is too large; maximum size is {max_chars} characters"
588
+
589
+ try:
590
+ async with ctx.run.agent._filesystem_lock:
591
+ result = await asyncio.to_thread(
592
+ apply_patch_text,
593
+ patch,
594
+ root=Path.cwd(),
595
+ allow_absolute=ctx.run.agent.config.get(allow_absolute_paths),
596
+ )
597
+ except PatchError as exc:
598
+ return f"PATCH ERROR: {exc}"
599
+ return result.render()
600
+
601
+
602
+ __all__ = [
603
+ "FileOp",
604
+ "Hunk",
605
+ "PatchError",
606
+ "PatchOperationResult",
607
+ "PatchResult",
608
+ "max_patch_chars",
609
+ "apply_patch",
610
+ "apply_patch_guidance",
611
+ "apply_patch_text",
612
+ "apply_update",
613
+ "parse_patch",
614
+ ]