Skip to content

Commit ba54aa0

Browse files
committed
Added a run_command tool
1 parent a2aeb24 commit ba54aa0

7 files changed

Lines changed: 294 additions & 4 deletions

render_machine/actions/agent_fix_conformance_test.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
list_files,
1515
ls_files,
1616
read_file,
17+
run_command,
1718
think,
1819
write_file,
1920
write_memory,
@@ -76,7 +77,7 @@ def execute(self, render_context: RenderContext, previous_action_payload: Any |
7677
frid_implementation_diff = self._build_implementation_diff_text(render_context)
7778
frid_conformance_tests_diff = self._build_conformance_tests_diff_text(render_context)
7879

79-
# Only provide code editing tools (no test/review tools)
80+
# Code editing tools plus run_command for diagnostics (no full test/review tools).
8081
tools = {
8182
"edit_file": edit_file,
8283
"write_file": write_file,
@@ -85,6 +86,7 @@ def execute(self, render_context: RenderContext, previous_action_payload: Any |
8586
"list_files": list_files,
8687
"ls_files": ls_files,
8788
"grep": grep,
89+
"run_command": run_command,
8890
"think": think,
8991
"write_memory": write_memory,
9092
}

render_machine/actions/agent_fix_unit_tests.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@
77
from render_machine.agent import agent_runner
88
from render_machine.agent.tool_executor import ToolExecutor
99
from render_machine.agent.tools import (
10-
MAX_INLINE_OUTPUT_LINES,
1110
delete_file,
1211
edit_file,
1312
grep,
1413
list_files,
1514
ls_files,
1615
read_file,
16+
run_command,
1717
run_unit_tests,
1818
think,
1919
write_file,
@@ -23,6 +23,7 @@
2323

2424
FIX_UNIT_TESTS_TOOLS = {
2525
"run_unit_tests": run_unit_tests,
26+
"run_command": run_command,
2627
"edit_file": edit_file,
2728
"write_file": write_file,
2829
"delete_file": delete_file,

render_machine/actions/agent_render_conformance_tests.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,16 @@
66
from render_machine.actions.base_action import BaseAction
77
from render_machine.agent import agent_runner
88
from render_machine.agent.tool_executor import ToolExecutor
9-
from render_machine.agent.tools import delete_file, edit_file, grep, list_files, ls_files, read_file, write_file
9+
from render_machine.agent.tools import (
10+
delete_file,
11+
edit_file,
12+
grep,
13+
list_files,
14+
ls_files,
15+
read_file,
16+
run_command,
17+
write_file,
18+
)
1019
from render_machine.render_context import RenderContext
1120
from render_machine.render_types import AcceptanceTestPhase, TestExecutionPhase
1221

@@ -18,6 +27,7 @@
1827
"list_files": list_files,
1928
"ls_files": ls_files,
2029
"grep": grep,
30+
"run_command": run_command,
2131
}
2232

2333

render_machine/actions/agent_render_functional_requirement.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,16 @@
77
from render_machine.actions.base_action import BaseAction
88
from render_machine.agent import agent_runner
99
from render_machine.agent.tool_executor import ToolExecutor
10-
from render_machine.agent.tools import delete_file, edit_file, grep, list_files, ls_files, read_file, write_file
10+
from render_machine.agent.tools import (
11+
delete_file,
12+
edit_file,
13+
grep,
14+
list_files,
15+
ls_files,
16+
read_file,
17+
run_command,
18+
write_file,
19+
)
1120
from render_machine.render_context import RenderContext
1221

1322
RENDER_FUNCTIONAL_REQUIREMENT_TOOLS = {
@@ -18,6 +27,7 @@
1827
"list_files": list_files,
1928
"ls_files": ls_files,
2029
"grep": grep,
30+
"run_command": run_command,
2131
}
2232

2333

render_machine/agent/tool_executor.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
DEFAULT_TOOLS: dict[str, ToolFunction] = {
1313
"run_unit_tests": tools.run_unit_tests,
1414
"run_conformance_tests": tools.run_conformance_tests,
15+
"run_command": tools.run_command,
1516
"edit_file": tools.edit_file,
1617
"write_file": tools.write_file,
1718
"delete_file": tools.delete_file,

render_machine/agent/tools.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,23 @@
1111
MAX_INLINE_OUTPUT_LINES = 200
1212
DEFAULT_READ_LIMIT = 200
1313
MAX_LINE_LENGTH = 10000 # Max characters per line to prevent context explosion
14+
MAX_COMMAND_TIMEOUT = 300 # Hard cap (seconds) for an agent run_command call
15+
16+
# Catastrophic command patterns refused by run_command as defense-in-depth. Commands
17+
# run on the user's own machine against their own project, but a fixing agent should
18+
# never need these, and refusing them guards against an accidental destructive call.
19+
_DESTRUCTIVE_COMMAND_PATTERNS = (
20+
"rm -rf /",
21+
"rm -rf /*",
22+
"rm -rf ~",
23+
"mkfs",
24+
"shutdown",
25+
"reboot",
26+
"dd if=",
27+
":(){",
28+
"> /dev/sd",
29+
"of=/dev/sd",
30+
)
1431

1532

1633
def run_unit_tests(args: dict, render_context: RenderContext) -> str:
@@ -433,6 +450,156 @@ def _get_linked_resource_paths(render_context: RenderContext) -> list[str]:
433450
return paths
434451

435452

453+
def _rejects_full_conformance_suite(command: str, render_context: RenderContext) -> str | None:
454+
"""Refuse commands that invoke the full conformance test suite.
455+
456+
The full suite is expensive and is run automatically when the agent submits its
457+
fix, so the agent never needs to run it itself. Steer it toward targeted
458+
diagnostics instead. (The unit test script is cheaper and not guarded.)
459+
"""
460+
script = render_context.conformance_tests_script
461+
if not script:
462+
return None
463+
candidates = {script, os.path.normpath(script), os.path.basename(script)}
464+
if any(c and c in command for c in candidates):
465+
return (
466+
"Error: Running the full conformance test suite via run_command is not allowed — it is "
467+
"expensive and runs automatically when you submit your fix. Use run_command for targeted "
468+
"diagnostics instead: reproduce the failing case directly (run a single function or a small "
469+
"snippet), inspect intermediate values, or probe a specific endpoint."
470+
)
471+
return None
472+
473+
474+
def _rejects_destructive_command(command: str) -> str | None:
475+
"""Refuse obviously catastrophic commands (defense-in-depth)."""
476+
lowered = command.lower()
477+
for pattern in _DESTRUCTIVE_COMMAND_PATTERNS:
478+
if pattern in lowered:
479+
return f"Error: command refused as potentially destructive (matched '{pattern}')."
480+
return None
481+
482+
483+
def _snapshot_folder_files(folders: list[str]) -> set[str]:
484+
"""Capture the set of file paths currently under the given folders."""
485+
snapshot: set[str] = set()
486+
for folder in folders:
487+
if not os.path.isdir(folder):
488+
continue
489+
for root, _dirs, names in os.walk(folder):
490+
for name in names:
491+
snapshot.add(os.path.join(root, name))
492+
return snapshot
493+
494+
495+
def _remove_files_created_since(folders: list[str], before: set[str]) -> int:
496+
"""Delete files under the given folders that were not present in the before-snapshot.
497+
498+
Used to undo the file side effects of run_command (e.g. compiled .class files and
499+
other build artifacts produced by `mvn test` and the like) so they never leak into
500+
git diffs, per-FRID commits, or the existing-files content passed to agents. Only
501+
newly-created files are removed; pre-existing files — including the agent's own
502+
in-progress edits — are left untouched.
503+
"""
504+
removed = 0
505+
for folder in folders:
506+
if not os.path.isdir(folder):
507+
continue
508+
for root, _dirs, names in os.walk(folder):
509+
for name in names:
510+
path = os.path.join(root, name)
511+
if path not in before:
512+
try:
513+
os.remove(path)
514+
removed += 1
515+
except OSError:
516+
pass
517+
return removed
518+
519+
520+
def run_command(args: dict, render_context: RenderContext) -> str:
521+
"""Run an arbitrary shell command for diagnostics and return its combined output.
522+
523+
The command runs from the project root. To run from elsewhere, prefix it with a cd
524+
(e.g. ``cd build && ...``). There is no working-directory restriction — a shell
525+
command can reach anywhere on the machine regardless, so a cwd guard would be
526+
meaningless; the only real guards are the suite/destructive refusals below.
527+
528+
Any files the command creates under the build or conformance test folders are
529+
removed afterwards, so build artifacts (e.g. compiled .class files from `mvn test`)
530+
don't pollute diffs, commits, or the files passed to agents. Write scratch files to
531+
/tmp if you need them to persist.
532+
533+
Args:
534+
args: Dictionary containing:
535+
- command (str, required): The shell command to run.
536+
- timeout (int, optional): Seconds before the command is killed. Defaults to
537+
the standard command timeout, capped at MAX_COMMAND_TIMEOUT.
538+
render_context: The current render context.
539+
540+
Returns:
541+
The exit code plus combined stdout/stderr (truncated, with the full output
542+
spilled to a temp file when large), or an error description.
543+
"""
544+
command = args.get("command", "")
545+
if not command or not command.strip():
546+
return "Error: command is required"
547+
548+
suite_rejection = _rejects_full_conformance_suite(command, render_context)
549+
if suite_rejection:
550+
return suite_rejection
551+
552+
destructive_rejection = _rejects_destructive_command(command)
553+
if destructive_rejection:
554+
return destructive_rejection
555+
556+
cwd = _get_project_root()
557+
558+
# Resolve the timeout (capped).
559+
timeout = args.get("timeout")
560+
if timeout is not None:
561+
try:
562+
timeout = min(int(timeout), MAX_COMMAND_TIMEOUT)
563+
except (ValueError, TypeError):
564+
timeout = render_utils.COMMAND_EXECUTION_TIMEOUT
565+
else:
566+
timeout = render_utils.COMMAND_EXECUTION_TIMEOUT
567+
568+
# Snapshot the tracked folders so any files the command creates (build artifacts
569+
# like compiled .class files) can be removed afterwards. These would otherwise be
570+
# picked up by git diffs / commits and leak into the context passed to agents.
571+
artifact_folders = _get_allowed_write_folders(render_context)
572+
files_before = _snapshot_folder_files(artifact_folders)
573+
try:
574+
exit_code, output, temp_file_path = render_utils.execute_command(
575+
command,
576+
cwd=cwd,
577+
timeout=timeout,
578+
stop_event=render_context.stop_event,
579+
)
580+
except Exception as e:
581+
return f"Error running command: {type(e).__name__}: {e}"
582+
finally:
583+
_remove_files_created_since(artifact_folders, files_before)
584+
585+
header = f"Command exited with code {exit_code} (cwd: {cwd})."
586+
if not output:
587+
return f"{header}\n(no output)"
588+
589+
lines = output.split("\n")
590+
if len(lines) <= MAX_INLINE_OUTPUT_LINES:
591+
return f"{header}\n{output}"
592+
593+
truncated = "\n".join(lines[:MAX_INLINE_OUTPUT_LINES])
594+
if temp_file_path:
595+
return (
596+
f"{header} Output truncated ({len(lines)} total lines). "
597+
f"Full output available at: {temp_file_path}\n"
598+
f'Use read_file with file_path="{temp_file_path}" to see the complete output.\n\n{truncated}'
599+
)
600+
return f"{header} Output truncated ({len(lines)} total lines).\n{truncated}"
601+
602+
436603
def read_file(args: dict, render_context: RenderContext) -> str:
437604
file_path = args.get("file_path", "")
438605
offset = args.get("offset")

0 commit comments

Comments
 (0)