77"""
88
99import glob
10+ import json
1011import os
1112import subprocess
1213import tempfile
1920DEFAULT_READ_LIMIT = 200
2021MAX_LINE_CHARS = 10_000
2122MAX_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" )
2228GREP_EXCLUDED_DIRS = (".git" , "__pycache__" , "node_modules" , ".venv" , "target" , "dist" , "build" )
2329
2430
@@ -37,7 +43,16 @@ def _within(path: str, folder: str) -> bool:
3743
3844
3945def _readable (path : str , render_context : RenderContext ) -> bool :
40- return _within (path , _build_folder (render_context )) or _within (path , os .path .normpath (os .getcwd ()))
46+ return (
47+ _within (path , _build_folder (render_context ))
48+ or _within (path , os .path .normpath (os .getcwd ()))
49+ or path in render_context .unit_tests_running_context .readable_log_paths
50+ )
51+
52+
53+ def register_log_path (log_path : str , render_context : RenderContext ) -> None :
54+ """Allow read_file/grep on a full test log the agent is pointed to."""
55+ render_context .unit_tests_running_context .readable_log_paths .add (os .path .normpath (os .path .abspath (log_path )))
4156
4257
4358def _writable (path : str , render_context : RenderContext ) -> bool :
@@ -58,8 +73,10 @@ def _bound(text: str) -> str:
5873
5974
6075def _track_change (full_path : str , render_context : RenderContext ) -> None :
61- relative_path = os .path .relpath (full_path , _build_folder (render_context ))
62- render_context .unit_tests_running_context .changed_files .add (relative_path )
76+ context = render_context .unit_tests_running_context
77+ context .changed_files .add (os .path .relpath (full_path , _build_folder (render_context )))
78+ context .verified_passing , context .verified_passing_log_path = False , None
79+ context .tool_result_cache .clear ()
6380
6481
6582def read_file (args : dict , render_context : RenderContext ) -> str :
@@ -98,7 +115,13 @@ def grep(args: dict, render_context: RenderContext) -> str:
98115 # is the form the other tools accept.
99116 build_folder = _build_folder (render_context )
100117 cwd = build_folder if _within (target , build_folder ) else os .getcwd ()
101- command = ["grep" , "-rnI" , * [f"--exclude-dir={ d } " for d in GREP_EXCLUDED_DIRS ], "-e" , pattern , "--" ]
118+ options = [f"--exclude-dir={ d } " for d in GREP_EXCLUDED_DIRS ]
119+ context_lines = min (max (int (args .get ("context_lines" ) or 0 ), 0 ), MAX_GREP_CONTEXT_LINES )
120+ if context_lines :
121+ options .append (f"-C{ context_lines } " )
122+ if args .get ("include" ):
123+ options .append (f"--include={ args ['include' ]} " )
124+ command = ["grep" , "-rnI" , * options , "-e" , pattern , "--" ]
102125 command .append (os .path .relpath (target , cwd ) if _within (target , cwd ) else target )
103126 result = subprocess .run (command , capture_output = True , text = True , cwd = cwd )
104127 if result .returncode == 1 :
@@ -118,7 +141,7 @@ def ls_files(args: dict, render_context: RenderContext) -> str:
118141 listing = [entry + "/" if os .path .isdir (os .path .join (target , entry )) else entry for entry in entries ]
119142 return f"{ target } :\n " + ("\n " .join (listing ) if listing else "(empty)" )
120143 matches = sorted (glob .glob (target , recursive = True ))
121- return "\n " .join (matches ) if matches else f"No files match '{ target } '."
144+ return _bound ( "\n " .join (matches ) ) if matches else f"No files match '{ target } '."
122145
123146
124147def edit_file (args : dict , render_context : RenderContext ) -> str :
@@ -141,7 +164,18 @@ def edit_file(args: dict, render_context: RenderContext) -> str:
141164 with open (full_path , "w" , encoding = "utf-8" ) as f :
142165 f .write (content .replace (search , replace , 1 ))
143166 _track_change (full_path , render_context )
144- return f"Edited '{ full_path } '."
167+ return f"Edited '{ full_path } '. The edited region now reads:\n " + _edit_snippet (content , search , replace )
168+
169+
170+ def _edit_snippet (content : str , search : str , replace : str ) -> str :
171+ """Numbered lines of the replacement plus a little context, so the edit needs no re-read."""
172+ lines = content .replace (search , replace , 1 ).split ("\n " )
173+ first = content [: content .index (search )].count ("\n " )
174+ last = first + replace .count ("\n " )
175+ start = max (first - EDIT_SNIPPET_CONTEXT_LINES , 0 )
176+ end = min (last + EDIT_SNIPPET_CONTEXT_LINES + 1 , len (lines ), start + MAX_EDIT_SNIPPET_LINES )
177+ snippet = "\n " .join (f"{ start + i + 1 } : { line } " for i , line in enumerate (lines [start :end ]))
178+ return _bound (snippet )
145179
146180
147181def write_file (args : dict , render_context : RenderContext ) -> str :
@@ -166,25 +200,37 @@ def delete_file(args: dict, render_context: RenderContext) -> str:
166200 return f"Deleted '{ full_path } '."
167201
168202
169- def run_unit_tests (_args : dict , render_context : RenderContext ) -> str :
203+ def full_log_pointer (log_file_path : str | None ) -> str :
204+ return f" Full log: { log_file_path } (search it with grep, passing that path as file_path)." if log_file_path else ""
205+
206+
207+ def run_unit_tests (_args : dict , render_context : RenderContext ) -> dict :
208+ """Returns a result dict: a short header in `output` and the raw failure output in
209+ `test_output`, which the server condenses (summarizing it when it is long)."""
170210 exit_code , output , log_file_path = render_utils .execute_script (
171211 os .path .normpath (render_context .unittests_script ),
172212 [render_context .build_folder ],
173213 "Unit Tests" ,
174214 timeout = render_context .test_script_timeout ,
175215 stop_event = render_context .stop_event ,
176216 )
217+ context = render_context .unit_tests_running_context
177218 if exit_code == 0 :
178- return "All unit tests passed."
219+ context .verified_passing , context .verified_passing_log_path = True , log_file_path
220+ return {"output" : "All unit tests passed." }
179221 if not log_file_path and output :
180222 with tempfile .NamedTemporaryFile ("w" , encoding = "utf-8" , delete = False , suffix = ".unittest_output" ) as f :
181223 f .write (output )
182224 log_file_path = f .name
183- pointer = f" Full output: { log_file_path } (use read_file)." if log_file_path else ""
184- return f"Unit tests failed (exit code { exit_code } ).{ pointer } \n { _bound (output )} "
225+ if log_file_path :
226+ register_log_path (log_file_path , render_context )
227+ return {
228+ "output" : f"Unit tests failed (exit code { exit_code } ).{ full_log_pointer (log_file_path )} " ,
229+ "test_output" : output ,
230+ }
185231
186232
187- TOOLS : dict [str , Callable [[dict , RenderContext ], str ]] = {
233+ TOOLS : dict [str , Callable [[dict , RenderContext ], str | dict ]] = {
188234 "read_file" : read_file ,
189235 "grep" : grep ,
190236 "ls_files" : ls_files ,
@@ -196,17 +242,31 @@ def run_unit_tests(_args: dict, render_context: RenderContext) -> str:
196242
197243
198244def execute_calls (calls : list [dict ], render_context : RenderContext ) -> list [dict ]:
199- """Execute the agent's tool calls in order; every call gets a result, errors included."""
245+ """Execute the agent's tool calls in order; every call gets a result, errors included.
246+
247+ A repeated read-only call (same tool and arguments, no file changed in between) is not
248+ re-executed: its result is already in the conversation, so a short pointer is returned."""
249+ cache = render_context .unit_tests_running_context .tool_result_cache
200250 results = []
201251 for call in calls :
202252 tool = TOOLS .get (call ["name" ])
253+ cache_key = json .dumps ([call ["name" ], call .get ("args" ) or {}], sort_keys = True )
254+ result : dict
203255 if tool is None :
204- output = f"Error: unknown tool '{ call ['name' ]} '."
256+ result = {"output" : f"Error: unknown tool '{ call ['name' ]} '." }
257+ elif call ["name" ] in READ_ONLY_TOOLS and cache_key in cache :
258+ result = {
259+ "output" : "Same call as an earlier one and no file has changed since; "
260+ "its result is unchanged (see the earlier result above)."
261+ }
205262 else :
206263 try :
207264 output = tool (call .get ("args" ) or {}, render_context )
265+ result = output if isinstance (output , dict ) else {"output" : output }
208266 except Exception as e :
209- output = f"Error: tool '{ call ['name' ]} ' failed: { type (e ).__name__ } : { e } "
210- console .debug (f"Agent tool { call ['name' ]} ({ call .get ('args' )} ) -> { output [:200 ]!r} " )
211- results .append ({"call_id" : call ["id" ], "output" : output })
267+ result = {"output" : f"Error: tool '{ call ['name' ]} ' failed: { type (e ).__name__ } : { e } " }
268+ if call ["name" ] in READ_ONLY_TOOLS and not result ["output" ].startswith ("Error" ):
269+ cache [cache_key ] = result ["output" ]
270+ console .debug (f"Agent tool { call ['name' ]} ({ call .get ('args' )} ) -> { result ['output' ][:200 ]!r} " )
271+ results .append ({"call_id" : call ["id" ], ** result })
212272 return results
0 commit comments