codeplain 0.3.11.dev19__py3-none-any.whl → 0.3.11.dev20__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.
- {codeplain-0.3.11.dev19.dist-info → codeplain-0.3.11.dev20.dist-info}/METADATA +1 -1
- {codeplain-0.3.11.dev19.dist-info → codeplain-0.3.11.dev20.dist-info}/RECORD +14 -11
- codeplain_REST_api.py +26 -32
- render_machine/actions/fix_unit_tests.py +224 -47
- render_machine/actions/run_unit_tests.py +9 -0
- render_machine/agent_tools.py +270 -0
- render_machine/render_context.py +9 -0
- render_machine/render_types.py +45 -0
- tests/test_agent_tools.py +166 -0
- tests/test_fix_unit_tests_action.py +239 -0
- tests/test_fix_unit_tests_conformance_context.py +120 -25
- {codeplain-0.3.11.dev19.dist-info → codeplain-0.3.11.dev20.dist-info}/WHEEL +0 -0
- {codeplain-0.3.11.dev19.dist-info → codeplain-0.3.11.dev20.dist-info}/entry_points.txt +0 -0
- {codeplain-0.3.11.dev19.dist-info → codeplain-0.3.11.dev20.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Client-side implementations of the tools a server-side agent can call.
|
|
2
|
+
|
|
3
|
+
The server declares the tools to the LLM (codeplain-api: src/agent/tools.py) and forwards
|
|
4
|
+
the model's calls; this module executes them against the local build folder and returns
|
|
5
|
+
plain-text results. Relative paths resolve against the build folder. Reads are allowed only in the
|
|
6
|
+
build folder and the full test logs the agent was pointed to; writes only inside the build folder.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import glob
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
from typing import Callable
|
|
15
|
+
|
|
16
|
+
from plain2code_console import console
|
|
17
|
+
from render_machine import render_utils
|
|
18
|
+
from render_machine.render_context import RenderContext
|
|
19
|
+
|
|
20
|
+
DEFAULT_READ_LIMIT = 200
|
|
21
|
+
MAX_LINE_CHARS = 10_000
|
|
22
|
+
MAX_OUTPUT_CHARS = 30_000
|
|
23
|
+
EDIT_SNIPPET_CONTEXT_LINES = 3
|
|
24
|
+
MAX_EDIT_SNIPPET_LINES = 60
|
|
25
|
+
MAX_GREP_CONTEXT_LINES = 20
|
|
26
|
+
# Tools without side effects; repeating one while no file changed returns a pointer instead.
|
|
27
|
+
READ_ONLY_TOOLS = ("read_file", "grep", "ls_files")
|
|
28
|
+
GREP_EXCLUDED_DIRS = (".git", "__pycache__", "node_modules", ".venv", "target", "dist", "build")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _build_folder(render_context: RenderContext) -> str:
|
|
32
|
+
return os.path.normpath(os.path.abspath(render_context.build_folder))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _resolve(file_path: str, render_context: RenderContext) -> str:
|
|
36
|
+
if os.path.isabs(file_path):
|
|
37
|
+
return os.path.normpath(file_path)
|
|
38
|
+
return os.path.normpath(os.path.join(_build_folder(render_context), file_path))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _within(path: str, folder: str) -> bool:
|
|
42
|
+
return path == folder or path.startswith(folder + os.sep)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _readable(path: str, render_context: RenderContext) -> bool:
|
|
46
|
+
return (
|
|
47
|
+
_within(path, _build_folder(render_context))
|
|
48
|
+
or path in render_context.unit_tests_agent_session.readable_log_paths
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def register_log_path(log_path: str, render_context: RenderContext) -> None:
|
|
53
|
+
"""Allow read_file/grep on a full test log the agent is pointed to."""
|
|
54
|
+
render_context.unit_tests_agent_session.readable_log_paths.add(os.path.normpath(os.path.abspath(log_path)))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _writable(path: str, render_context: RenderContext) -> bool:
|
|
58
|
+
return _within(path, _build_folder(render_context))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _bound(text: str) -> str:
|
|
62
|
+
"""Cap very long lines and the total size so one tool result cannot flood the context."""
|
|
63
|
+
lines = [
|
|
64
|
+
line if len(line) <= MAX_LINE_CHARS else line[:MAX_LINE_CHARS] + "... [line truncated]"
|
|
65
|
+
for line in text.split("\n")
|
|
66
|
+
]
|
|
67
|
+
text = "\n".join(lines)
|
|
68
|
+
if len(text) > MAX_OUTPUT_CHARS:
|
|
69
|
+
head, tail = int(MAX_OUTPUT_CHARS * 0.6), int(MAX_OUTPUT_CHARS * 0.4)
|
|
70
|
+
text = text[:head] + f"\n\n... [truncated {len(text) - head - tail:,} chars] ...\n\n" + text[-tail:]
|
|
71
|
+
return text
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _track_change(full_path: str, render_context: RenderContext) -> None:
|
|
75
|
+
context = render_context.unit_tests_running_context
|
|
76
|
+
context.changed_files.add(os.path.relpath(full_path, _build_folder(render_context)))
|
|
77
|
+
context.verified_passing, context.verified_passing_log_path = False, None
|
|
78
|
+
context.tool_result_cache.clear()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def read_file(args: dict, render_context: RenderContext) -> str:
|
|
82
|
+
full_path = _resolve(args.get("file_path", ""), render_context)
|
|
83
|
+
if not _readable(full_path, render_context):
|
|
84
|
+
return f"Error: read access denied for '{full_path}' (readable: build folder and test logs)."
|
|
85
|
+
if not os.path.isfile(full_path):
|
|
86
|
+
return f"Error: file not found: '{full_path}'."
|
|
87
|
+
with open(full_path, "r", encoding="utf-8", errors="replace") as f:
|
|
88
|
+
lines = f.read().split("\n")
|
|
89
|
+
offset = max(int(args.get("offset") or 1), 1)
|
|
90
|
+
limit = int(args.get("limit") or DEFAULT_READ_LIMIT)
|
|
91
|
+
selected = lines[offset - 1 : offset - 1 + limit]
|
|
92
|
+
if not selected:
|
|
93
|
+
return f"Error: offset {offset} is past the end of the file ({len(lines)} lines)."
|
|
94
|
+
numbered = "\n".join(f"{offset + i}: {line}" for i, line in enumerate(selected))
|
|
95
|
+
last = offset - 1 + len(selected)
|
|
96
|
+
note = (
|
|
97
|
+
f"\n[showing lines {offset}-{last} of {len(lines)}; use offset={last + 1} to continue]"
|
|
98
|
+
if last < len(lines)
|
|
99
|
+
else ""
|
|
100
|
+
)
|
|
101
|
+
return _bound(numbered) + note
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def grep(args: dict, render_context: RenderContext) -> str:
|
|
105
|
+
pattern = args.get("pattern", "")
|
|
106
|
+
if not pattern:
|
|
107
|
+
return "Error: pattern is required."
|
|
108
|
+
target = _resolve(args.get("file_path") or ".", render_context)
|
|
109
|
+
if not _readable(target, render_context):
|
|
110
|
+
return f"Error: read access denied for '{target}'."
|
|
111
|
+
if not os.path.exists(target):
|
|
112
|
+
return f"Error: path not found: '{target}'."
|
|
113
|
+
# Run from the build folder so matches inside it come back as build-relative paths, which
|
|
114
|
+
# is the form the other tools accept; a test log outside it is passed by absolute path.
|
|
115
|
+
cwd = _build_folder(render_context)
|
|
116
|
+
options = [f"--exclude-dir={d}" for d in GREP_EXCLUDED_DIRS]
|
|
117
|
+
context_lines = min(max(int(args.get("context_lines") or 0), 0), MAX_GREP_CONTEXT_LINES)
|
|
118
|
+
if context_lines:
|
|
119
|
+
options.append(f"-C{context_lines}")
|
|
120
|
+
if args.get("include"):
|
|
121
|
+
options.append(f"--include={args['include']}")
|
|
122
|
+
command = ["grep", "-rnI", *options, "-e", pattern, "--"]
|
|
123
|
+
command.append(os.path.relpath(target, cwd) if _within(target, cwd) else target)
|
|
124
|
+
result = subprocess.run(command, capture_output=True, text=True, cwd=cwd)
|
|
125
|
+
if result.returncode == 1:
|
|
126
|
+
return f"No matches for '{pattern}' in '{target}'."
|
|
127
|
+
if result.returncode != 0:
|
|
128
|
+
return f"Error: grep failed: {result.stderr.strip()}"
|
|
129
|
+
lines = [line[2:] if line.startswith("./") else line for line in result.stdout.rstrip("\n").split("\n")]
|
|
130
|
+
return _bound("\n".join(lines))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def ls_files(args: dict, render_context: RenderContext) -> str:
|
|
134
|
+
target = _resolve(args.get("pattern") or ".", render_context)
|
|
135
|
+
if not _readable(target, render_context):
|
|
136
|
+
return f"Error: read access denied for '{target}'."
|
|
137
|
+
if os.path.isdir(target):
|
|
138
|
+
entries = sorted(os.listdir(target))
|
|
139
|
+
listing = [entry + "/" if os.path.isdir(os.path.join(target, entry)) else entry for entry in entries]
|
|
140
|
+
return f"{target}:\n" + ("\n".join(listing) if listing else "(empty)")
|
|
141
|
+
matches = sorted(glob.glob(target, recursive=True))
|
|
142
|
+
return _bound("\n".join(matches)) if matches else f"No files match '{target}'."
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def edit_file(args: dict, render_context: RenderContext) -> str:
|
|
146
|
+
full_path = _resolve(args.get("file_path", ""), render_context)
|
|
147
|
+
search, replace = args.get("search", ""), args.get("replace", "")
|
|
148
|
+
if not search:
|
|
149
|
+
return "Error: search is required."
|
|
150
|
+
if not _writable(full_path, render_context):
|
|
151
|
+
return f"Error: write access denied for '{full_path}' (writable: build folder only)."
|
|
152
|
+
if not os.path.isfile(full_path):
|
|
153
|
+
return f"Error: file not found: '{full_path}'. Use write_file to create new files."
|
|
154
|
+
with open(full_path, "r", encoding="utf-8") as f:
|
|
155
|
+
content = f.read()
|
|
156
|
+
occurrences = content.count(search)
|
|
157
|
+
if occurrences != 1:
|
|
158
|
+
return (
|
|
159
|
+
f"Error: search text found {occurrences} times in '{full_path}'; it must appear exactly once. "
|
|
160
|
+
"Read the file and use a larger, unique snippet."
|
|
161
|
+
)
|
|
162
|
+
with open(full_path, "w", encoding="utf-8") as f:
|
|
163
|
+
f.write(content.replace(search, replace, 1))
|
|
164
|
+
_track_change(full_path, render_context)
|
|
165
|
+
return f"Edited '{full_path}'. The edited region now reads:\n" + _edit_snippet(content, search, replace)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _edit_snippet(content: str, search: str, replace: str) -> str:
|
|
169
|
+
"""Numbered lines of the replacement plus a little context, so the edit needs no re-read."""
|
|
170
|
+
lines = content.replace(search, replace, 1).split("\n")
|
|
171
|
+
first = content[: content.index(search)].count("\n")
|
|
172
|
+
last = first + replace.count("\n")
|
|
173
|
+
start = max(first - EDIT_SNIPPET_CONTEXT_LINES, 0)
|
|
174
|
+
end = min(last + EDIT_SNIPPET_CONTEXT_LINES + 1, len(lines), start + MAX_EDIT_SNIPPET_LINES)
|
|
175
|
+
snippet = "\n".join(f"{start + i + 1}: {line}" for i, line in enumerate(lines[start:end]))
|
|
176
|
+
return _bound(snippet)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def write_file(args: dict, render_context: RenderContext) -> str:
|
|
180
|
+
full_path = _resolve(args.get("file_path", ""), render_context)
|
|
181
|
+
if not _writable(full_path, render_context):
|
|
182
|
+
return f"Error: write access denied for '{full_path}' (writable: build folder only)."
|
|
183
|
+
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
|
184
|
+
with open(full_path, "w", encoding="utf-8") as f:
|
|
185
|
+
f.write(args.get("content", ""))
|
|
186
|
+
_track_change(full_path, render_context)
|
|
187
|
+
return f"Wrote '{full_path}'."
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def delete_file(args: dict, render_context: RenderContext) -> str:
|
|
191
|
+
full_path = _resolve(args.get("file_path", ""), render_context)
|
|
192
|
+
if not _writable(full_path, render_context):
|
|
193
|
+
return f"Error: write access denied for '{full_path}' (writable: build folder only)."
|
|
194
|
+
if not os.path.isfile(full_path):
|
|
195
|
+
return f"Error: file not found: '{full_path}'."
|
|
196
|
+
os.remove(full_path)
|
|
197
|
+
_track_change(full_path, render_context)
|
|
198
|
+
return f"Deleted '{full_path}'."
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def full_log_pointer(log_file_path: str | None) -> str:
|
|
202
|
+
return f" Full log: {log_file_path} (search it with grep, passing that path as file_path)." if log_file_path else ""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def run_unit_tests(_args: dict, render_context: RenderContext) -> dict:
|
|
206
|
+
"""Returns a result dict: a short header in `output` and the raw failure output in
|
|
207
|
+
`test_output`, which the server condenses (summarizing it when it is long)."""
|
|
208
|
+
exit_code, output, log_file_path = render_utils.execute_script(
|
|
209
|
+
os.path.normpath(render_context.unittests_script),
|
|
210
|
+
[render_context.build_folder],
|
|
211
|
+
"Unit Tests",
|
|
212
|
+
timeout=render_context.test_script_timeout,
|
|
213
|
+
stop_event=render_context.stop_event,
|
|
214
|
+
)
|
|
215
|
+
context = render_context.unit_tests_running_context
|
|
216
|
+
if exit_code == 0:
|
|
217
|
+
context.verified_passing, context.verified_passing_log_path = True, log_file_path
|
|
218
|
+
return {"output": "All unit tests passed."}
|
|
219
|
+
if not log_file_path and output:
|
|
220
|
+
with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False, suffix=".unittest_output") as f:
|
|
221
|
+
f.write(output)
|
|
222
|
+
log_file_path = f.name
|
|
223
|
+
if log_file_path:
|
|
224
|
+
register_log_path(log_file_path, render_context)
|
|
225
|
+
return {
|
|
226
|
+
"output": f"Unit tests failed (exit code {exit_code}).{full_log_pointer(log_file_path)}",
|
|
227
|
+
"test_output": output,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
TOOLS: dict[str, Callable[[dict, RenderContext], str | dict]] = {
|
|
232
|
+
"read_file": read_file,
|
|
233
|
+
"grep": grep,
|
|
234
|
+
"ls_files": ls_files,
|
|
235
|
+
"edit_file": edit_file,
|
|
236
|
+
"write_file": write_file,
|
|
237
|
+
"delete_file": delete_file,
|
|
238
|
+
"run_unit_tests": run_unit_tests,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def execute_calls(calls: list[dict], render_context: RenderContext) -> list[dict]:
|
|
243
|
+
"""Execute the agent's tool calls in order; every call gets a result, errors included.
|
|
244
|
+
|
|
245
|
+
A repeated read-only call (same tool and arguments, no file changed in between) is not
|
|
246
|
+
re-executed: its result is already in the conversation, so a short pointer is returned."""
|
|
247
|
+
cache = render_context.unit_tests_running_context.tool_result_cache
|
|
248
|
+
results = []
|
|
249
|
+
for call in calls:
|
|
250
|
+
tool = TOOLS.get(call["name"])
|
|
251
|
+
cache_key = json.dumps([call["name"], call.get("args") or {}], sort_keys=True)
|
|
252
|
+
result: dict
|
|
253
|
+
if tool is None:
|
|
254
|
+
result = {"output": f"Error: unknown tool '{call['name']}'."}
|
|
255
|
+
elif call["name"] in READ_ONLY_TOOLS and cache_key in cache:
|
|
256
|
+
result = {
|
|
257
|
+
"output": "Same call as an earlier one and no file has changed since; "
|
|
258
|
+
"its result is unchanged (see the earlier result above)."
|
|
259
|
+
}
|
|
260
|
+
else:
|
|
261
|
+
try:
|
|
262
|
+
output = tool(call.get("args") or {}, render_context)
|
|
263
|
+
result = output if isinstance(output, dict) else {"output": output}
|
|
264
|
+
except Exception as e:
|
|
265
|
+
result = {"output": f"Error: tool '{call['name']}' failed: {type(e).__name__}: {e}"}
|
|
266
|
+
if call["name"] in READ_ONLY_TOOLS and not result["output"].startswith("Error"):
|
|
267
|
+
cache[cache_key] = result["output"]
|
|
268
|
+
console.debug(f"Agent tool {call['name']}({call.get('args')}) -> {result['output'][:200]!r}")
|
|
269
|
+
results.append({"call_id": call["id"], **result})
|
|
270
|
+
return results
|
render_machine/render_context.py
CHANGED
|
@@ -19,6 +19,7 @@ from render_machine.render_types import (
|
|
|
19
19
|
FridContext,
|
|
20
20
|
ScriptExecutionHistory,
|
|
21
21
|
TestExecutionPhase,
|
|
22
|
+
UnitTestsAgentSession,
|
|
22
23
|
UnitTestsRunningContext,
|
|
23
24
|
)
|
|
24
25
|
|
|
@@ -188,6 +189,14 @@ class RenderContext:
|
|
|
188
189
|
def should_run_conformance_tests(self) -> bool:
|
|
189
190
|
return self.conformance_tests_script is not None
|
|
190
191
|
|
|
192
|
+
@property
|
|
193
|
+
def unit_tests_agent_session(self) -> UnitTestsAgentSession:
|
|
194
|
+
"""The agent session fixing the unit tests: per unit-test loop, except during the conformance
|
|
195
|
+
phase, where one session spans every loop (see UnitTestsAgentSession)."""
|
|
196
|
+
if self.conformance_tests_running_context is not None:
|
|
197
|
+
return self.conformance_tests_running_context.unit_tests_agent_session
|
|
198
|
+
return self.unit_tests_running_context.agent_session
|
|
199
|
+
|
|
191
200
|
def start_unittests_processing(self):
|
|
192
201
|
self.unit_tests_running_context = UnitTestsRunningContext(fix_attempts=0)
|
|
193
202
|
self.run_state.increment_unittest_batch_id()
|
render_machine/render_types.py
CHANGED
|
@@ -48,10 +48,53 @@ class FridContext:
|
|
|
48
48
|
refactoring_iteration: int = 0
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
@dataclass
|
|
52
|
+
class UnitTestsAgentSession:
|
|
53
|
+
"""Server-side agent session fixing a FRID's unit tests, and what it still has to be told.
|
|
54
|
+
|
|
55
|
+
Owned by the unit-tests running context (one session per unit-test loop) in the implementation
|
|
56
|
+
and refactoring phases, and by the conformance tests running context during the conformance
|
|
57
|
+
phase, so there one session spans every unit-test loop - the agent then sees that the
|
|
58
|
+
conformance tests fixer keeps changing the code it adjusts (see RenderContext.unit_tests_agent_session).
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
session_id: Optional[str] = None
|
|
62
|
+
# The submit_fix call the agent ended its last attempt with, answered with the next test
|
|
63
|
+
# run's outcome, plus results of any tool calls made in the same turn as submit_fix.
|
|
64
|
+
pending_submit_call_id: Optional[str] = None
|
|
65
|
+
pending_tool_results: list[dict] = field(default_factory=list)
|
|
66
|
+
# Session abandoned without a submission (turn budget used up, LLM failure); the next session
|
|
67
|
+
# starts with a digest of what it tried.
|
|
68
|
+
previous_session_id: Optional[str] = None
|
|
69
|
+
# Full test logs the agent was pointed to; readable by read_file/grep although outside the
|
|
70
|
+
# build folder.
|
|
71
|
+
readable_log_paths: set[str] = field(default_factory=set)
|
|
72
|
+
# How many of the conformance tests fixes (ConformanceTestsRunningContext.implementation_code_fixes)
|
|
73
|
+
# the session has already been shown.
|
|
74
|
+
conformance_fixes_handed_off: int = 0
|
|
75
|
+
|
|
76
|
+
def reset(self) -> None:
|
|
77
|
+
"""Drop the session (keeping its id as the previous one) so the next failure starts a new one."""
|
|
78
|
+
self.previous_session_id = self.session_id
|
|
79
|
+
self.session_id, self.pending_submit_call_id, self.pending_tool_results = None, None, []
|
|
80
|
+
self.conformance_fixes_handed_off = 0
|
|
81
|
+
|
|
82
|
+
|
|
51
83
|
@dataclass
|
|
52
84
|
class UnitTestsRunningContext:
|
|
53
85
|
fix_attempts: int
|
|
54
86
|
changed_files: set[str] = field(default_factory=set)
|
|
87
|
+
# The agent session of this loop; used only outside the conformance phase.
|
|
88
|
+
agent_session: UnitTestsAgentSession = field(default_factory=UnitTestsAgentSession)
|
|
89
|
+
# Whether the agent already made a fix attempt in this loop. A session that is still open when
|
|
90
|
+
# a loop starts had its last fix accepted, which is what the agent is told.
|
|
91
|
+
agent_used_in_this_loop: bool = False
|
|
92
|
+
# Set when the agent's own run_unit_tests passed and no file changed since, so the harness
|
|
93
|
+
# can accept the fix without running the suite again.
|
|
94
|
+
verified_passing: bool = False
|
|
95
|
+
verified_passing_log_path: Optional[str] = None
|
|
96
|
+
# Results of read-only tool calls, keyed by call; cleared whenever a file changes.
|
|
97
|
+
tool_result_cache: dict[str, str] = field(default_factory=dict)
|
|
55
98
|
|
|
56
99
|
|
|
57
100
|
class ConformanceTestsRunningContext:
|
|
@@ -95,6 +138,8 @@ class ConformanceTestsRunningContext:
|
|
|
95
138
|
# order. Each entry is {"hypothesis": str | None, "approach": str | None, "code_diff": {file: diff}}.
|
|
96
139
|
# Handed to the unit tests fixer so it adjusts the unit tests instead of reverting these changes.
|
|
97
140
|
self.implementation_code_fixes: list[dict] = []
|
|
141
|
+
# The unit-test fixing agent session, shared by every unit-test loop of this conformance phase.
|
|
142
|
+
self.unit_tests_agent_session = UnitTestsAgentSession()
|
|
98
143
|
|
|
99
144
|
def get_conformance_tests_json(self, module_name: str) -> dict:
|
|
100
145
|
return self._conformance_tests_json[module_name]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Tests for the client-side agent tool implementations."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from types import SimpleNamespace
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from render_machine import agent_tools
|
|
9
|
+
from render_machine.render_context import RenderContext
|
|
10
|
+
from render_machine.render_types import UnitTestsRunningContext
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FakeRenderContext(SimpleNamespace):
|
|
14
|
+
unit_tests_agent_session = RenderContext.unit_tests_agent_session
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def project(tmp_path, monkeypatch):
|
|
19
|
+
build = tmp_path / "plain_modules" / "m"
|
|
20
|
+
build.mkdir(parents=True)
|
|
21
|
+
(build / "app.py").write_text("def add(a, b):\n return a - b\n\n\ndef sub(a, b):\n return a - b\n")
|
|
22
|
+
(tmp_path / "outside.txt").write_text("outside\n")
|
|
23
|
+
monkeypatch.chdir(tmp_path)
|
|
24
|
+
render_context = FakeRenderContext(
|
|
25
|
+
build_folder=str(build),
|
|
26
|
+
unit_tests_running_context=UnitTestsRunningContext(fix_attempts=1),
|
|
27
|
+
conformance_tests_running_context=None,
|
|
28
|
+
unittests_script=None,
|
|
29
|
+
test_script_timeout=None,
|
|
30
|
+
stop_event=None,
|
|
31
|
+
)
|
|
32
|
+
return SimpleNamespace(root=tmp_path, build=build, rc=render_context)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_read_file_resolves_relative_to_build_folder_with_paging(project):
|
|
36
|
+
out = agent_tools.read_file({"file_path": "app.py", "offset": 2, "limit": 1}, project.rc)
|
|
37
|
+
assert out.startswith("2: return a - b")
|
|
38
|
+
assert "use offset=3 to continue" in out
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_read_is_confined_to_build_folder(project):
|
|
42
|
+
outside = str(project.root / "outside.txt")
|
|
43
|
+
assert agent_tools.read_file({"file_path": outside}, project.rc).startswith("Error: read access denied")
|
|
44
|
+
assert agent_tools.read_file({"file_path": "../../outside.txt"}, project.rc).startswith("Error: read access denied")
|
|
45
|
+
assert agent_tools.grep({"pattern": "outside", "file_path": outside}, project.rc).startswith("Error: read access")
|
|
46
|
+
assert agent_tools.ls_files({"pattern": str(project.root)}, project.rc).startswith("Error: read access denied")
|
|
47
|
+
assert agent_tools.read_file({"file_path": "/etc/hosts"}, project.rc).startswith("Error: read access denied")
|
|
48
|
+
assert agent_tools.read_file({"file_path": "missing.py"}, project.rc).startswith("Error: file not found")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_edit_file_requires_unique_match_and_tracks_change(project):
|
|
52
|
+
ambiguous = agent_tools.edit_file({"file_path": "app.py", "search": "return a - b", "replace": "x"}, project.rc)
|
|
53
|
+
assert "found 2 times" in ambiguous
|
|
54
|
+
assert project.rc.unit_tests_running_context.changed_files == set()
|
|
55
|
+
|
|
56
|
+
ok = agent_tools.edit_file(
|
|
57
|
+
{
|
|
58
|
+
"file_path": "app.py",
|
|
59
|
+
"search": "def add(a, b):\n return a - b",
|
|
60
|
+
"replace": "def add(a, b):\n return a + b",
|
|
61
|
+
},
|
|
62
|
+
project.rc,
|
|
63
|
+
)
|
|
64
|
+
assert ok.startswith("Edited")
|
|
65
|
+
assert "return a + b" in (project.build / "app.py").read_text()
|
|
66
|
+
assert project.rc.unit_tests_running_context.changed_files == {"app.py"}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_write_and_delete_are_confined_to_build_folder(project):
|
|
70
|
+
denied = agent_tools.write_file({"file_path": str(project.root / "evil.py"), "content": "x"}, project.rc)
|
|
71
|
+
assert denied.startswith("Error: write access denied")
|
|
72
|
+
assert not (project.root / "evil.py").exists()
|
|
73
|
+
|
|
74
|
+
assert agent_tools.write_file({"file_path": "pkg/new.py", "content": "print(1)\n"}, project.rc).startswith("Wrote")
|
|
75
|
+
assert (project.build / "pkg" / "new.py").read_text() == "print(1)\n"
|
|
76
|
+
assert agent_tools.delete_file({"file_path": "pkg/new.py"}, project.rc).startswith("Deleted")
|
|
77
|
+
assert not (project.build / "pkg" / "new.py").exists()
|
|
78
|
+
assert project.rc.unit_tests_running_context.changed_files == {os.path.join("pkg", "new.py")}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_grep_and_ls(project):
|
|
82
|
+
hits = agent_tools.grep({"pattern": "def sub"}, project.rc)
|
|
83
|
+
assert hits == "app.py:5:def sub(a, b):"
|
|
84
|
+
assert agent_tools.grep({"pattern": "nope"}, project.rc).startswith("No matches")
|
|
85
|
+
assert agent_tools.grep({"pattern": ""}, project.rc).startswith("Error")
|
|
86
|
+
|
|
87
|
+
assert agent_tools.ls_files({}, project.rc).splitlines()[1:] == ["app.py"]
|
|
88
|
+
assert agent_tools.ls_files({"pattern": "**/*.py"}, project.rc).endswith("app.py")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_execute_calls_answers_every_call_and_captures_errors(project, monkeypatch):
|
|
92
|
+
def boom(_args, _rc):
|
|
93
|
+
raise RuntimeError("kaput")
|
|
94
|
+
|
|
95
|
+
monkeypatch.setitem(agent_tools.TOOLS, "boom", boom)
|
|
96
|
+
results = agent_tools.execute_calls(
|
|
97
|
+
[
|
|
98
|
+
{"id": "1", "name": "read_file", "args": {"file_path": "app.py", "limit": 1}},
|
|
99
|
+
{"id": "2", "name": "unknown_tool", "args": {}},
|
|
100
|
+
{"id": "3", "name": "boom"},
|
|
101
|
+
],
|
|
102
|
+
project.rc,
|
|
103
|
+
)
|
|
104
|
+
assert [r["call_id"] for r in results] == ["1", "2", "3"]
|
|
105
|
+
assert results[0]["output"].startswith("1: def add")
|
|
106
|
+
assert results[1]["output"] == "Error: unknown tool 'unknown_tool'."
|
|
107
|
+
assert results[2]["output"] == "Error: tool 'boom' failed: RuntimeError: kaput"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_run_unit_tests_reports_pass_and_failure(project, monkeypatch):
|
|
111
|
+
log = project.root.parent / "log.txt"
|
|
112
|
+
log.write_text("FAILED test_x\nCaused by: boom\n")
|
|
113
|
+
outcomes = iter([(0, "", "/tmp/pass.txt"), (1, "FAILED test_x", str(log))])
|
|
114
|
+
monkeypatch.setattr(agent_tools.render_utils, "execute_script", lambda *a, **k: next(outcomes))
|
|
115
|
+
project.rc.unittests_script = "run_tests.sh"
|
|
116
|
+
context = project.rc.unit_tests_running_context
|
|
117
|
+
|
|
118
|
+
assert agent_tools.run_unit_tests({}, project.rc) == {"output": "All unit tests passed."}
|
|
119
|
+
assert context.verified_passing and context.verified_passing_log_path == "/tmp/pass.txt"
|
|
120
|
+
|
|
121
|
+
failure = agent_tools.run_unit_tests({}, project.rc)
|
|
122
|
+
assert failure["output"].startswith(f"Unit tests failed (exit code 1). Full log: {log}")
|
|
123
|
+
# raw output goes to the server for condensing, not truncated here
|
|
124
|
+
assert failure["test_output"] == "FAILED test_x"
|
|
125
|
+
# the full log is outside the build folder but greppable
|
|
126
|
+
assert agent_tools.grep({"pattern": "Caused by", "file_path": str(log)}, project.rc).endswith("Caused by: boom")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_file_change_invalidates_verified_pass_and_read_cache(project):
|
|
130
|
+
context = project.rc.unit_tests_running_context
|
|
131
|
+
context.verified_passing = True
|
|
132
|
+
first = agent_tools.execute_calls([{"id": "1", "name": "read_file", "args": {"file_path": "app.py"}}], project.rc)
|
|
133
|
+
repeat = agent_tools.execute_calls([{"id": "2", "name": "read_file", "args": {"file_path": "app.py"}}], project.rc)
|
|
134
|
+
assert first[0]["output"].startswith("1: def add")
|
|
135
|
+
assert repeat[0]["output"].startswith("Same call as an earlier one")
|
|
136
|
+
|
|
137
|
+
agent_tools.write_file({"file_path": "other.py", "content": "y = 1\n"}, project.rc)
|
|
138
|
+
assert context.verified_passing is False
|
|
139
|
+
again = agent_tools.execute_calls([{"id": "3", "name": "read_file", "args": {"file_path": "app.py"}}], project.rc)
|
|
140
|
+
assert again[0]["output"].startswith("1: def add")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_edit_file_returns_the_edited_region(project):
|
|
144
|
+
out = agent_tools.edit_file(
|
|
145
|
+
{
|
|
146
|
+
"file_path": "app.py",
|
|
147
|
+
"search": "def sub(a, b):\n return a - b",
|
|
148
|
+
"replace": "def sub(a, b):\n return b",
|
|
149
|
+
},
|
|
150
|
+
project.rc,
|
|
151
|
+
)
|
|
152
|
+
assert out.startswith("Edited") and "5: def sub(a, b):\n6: return b" in out
|
|
153
|
+
assert "2: return a - b" in out and "1: def add" not in out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_grep_context_lines_and_include(project):
|
|
157
|
+
(project.build / "notes.txt").write_text("def sub is documented here\n")
|
|
158
|
+
hits = agent_tools.grep({"pattern": "def sub", "context_lines": 1, "include": "*.py"}, project.rc)
|
|
159
|
+
assert "notes.txt" not in hits
|
|
160
|
+
assert "app.py-4-" in hits and "app.py:5:def sub(a, b):" in hits and "app.py-6-" in hits
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_bound_truncates_long_lines_and_large_output():
|
|
164
|
+
bounded = agent_tools._bound("a" * (agent_tools.MAX_LINE_CHARS + 5) + "\n" + "b\n" * 40_000)
|
|
165
|
+
assert "[line truncated]" in bounded and "[truncated" in bounded
|
|
166
|
+
assert len(bounded) < agent_tools.MAX_OUTPUT_CHARS + 200
|