semora-coding 0.2.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.
- semora_coding/__init__.py +7 -0
- semora_coding/builtins/__init__.py +249 -0
- semora_coding/builtins/_exec.py +303 -0
- semora_coding/builtins/_files.py +257 -0
- semora_coding/builtins/_search.py +384 -0
- semora_coding/builtins/_types.py +118 -0
- semora_coding/builtins/_web.py +209 -0
- semora_coding/goal.py +87 -0
- semora_coding/plan_mode.py +134 -0
- semora_coding/prompts.py +98 -0
- semora_coding/py.typed +0 -0
- semora_coding/skills.py +424 -0
- semora_coding/tool_search.py +224 -0
- semora_coding-0.2.0.dist-info/METADATA +32 -0
- semora_coding-0.2.0.dist-info/RECORD +16 -0
- semora_coding-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Workspace-backed read, write, and edit built-ins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import PurePath
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from semora.workspace import ToolContext, WorkspaceViolation
|
|
11
|
+
|
|
12
|
+
from ._types import BuiltinToolState, ToolResult, error_result, require_workspace, text_result
|
|
13
|
+
|
|
14
|
+
MAX_LINES = 2_000
|
|
15
|
+
MAX_BYTES = 8 * 1024 * 1024
|
|
16
|
+
NOTEBOOK_OUTPUT_LIMIT = 10 * 1024
|
|
17
|
+
IMAGE_MIME_BY_EXT = {
|
|
18
|
+
".png": "image/png",
|
|
19
|
+
".jpg": "image/jpeg",
|
|
20
|
+
".jpeg": "image/jpeg",
|
|
21
|
+
".webp": "image/webp",
|
|
22
|
+
".gif": "image/gif",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def read_tool(
|
|
27
|
+
_call_id: str, arguments: object, context: ToolContext, state: BuiltinToolState
|
|
28
|
+
) -> ToolResult:
|
|
29
|
+
"""Read a text file, image, notebook, or directory through ``WorkspaceFS``.
|
|
30
|
+
|
|
31
|
+
Ports ``createReadTool().execute`` and its ``numberLines`` expression.
|
|
32
|
+
"""
|
|
33
|
+
params = arguments if isinstance(arguments, dict) else {}
|
|
34
|
+
raw_path = params.get("path")
|
|
35
|
+
path = raw_path.strip() if isinstance(raw_path, str) else ""
|
|
36
|
+
if not path:
|
|
37
|
+
return error_result("path is required")
|
|
38
|
+
workspace = require_workspace(context)
|
|
39
|
+
if workspace is None:
|
|
40
|
+
return error_result(
|
|
41
|
+
"read requires an active workspace; configure AgentRuntime(workspace_provider=...) "
|
|
42
|
+
"or pass a ToolContext to builtin_tools()"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
stat = await workspace.fs.stat(path)
|
|
47
|
+
except FileNotFoundError:
|
|
48
|
+
return await _not_found(path, context)
|
|
49
|
+
except (OSError, ValueError, WorkspaceViolation) as error:
|
|
50
|
+
return error_result(f"Cannot read: {error}")
|
|
51
|
+
|
|
52
|
+
if stat.is_directory:
|
|
53
|
+
try:
|
|
54
|
+
entries = await workspace.fs.readdir(path)
|
|
55
|
+
except (OSError, ValueError, WorkspaceViolation) as error:
|
|
56
|
+
return error_result(f"Cannot read directory: {error}")
|
|
57
|
+
lines = sorted(entry.name + ("/" if entry.is_directory else "") for entry in entries)
|
|
58
|
+
return text_result(f"Directory: {path}\n\n" + "\n".join(lines))
|
|
59
|
+
if not stat.is_file:
|
|
60
|
+
return error_result(f"Cannot read: {path} is not a regular file")
|
|
61
|
+
|
|
62
|
+
suffix = PurePath(path).suffix.lower()
|
|
63
|
+
try:
|
|
64
|
+
data = await workspace.fs.read_file(path)
|
|
65
|
+
except (OSError, ValueError, WorkspaceViolation) as error:
|
|
66
|
+
return error_result(f"Cannot read: {error}")
|
|
67
|
+
|
|
68
|
+
if suffix in IMAGE_MIME_BY_EXT:
|
|
69
|
+
return {
|
|
70
|
+
"type": "image",
|
|
71
|
+
"data": base64.b64encode(data).decode("ascii"),
|
|
72
|
+
"mime_type": IMAGE_MIME_BY_EXT[suffix],
|
|
73
|
+
}
|
|
74
|
+
if suffix == ".pdf":
|
|
75
|
+
return error_result(
|
|
76
|
+
"Cannot read PDF: page rendering is not available in this runtime; "
|
|
77
|
+
"provide a PDF-capable tool if the application needs it"
|
|
78
|
+
)
|
|
79
|
+
if len(data) > MAX_BYTES:
|
|
80
|
+
return error_result(f"Cannot read: {path} exceeds {MAX_BYTES} byte cap ({len(data)} bytes)")
|
|
81
|
+
if suffix == ".ipynb":
|
|
82
|
+
return _read_notebook(path, data)
|
|
83
|
+
|
|
84
|
+
offset = _positive_int(params.get("offset"))
|
|
85
|
+
limit = _positive_int(params.get("limit"))
|
|
86
|
+
key = f"{workspace.id}\0{path}"
|
|
87
|
+
signature = (int(stat.mtime_ms), stat.size, offset, limit)
|
|
88
|
+
if state.read_files.get(key) == signature:
|
|
89
|
+
return text_result(
|
|
90
|
+
f"<file unchanged since you last read it — its content is already in context: {path}>"
|
|
91
|
+
)
|
|
92
|
+
state.read_files[key] = signature
|
|
93
|
+
return text_result(_number_lines(data.decode("utf-8", errors="replace"), offset, limit))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def write_tool(
|
|
97
|
+
_call_id: str, arguments: object, context: ToolContext, state: BuiltinToolState
|
|
98
|
+
) -> ToolResult:
|
|
99
|
+
"""Create or replace a workspace file, matching ``createWriteTool().execute``."""
|
|
100
|
+
params = arguments if isinstance(arguments, dict) else {}
|
|
101
|
+
raw_path = params.get("path")
|
|
102
|
+
path = raw_path.strip() if isinstance(raw_path, str) else ""
|
|
103
|
+
if not path:
|
|
104
|
+
return error_result("path is required")
|
|
105
|
+
content = params.get("content")
|
|
106
|
+
if not isinstance(content, str):
|
|
107
|
+
return error_result("content is required")
|
|
108
|
+
workspace = require_workspace(context)
|
|
109
|
+
if workspace is None:
|
|
110
|
+
return error_result("write requires an active workspace")
|
|
111
|
+
try:
|
|
112
|
+
resolved = await workspace.resolve(path, access="write")
|
|
113
|
+
async with state.file_lock(f"{workspace.id}\0{resolved.relative_path}"):
|
|
114
|
+
await workspace.fs.write_file(path, content.encode(), atomic=True)
|
|
115
|
+
except (OSError, ValueError, WorkspaceViolation) as error:
|
|
116
|
+
return error_result(f"Cannot write: {error}")
|
|
117
|
+
return text_result(f"Wrote {len(content)} bytes to {path}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def edit_tool(
|
|
121
|
+
_call_id: str, arguments: object, context: ToolContext, state: BuiltinToolState
|
|
122
|
+
) -> ToolResult:
|
|
123
|
+
"""Apply an exact string replacement, matching ``createEditTool().execute``."""
|
|
124
|
+
params = arguments if isinstance(arguments, dict) else {}
|
|
125
|
+
raw_path = params.get("path")
|
|
126
|
+
path = raw_path.strip() if isinstance(raw_path, str) else ""
|
|
127
|
+
if not path:
|
|
128
|
+
return error_result("path is required")
|
|
129
|
+
old = params.get("old_string")
|
|
130
|
+
new = params.get("new_string")
|
|
131
|
+
if not isinstance(old, str):
|
|
132
|
+
return error_result("old_string is required")
|
|
133
|
+
if not isinstance(new, str):
|
|
134
|
+
return error_result("new_string is required")
|
|
135
|
+
if old == new:
|
|
136
|
+
return error_result("old_string and new_string are identical")
|
|
137
|
+
workspace = require_workspace(context)
|
|
138
|
+
if workspace is None:
|
|
139
|
+
return error_result("edit requires an active workspace")
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
resolved = await workspace.resolve(path, access="readwrite")
|
|
143
|
+
async with state.file_lock(f"{workspace.id}\0{resolved.relative_path}"):
|
|
144
|
+
stat = await workspace.fs.stat(path)
|
|
145
|
+
if stat.is_directory:
|
|
146
|
+
return error_result(f"Cannot edit: {path} is a directory")
|
|
147
|
+
content = (await workspace.fs.read_file(path)).decode("utf-8")
|
|
148
|
+
occurrences = content.count(old)
|
|
149
|
+
if occurrences == 0:
|
|
150
|
+
return error_result("old_string not found in file")
|
|
151
|
+
replace_all = params.get("replace_all") is True
|
|
152
|
+
if occurrences > 1 and not replace_all:
|
|
153
|
+
return error_result(
|
|
154
|
+
f"old_string appears {occurrences} times; provide a more specific string "
|
|
155
|
+
"or set replace_all=true"
|
|
156
|
+
)
|
|
157
|
+
updated = content.replace(old, new) if replace_all else content.replace(old, new, 1)
|
|
158
|
+
await workspace.fs.write_file(
|
|
159
|
+
path, updated.encode(), mode=stat.mode & 0o777, atomic=True
|
|
160
|
+
)
|
|
161
|
+
except FileNotFoundError:
|
|
162
|
+
return error_result(f"Cannot edit: {path} not found")
|
|
163
|
+
except UnicodeDecodeError:
|
|
164
|
+
return error_result(f"Cannot edit: {path} is not UTF-8 text")
|
|
165
|
+
except (OSError, ValueError, WorkspaceViolation) as error:
|
|
166
|
+
return error_result(f"Cannot edit: {error}")
|
|
167
|
+
count = occurrences if params.get("replace_all") is True else 1
|
|
168
|
+
return text_result(f"Replaced {count} occurrence{'s' if count != 1 else ''} in {path}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _number_lines(content: str, offset: int | None, limit: int | None) -> str:
|
|
172
|
+
lines = content.split("\n")
|
|
173
|
+
start = offset - 1 if offset is not None else 0
|
|
174
|
+
count = min(limit, MAX_LINES) if limit is not None else MAX_LINES
|
|
175
|
+
end = min(start + count, len(lines))
|
|
176
|
+
numbered = "\n".join(
|
|
177
|
+
f"{line_number:>6}→{line}"
|
|
178
|
+
for line_number, line in enumerate(lines[start:end], start=start + 1)
|
|
179
|
+
)
|
|
180
|
+
if end < len(lines):
|
|
181
|
+
numbered += (
|
|
182
|
+
f"\n\n[Showing lines {start + 1}-{end} of {len(lines)}. "
|
|
183
|
+
f"Use offset={end + 1} to continue.]"
|
|
184
|
+
)
|
|
185
|
+
return numbered
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _positive_int(value: Any) -> int | None:
|
|
189
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
|
|
190
|
+
return None
|
|
191
|
+
return int(value)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def _not_found(path: str, context: ToolContext) -> ToolResult:
|
|
195
|
+
workspace = context.workspace
|
|
196
|
+
assert workspace is not None
|
|
197
|
+
pure = PurePath(path)
|
|
198
|
+
try:
|
|
199
|
+
entries = await workspace.fs.readdir(str(pure.parent))
|
|
200
|
+
except (OSError, ValueError, WorkspaceViolation):
|
|
201
|
+
return error_result(f"Cannot read: {path} not found")
|
|
202
|
+
match = next(
|
|
203
|
+
(
|
|
204
|
+
entry.name
|
|
205
|
+
for entry in entries
|
|
206
|
+
if entry.name != pure.name and PurePath(entry.name).stem == pure.stem
|
|
207
|
+
),
|
|
208
|
+
None,
|
|
209
|
+
)
|
|
210
|
+
suggestion = str(pure.parent / match) if match is not None else None
|
|
211
|
+
message = f"Cannot read: {path} not found"
|
|
212
|
+
if suggestion is not None:
|
|
213
|
+
message += f". Did you mean {suggestion}?"
|
|
214
|
+
return error_result(message)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _read_notebook(path: str, data: bytes) -> ToolResult:
|
|
218
|
+
try:
|
|
219
|
+
notebook = json.loads(data)
|
|
220
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
221
|
+
return error_result(f"Cannot read notebook {path}: {error}")
|
|
222
|
+
cells = notebook.get("cells") if isinstance(notebook, dict) else None
|
|
223
|
+
if not isinstance(cells, list):
|
|
224
|
+
return error_result(f"Cannot read notebook {path}: cells is not a list")
|
|
225
|
+
sections: list[str] = []
|
|
226
|
+
for index, cell in enumerate(cells, start=1):
|
|
227
|
+
if not isinstance(cell, dict):
|
|
228
|
+
continue
|
|
229
|
+
kind = cell.get("cell_type", "unknown")
|
|
230
|
+
source = cell.get("source", "")
|
|
231
|
+
text = "".join(source) if isinstance(source, list) else str(source)
|
|
232
|
+
sections.append(f"## Cell {index} ({kind})\n{text}")
|
|
233
|
+
outputs = cell.get("outputs", [])
|
|
234
|
+
if isinstance(outputs, list):
|
|
235
|
+
rendered = "".join(_notebook_output(item) for item in outputs)
|
|
236
|
+
if rendered:
|
|
237
|
+
sections.append(rendered[:NOTEBOOK_OUTPUT_LIMIT])
|
|
238
|
+
return text_result("\n\n".join(sections))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _notebook_output(output: object) -> str:
|
|
242
|
+
if not isinstance(output, dict):
|
|
243
|
+
return ""
|
|
244
|
+
for key in ("text", "traceback"):
|
|
245
|
+
value = output.get(key)
|
|
246
|
+
if isinstance(value, list):
|
|
247
|
+
return "".join(str(item) for item in value)
|
|
248
|
+
if isinstance(value, str):
|
|
249
|
+
return value
|
|
250
|
+
data = output.get("data")
|
|
251
|
+
if isinstance(data, dict):
|
|
252
|
+
text = data.get("text/plain")
|
|
253
|
+
if isinstance(text, list):
|
|
254
|
+
return "".join(str(item) for item in text)
|
|
255
|
+
if isinstance(text, str):
|
|
256
|
+
return text
|
|
257
|
+
return ""
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Workspace-backed glob and grep built-ins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import math
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
|
|
9
|
+
from semora.workspace import CommandResult, SandboxCommand, ToolContext, WorkspaceViolation
|
|
10
|
+
|
|
11
|
+
from ._types import (
|
|
12
|
+
BuiltinToolState,
|
|
13
|
+
ToolResult,
|
|
14
|
+
error_result,
|
|
15
|
+
require_workspace,
|
|
16
|
+
text_result,
|
|
17
|
+
tool_environment,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
SEARCH_TIMEOUT_SECONDS = 120.0
|
|
21
|
+
DEFAULT_HEAD_LIMIT = 250
|
|
22
|
+
MAX_COLUMNS = 500
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def glob_tool(
|
|
26
|
+
_call_id: str, arguments: object, context: ToolContext, state: BuiltinToolState
|
|
27
|
+
) -> ToolResult:
|
|
28
|
+
"""Find paths using ripgrep, porting ``createGlobTool().execute``."""
|
|
29
|
+
params = arguments if isinstance(arguments, dict) else {}
|
|
30
|
+
raw_pattern = params.get("pattern")
|
|
31
|
+
pattern = raw_pattern.strip() if isinstance(raw_pattern, str) else ""
|
|
32
|
+
if not pattern:
|
|
33
|
+
return error_result("pattern is required")
|
|
34
|
+
if ".." in pattern:
|
|
35
|
+
return error_result('pattern must not contain ".."')
|
|
36
|
+
workspace = require_workspace(context)
|
|
37
|
+
if workspace is None:
|
|
38
|
+
return error_result("glob requires an active workspace")
|
|
39
|
+
offset = _clamp_int(params.get("offset"), 0, 0)
|
|
40
|
+
head_limit = _clamp_int(params.get("head_limit"), DEFAULT_HEAD_LIMIT, 0)
|
|
41
|
+
raw_path = params.get("path")
|
|
42
|
+
path = raw_path.strip() if isinstance(raw_path, str) and raw_path.strip() else "."
|
|
43
|
+
try:
|
|
44
|
+
resolved = await workspace.fs.real_path(path)
|
|
45
|
+
except FileNotFoundError:
|
|
46
|
+
return text_result("No files found.")
|
|
47
|
+
except (OSError, ValueError, WorkspaceViolation) as error:
|
|
48
|
+
return error_result(f"Cannot glob: {error}")
|
|
49
|
+
if await _detect_engine(context, state) != "rg":
|
|
50
|
+
return error_result("glob requires ripgrep (rg), which was not found on PATH")
|
|
51
|
+
try:
|
|
52
|
+
process = await _run_engine(
|
|
53
|
+
context,
|
|
54
|
+
"rg",
|
|
55
|
+
["--files", "--glob", pattern, str(resolved.path)],
|
|
56
|
+
str(resolved.root),
|
|
57
|
+
)
|
|
58
|
+
except (OSError, ValueError, WorkspaceViolation) as error:
|
|
59
|
+
return error_result(f"Cannot glob: {error}")
|
|
60
|
+
classified = _classify(process, "glob")
|
|
61
|
+
if isinstance(classified, str):
|
|
62
|
+
return error_result(classified)
|
|
63
|
+
stdout, warning = classified
|
|
64
|
+
lines = [line for line in stdout.splitlines() if line]
|
|
65
|
+
if not lines:
|
|
66
|
+
return text_result("No files found." + warning)
|
|
67
|
+
return await _format_files(
|
|
68
|
+
lines, str(resolved.root), offset, head_limit, warning, context
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def grep_tool(
|
|
73
|
+
_call_id: str, arguments: object, context: ToolContext, state: BuiltinToolState
|
|
74
|
+
) -> ToolResult:
|
|
75
|
+
"""Search contents, porting ``createGrepTool().execute`` and argument builders."""
|
|
76
|
+
params = arguments if isinstance(arguments, dict) else {}
|
|
77
|
+
raw_pattern = params.get("pattern")
|
|
78
|
+
pattern = raw_pattern.strip() if isinstance(raw_pattern, str) else ""
|
|
79
|
+
if not pattern:
|
|
80
|
+
return error_result("pattern is required")
|
|
81
|
+
output_mode = params.get("output_mode", "content")
|
|
82
|
+
if output_mode not in {"content", "files_with_matches", "count"}:
|
|
83
|
+
return error_result("output_mode must be content, files_with_matches, or count")
|
|
84
|
+
raw_glob = params.get("glob")
|
|
85
|
+
glob = raw_glob.strip() if isinstance(raw_glob, str) and raw_glob.strip() else None
|
|
86
|
+
if glob is not None and ".." in glob:
|
|
87
|
+
return error_result('glob must not contain ".."')
|
|
88
|
+
workspace = require_workspace(context)
|
|
89
|
+
if workspace is None:
|
|
90
|
+
return error_result("grep requires an active workspace")
|
|
91
|
+
raw_path = params.get("path")
|
|
92
|
+
path = raw_path.strip() if isinstance(raw_path, str) and raw_path.strip() else "."
|
|
93
|
+
try:
|
|
94
|
+
resolved = await workspace.fs.real_path(path)
|
|
95
|
+
except FileNotFoundError:
|
|
96
|
+
return text_result("No matches found.")
|
|
97
|
+
except (OSError, ValueError, WorkspaceViolation) as error:
|
|
98
|
+
return error_result(f"Cannot grep: {error}")
|
|
99
|
+
|
|
100
|
+
offset = _clamp_int(params.get("offset"), 0, 0)
|
|
101
|
+
head_limit = _clamp_int(params.get("head_limit"), DEFAULT_HEAD_LIMIT, 0)
|
|
102
|
+
engine = await _detect_engine(context, state)
|
|
103
|
+
target = str(resolved.path)
|
|
104
|
+
args = (
|
|
105
|
+
_rg_args(params, pattern, str(output_mode), glob, target)
|
|
106
|
+
if engine == "rg"
|
|
107
|
+
else _grep_args(params, pattern, str(output_mode), glob, target)
|
|
108
|
+
)
|
|
109
|
+
try:
|
|
110
|
+
process = await _run_engine(context, engine, args, str(resolved.root))
|
|
111
|
+
except (OSError, ValueError, WorkspaceViolation) as error:
|
|
112
|
+
return error_result(f"Cannot grep: {error}")
|
|
113
|
+
classified = _classify(process, "grep")
|
|
114
|
+
if isinstance(classified, str):
|
|
115
|
+
return error_result(classified)
|
|
116
|
+
stdout, warning = classified
|
|
117
|
+
degraded = _grep_degradation(params) if engine == "grep" else ""
|
|
118
|
+
tail = warning + (f"\n\n{degraded}" if degraded else "")
|
|
119
|
+
lines = [line for line in stdout.splitlines() if line]
|
|
120
|
+
if not lines:
|
|
121
|
+
return text_result("No matches found." + tail)
|
|
122
|
+
root = str(resolved.root)
|
|
123
|
+
if output_mode == "files_with_matches":
|
|
124
|
+
return await _format_files(lines, root, offset, head_limit, tail, context)
|
|
125
|
+
if output_mode == "count":
|
|
126
|
+
return _format_count(lines, root, offset, head_limit, tail)
|
|
127
|
+
return _format_content(lines, root, offset, head_limit, tail)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def _detect_engine(context: ToolContext, state: BuiltinToolState) -> str:
|
|
131
|
+
workspace = context.workspace
|
|
132
|
+
assert workspace is not None
|
|
133
|
+
cached = state.search_engines.get(workspace.id)
|
|
134
|
+
if cached is not None:
|
|
135
|
+
return cached
|
|
136
|
+
try:
|
|
137
|
+
result = await _run_engine(context, "rg", ["--version"], str(workspace.root), timeout=5)
|
|
138
|
+
engine = "rg" if result.exit_code == 0 else "grep"
|
|
139
|
+
except (OSError, ValueError, WorkspaceViolation):
|
|
140
|
+
engine = "grep"
|
|
141
|
+
state.search_engines[workspace.id] = engine
|
|
142
|
+
return engine
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def _run_engine(
|
|
146
|
+
context: ToolContext,
|
|
147
|
+
engine: str,
|
|
148
|
+
args: Sequence[str],
|
|
149
|
+
cwd: str,
|
|
150
|
+
*,
|
|
151
|
+
timeout: float = SEARCH_TIMEOUT_SECONDS,
|
|
152
|
+
) -> CommandResult:
|
|
153
|
+
workspace = context.workspace
|
|
154
|
+
assert workspace is not None
|
|
155
|
+
return await workspace.run(
|
|
156
|
+
SandboxCommand(
|
|
157
|
+
argv=[engine, *args],
|
|
158
|
+
cwd=cwd,
|
|
159
|
+
env=tool_environment(()),
|
|
160
|
+
inherit_env=False,
|
|
161
|
+
timeout_seconds=timeout,
|
|
162
|
+
require_isolation=workspace.isolated,
|
|
163
|
+
allowed_domains=() if workspace.isolated else None,
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _classify(result: CommandResult, noun: str) -> tuple[str, str] | str:
|
|
169
|
+
has_stdout = bool(result.stdout.strip())
|
|
170
|
+
if result.aborted:
|
|
171
|
+
return (
|
|
172
|
+
(result.stdout, f"\n\n[{noun} aborted: partial results returned]")
|
|
173
|
+
if has_stdout
|
|
174
|
+
else f"{noun} aborted"
|
|
175
|
+
)
|
|
176
|
+
if result.timed_out:
|
|
177
|
+
return (
|
|
178
|
+
(result.stdout, f"\n\n[{noun} timed out: partial results returned]")
|
|
179
|
+
if has_stdout
|
|
180
|
+
else f"{noun} timed out"
|
|
181
|
+
)
|
|
182
|
+
if result.signal:
|
|
183
|
+
partial = f"\n\n[{noun} killed by signal {result.signal}: partial results returned]"
|
|
184
|
+
return (
|
|
185
|
+
(result.stdout, partial)
|
|
186
|
+
if has_stdout
|
|
187
|
+
else f"{noun} killed by signal {result.signal}"
|
|
188
|
+
)
|
|
189
|
+
if result.exit_code in {0, 1}:
|
|
190
|
+
return result.stdout, ""
|
|
191
|
+
detail = result.stderr.strip() or f"{noun} failed with exit {result.exit_code}"
|
|
192
|
+
if has_stdout:
|
|
193
|
+
warning = (
|
|
194
|
+
f"\n\n[{noun} exited {result.exit_code}: {detail}; partial results returned]"
|
|
195
|
+
)
|
|
196
|
+
return result.stdout, warning
|
|
197
|
+
return detail
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _rg_args(
|
|
201
|
+
params: Mapping[str, object],
|
|
202
|
+
pattern: str,
|
|
203
|
+
output_mode: str,
|
|
204
|
+
glob: str | None,
|
|
205
|
+
target: str,
|
|
206
|
+
) -> list[str]:
|
|
207
|
+
args = ["--max-columns", str(MAX_COLUMNS)]
|
|
208
|
+
if params.get("multiline") is True:
|
|
209
|
+
args.extend(["-U", "--multiline-dotall"])
|
|
210
|
+
if params.get("-i") is True:
|
|
211
|
+
args.append("-i")
|
|
212
|
+
if output_mode == "files_with_matches":
|
|
213
|
+
args.append("-l")
|
|
214
|
+
elif output_mode == "count":
|
|
215
|
+
args.append("-c")
|
|
216
|
+
else:
|
|
217
|
+
if params.get("-n", True) is not False:
|
|
218
|
+
args.append("-n")
|
|
219
|
+
_push_context(args, params)
|
|
220
|
+
_push_pattern(args, pattern)
|
|
221
|
+
file_type = params.get("type")
|
|
222
|
+
if isinstance(file_type, str) and file_type:
|
|
223
|
+
args.extend(["--type", file_type])
|
|
224
|
+
for item in _split_globs(glob):
|
|
225
|
+
args.extend(["--glob", item])
|
|
226
|
+
args.append(target)
|
|
227
|
+
return args
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _grep_args(
|
|
231
|
+
params: Mapping[str, object],
|
|
232
|
+
pattern: str,
|
|
233
|
+
output_mode: str,
|
|
234
|
+
glob: str | None,
|
|
235
|
+
target: str,
|
|
236
|
+
) -> list[str]:
|
|
237
|
+
args = ["-rE", "--color=never"]
|
|
238
|
+
if params.get("-i") is True:
|
|
239
|
+
args.append("-i")
|
|
240
|
+
if output_mode == "files_with_matches":
|
|
241
|
+
args.append("-l")
|
|
242
|
+
elif output_mode == "count":
|
|
243
|
+
args.append("-c")
|
|
244
|
+
else:
|
|
245
|
+
if params.get("-n", True) is not False:
|
|
246
|
+
args.append("-n")
|
|
247
|
+
_push_context(args, params)
|
|
248
|
+
for item in _split_globs(glob):
|
|
249
|
+
if "/" not in item:
|
|
250
|
+
args.append(f"--include={item}")
|
|
251
|
+
args.append("--")
|
|
252
|
+
_push_pattern(args, pattern)
|
|
253
|
+
args.append(target)
|
|
254
|
+
return args
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _push_context(args: list[str], params: Mapping[str, object]) -> None:
|
|
258
|
+
for key in ("context", "-C"):
|
|
259
|
+
if key in params:
|
|
260
|
+
args.extend(["-C", str(_clamp_int(params[key], 0, 0))])
|
|
261
|
+
return
|
|
262
|
+
if "-B" in params:
|
|
263
|
+
args.extend(["-B", str(_clamp_int(params["-B"], 0, 0))])
|
|
264
|
+
if "-A" in params:
|
|
265
|
+
args.extend(["-A", str(_clamp_int(params["-A"], 0, 0))])
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _push_pattern(args: list[str], pattern: str) -> None:
|
|
269
|
+
if pattern.startswith("-"):
|
|
270
|
+
args.extend(["-e", pattern])
|
|
271
|
+
else:
|
|
272
|
+
args.append(pattern)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _split_globs(glob: str | None) -> list[str]:
|
|
276
|
+
if glob is None:
|
|
277
|
+
return []
|
|
278
|
+
output: list[str] = []
|
|
279
|
+
for raw in glob.split():
|
|
280
|
+
output.extend([raw] if "{" in raw and "}" in raw else filter(None, raw.split(",")))
|
|
281
|
+
return output
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _grep_degradation(params: Mapping[str, object]) -> str:
|
|
285
|
+
lost: list[str] = []
|
|
286
|
+
if params.get("type"):
|
|
287
|
+
lost.append("type")
|
|
288
|
+
if params.get("multiline"):
|
|
289
|
+
lost.append("multiline")
|
|
290
|
+
lost.append(".gitignore not respected")
|
|
291
|
+
return f"[grep fallback: ripgrep not found — {', '.join(lost)} unavailable]"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
async def _format_files(
|
|
295
|
+
lines: list[str],
|
|
296
|
+
root: str,
|
|
297
|
+
offset: int,
|
|
298
|
+
head_limit: int,
|
|
299
|
+
tail: str,
|
|
300
|
+
context: ToolContext,
|
|
301
|
+
) -> ToolResult:
|
|
302
|
+
workspace = context.workspace
|
|
303
|
+
assert workspace is not None
|
|
304
|
+
|
|
305
|
+
async def mtime(path: str) -> float:
|
|
306
|
+
try:
|
|
307
|
+
return (await workspace.fs.stat(path)).mtime_ms
|
|
308
|
+
except (OSError, ValueError, WorkspaceViolation):
|
|
309
|
+
return 0
|
|
310
|
+
|
|
311
|
+
mtimes = await asyncio.gather(*(mtime(path) for path in lines))
|
|
312
|
+
sorted_lines = [
|
|
313
|
+
path
|
|
314
|
+
for path, _ in sorted(
|
|
315
|
+
zip(lines, mtimes, strict=True), key=lambda item: (-item[1], item[0])
|
|
316
|
+
)
|
|
317
|
+
]
|
|
318
|
+
items, limited = _page(sorted_lines, head_limit, offset)
|
|
319
|
+
relative = [_strip_root(path, root) for path in items]
|
|
320
|
+
info = _limit_info(head_limit if limited else None, offset)
|
|
321
|
+
noun = "file" if len(relative) == 1 else "files"
|
|
322
|
+
header = f"Found {len(relative)} {noun}" + (f" ({info})" if info else "")
|
|
323
|
+
return text_result(f"{header}\n" + "\n".join(relative) + tail)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _format_count(
|
|
327
|
+
lines: list[str], root: str, offset: int, head_limit: int, tail: str
|
|
328
|
+
) -> ToolResult:
|
|
329
|
+
items, limited = _page(lines, head_limit, offset)
|
|
330
|
+
total = 0
|
|
331
|
+
output: list[str] = []
|
|
332
|
+
for line in items:
|
|
333
|
+
path, separator, count = line.rpartition(":")
|
|
334
|
+
if separator and count.isdigit():
|
|
335
|
+
total += int(count)
|
|
336
|
+
output.append(f"{_strip_root(path, root)}:{count}")
|
|
337
|
+
else:
|
|
338
|
+
output.append(_strip_root(line, root))
|
|
339
|
+
info = _limit_info(head_limit if limited else None, offset)
|
|
340
|
+
noun = "occurrence" if total == 1 else "occurrences"
|
|
341
|
+
files = "file" if len(output) == 1 else "files"
|
|
342
|
+
summary = f"Found {total} total {noun} across {len(output)} {files}."
|
|
343
|
+
if info:
|
|
344
|
+
summary += f" ({info})"
|
|
345
|
+
return text_result("\n".join(output) + f"\n\n{summary}" + tail)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _format_content(
|
|
349
|
+
lines: list[str], root: str, offset: int, head_limit: int, tail: str
|
|
350
|
+
) -> ToolResult:
|
|
351
|
+
items, limited = _page(lines, head_limit, offset)
|
|
352
|
+
output = [_strip_content_root(line, root) for line in items]
|
|
353
|
+
info = _limit_info(head_limit if limited else None, offset)
|
|
354
|
+
footer = f"\n\n[pagination: {info}]" if info else ""
|
|
355
|
+
return text_result("\n".join(output) + footer + tail)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _strip_root(path: str, root: str) -> str:
|
|
359
|
+
prefix = root.rstrip("/") + "/"
|
|
360
|
+
return path[len(prefix) :] if path.startswith(prefix) else path
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _strip_content_root(line: str, root: str) -> str:
|
|
364
|
+
prefix = root.rstrip("/") + "/"
|
|
365
|
+
return line[len(prefix) :] if line.startswith(prefix) else line
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _page(items: list[str], limit: int, offset: int) -> tuple[list[str], bool]:
|
|
369
|
+
if limit == 0:
|
|
370
|
+
return items[offset:], False
|
|
371
|
+
return items[offset : offset + limit], len(items) - offset > limit
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _limit_info(limit: int | None, offset: int) -> str:
|
|
375
|
+
parts = ([f"limit {limit}"] if limit is not None else []) + (
|
|
376
|
+
[f"offset {offset}"] if offset else []
|
|
377
|
+
)
|
|
378
|
+
return ", ".join(parts)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _clamp_int(value: object, fallback: int, minimum: int) -> int:
|
|
382
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
383
|
+
return fallback
|
|
384
|
+
return max(minimum, int(value))
|