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.
- commamatrix/__init__.py +16 -0
- commamatrix/builtin/__init__.py +42 -0
- commamatrix/builtin/apply_patch.py +614 -0
- commamatrix/builtin/codeact/__init__.py +37 -0
- commamatrix/builtin/codeact/executor/__init__.py +13 -0
- commamatrix/builtin/codeact/executor/backend.py +73 -0
- commamatrix/builtin/codeact/executor/docker.py +32 -0
- commamatrix/builtin/codeact/executor/subproc.py +242 -0
- commamatrix/builtin/codeact/executor/systemd.py +36 -0
- commamatrix/builtin/codeact/executor/worker.py +425 -0
- commamatrix/builtin/codeact/hooks.py +35 -0
- commamatrix/builtin/codeact/info.md +430 -0
- commamatrix/builtin/codeact/instructions.py +69 -0
- commamatrix/builtin/codeact/rpc/__init__.py +17 -0
- commamatrix/builtin/codeact/rpc/protocol.py +48 -0
- commamatrix/builtin/codeact/rpc/server.py +124 -0
- commamatrix/builtin/codeact/rpc/tcp.py +150 -0
- commamatrix/builtin/codeact/rpc/transport.py +23 -0
- commamatrix/builtin/codeact/search/__init__.py +8 -0
- commamatrix/builtin/codeact/search/api.py +51 -0
- commamatrix/builtin/codeact/search/bm25.py +78 -0
- commamatrix/builtin/codeact/service.py +140 -0
- commamatrix/builtin/codeact/tools.py +77 -0
- commamatrix/builtin/data_tools.py +318 -0
- commamatrix/builtin/filesystem.py +39 -0
- commamatrix/builtin/http_connector/__init__.py +41 -0
- commamatrix/builtin/http_connector/auth.py +326 -0
- commamatrix/builtin/http_connector/connector.py +1091 -0
- commamatrix/builtin/http_connector/ui/index.html +149 -0
- commamatrix/builtin/http_connector/ui/logo.svg +1 -0
- commamatrix/builtin/instructions/__init__.py +25 -0
- commamatrix/builtin/instructions/automation.py +23 -0
- commamatrix/builtin/instructions/coding.py +23 -0
- commamatrix/builtin/instructions/data_analysis.py +23 -0
- commamatrix/builtin/instructions/deep_research.py +22 -0
- commamatrix/builtin/instructions/default_instruction.py +16 -0
- commamatrix/builtin/instructions/roleplay.py +21 -0
- commamatrix/builtin/llm_http_adapter/__init__.py +30 -0
- commamatrix/builtin/llm_http_adapter/adapter.py +267 -0
- commamatrix/builtin/llm_http_adapter/anthropic_messages.py +343 -0
- commamatrix/builtin/llm_http_adapter/chat_completions.py +408 -0
- commamatrix/builtin/llm_http_adapter/codec.py +280 -0
- commamatrix/builtin/llm_http_adapter/responses.py +392 -0
- commamatrix/builtin/mcp/__init__.py +43 -0
- commamatrix/builtin/mcp/config.py +155 -0
- commamatrix/builtin/mcp/hooks.py +21 -0
- commamatrix/builtin/mcp/instructions.py +28 -0
- commamatrix/builtin/mcp/loader.py +90 -0
- commamatrix/builtin/mcp/manager.py +164 -0
- commamatrix/builtin/mcp/result.py +53 -0
- commamatrix/builtin/mcp/runtime.py +222 -0
- commamatrix/builtin/mcp/server.py +126 -0
- commamatrix/builtin/mcp/source.py +110 -0
- commamatrix/builtin/multi_dialog.py +254 -0
- commamatrix/builtin/multi_user.py +153 -0
- commamatrix/builtin/planner/__init__.py +49 -0
- commamatrix/builtin/planner/decorators.py +57 -0
- commamatrix/builtin/planner/service.py +236 -0
- commamatrix/builtin/self_extension/__init__.py +12 -0
- commamatrix/builtin/self_extension/guides/codeact.md +63 -0
- commamatrix/builtin/self_extension/guides/configuration.md +62 -0
- commamatrix/builtin/self_extension/guides/connectors.md +92 -0
- commamatrix/builtin/self_extension/guides/dialog.md +63 -0
- commamatrix/builtin/self_extension/guides/hooks.md +107 -0
- commamatrix/builtin/self_extension/guides/http.md +63 -0
- commamatrix/builtin/self_extension/guides/instructions.md +46 -0
- commamatrix/builtin/self_extension/guides/lifecycle.md +62 -0
- commamatrix/builtin/self_extension/guides/main.md +196 -0
- commamatrix/builtin/self_extension/guides/mcp.md +128 -0
- commamatrix/builtin/self_extension/guides/planner.md +48 -0
- commamatrix/builtin/self_extension/guides/providers.md +59 -0
- commamatrix/builtin/self_extension/guides/runtime.md +86 -0
- commamatrix/builtin/self_extension/guides/security.md +54 -0
- commamatrix/builtin/self_extension/guides/services.md +78 -0
- commamatrix/builtin/self_extension/guides/tables.md +71 -0
- commamatrix/builtin/self_extension/guides/tools.md +119 -0
- commamatrix/builtin/self_extension/tools.py +102 -0
- commamatrix/builtin/simple_fs.py +72 -0
- commamatrix/builtin/sql/__init__.py +22 -0
- commamatrix/builtin/sql/postgres_storage.py +105 -0
- commamatrix/builtin/sql/sql_storage.py +473 -0
- commamatrix/builtin/sql/sqlite_storage.py +78 -0
- commamatrix/builtin/storage_utils.py +48 -0
- commamatrix/builtin/subagent/__init__.py +17 -0
- commamatrix/builtin/subagent/connector.py +107 -0
- commamatrix/builtin/subagent/hooks.py +71 -0
- commamatrix/builtin/subagent/policy.py +42 -0
- commamatrix/builtin/subagent/service.py +42 -0
- commamatrix/builtin/subagent/submit.py +139 -0
- commamatrix/builtin/subagent/tools.py +43 -0
- commamatrix/builtin/web_utils/__init__.py +22 -0
- commamatrix/builtin/web_utils/instructions.py +18 -0
- commamatrix/builtin/web_utils/security.py +50 -0
- commamatrix/builtin/web_utils/tools.py +134 -0
- commamatrix/components/__init__.py +272 -0
- commamatrix/components/config.py +128 -0
- commamatrix/components/connector.py +170 -0
- commamatrix/components/dialog.py +127 -0
- commamatrix/components/file_storage.py +287 -0
- commamatrix/components/hook.py +478 -0
- commamatrix/components/http_client.py +71 -0
- commamatrix/components/instruction.py +237 -0
- commamatrix/components/llm_adapter.py +327 -0
- commamatrix/components/server.py +234 -0
- commamatrix/components/storage.py +94 -0
- commamatrix/components/table.py +214 -0
- commamatrix/components/tool.py +559 -0
- commamatrix/core/__init__.py +63 -0
- commamatrix/core/agent/__init__.py +17 -0
- commamatrix/core/agent/agent.py +822 -0
- commamatrix/core/agent/lifecycle.py +291 -0
- commamatrix/core/agent/runner.py +65 -0
- commamatrix/core/classes/__init__.py +57 -0
- commamatrix/core/classes/descriptor.py +34 -0
- commamatrix/core/classes/lifecycle_registry.py +78 -0
- commamatrix/core/classes/manager.py +457 -0
- commamatrix/core/classes/ordering.py +160 -0
- commamatrix/core/classes/service.py +70 -0
- commamatrix/core/classes/source.py +142 -0
- commamatrix/core/extensions.py +265 -0
- commamatrix/presets.py +82 -0
- commamatrix/utils.py +261 -0
- commamatrix-0.1.0.dist-info/METADATA +321 -0
- commamatrix-0.1.0.dist-info/RECORD +126 -0
- commamatrix-0.1.0.dist-info/WHEEL +4 -0
- commamatrix-0.1.0.dist-info/licenses/LICENSE +21 -0
commamatrix/__init__.py
ADDED
|
@@ -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
|
+
]
|