fix: prevent unchanged execution retry loops
This commit is contained in:
@@ -70,6 +70,7 @@ EXECUTION_REQUEST = re.compile(
|
||||
MUTATION_TOOLS = {"write_file", "apply_patch"}
|
||||
VERIFICATION_TOOLS = {"read_file", "git_status", "git_diff", "exec", "poll_process"}
|
||||
EXECUTION_TOOLS = {"exec", "start_process", "poll_process"}
|
||||
REPEAT_GUARDED_EXECUTION_TOOLS = {"exec", "start_process"}
|
||||
MAX_CONSECUTIVE_CHECKPOINT_REJECTIONS = 3
|
||||
|
||||
|
||||
@@ -133,6 +134,28 @@ def recovery_message(failure: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def execution_recovery_message(failures: list[str]) -> str:
|
||||
details = "\n".join(f"- {failure}" for failure in failures)
|
||||
return (
|
||||
"Execution recovery is required before you may finish the task.\n"
|
||||
f"{details}\n"
|
||||
"Do not repeat an unchanged failed command. Text in assistant content does not edit a file. "
|
||||
"Use read_file or search_files to inspect the actual source, then use write_file or apply_patch "
|
||||
"to repair it (or use a materially different diagnostic command), and finally execute the relevant "
|
||||
"check successfully. Keep benchmark inputs small enough for quadratic algorithms to finish."
|
||||
)
|
||||
|
||||
|
||||
def execution_call_key(name: str, arguments: dict[str, Any]) -> str | None:
|
||||
if name not in REPEAT_GUARDED_EXECUTION_TOOLS:
|
||||
return None
|
||||
command = " ".join(str(arguments.get("command", "")).split())
|
||||
if not command:
|
||||
return None
|
||||
cwd = str(arguments.get("cwd", ".")).strip() or "."
|
||||
return f"{name}\0{cwd}\0{command}"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RunRecorder:
|
||||
store: RuntimeStore
|
||||
@@ -246,6 +269,7 @@ class AgentLoop:
|
||||
successful_tools = 0
|
||||
successful_executions = 0
|
||||
unresolved_execution_failure: str | None = None
|
||||
failed_execution_revisions: dict[str, int] = {}
|
||||
consecutive_checkpoint_rejections = 0
|
||||
for iteration in range(max_iterations):
|
||||
await recorder.emit(
|
||||
@@ -321,6 +345,20 @@ class AgentLoop:
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
messages.append(assistant_message)
|
||||
blocked_call_ids: set[str] = set()
|
||||
mutation_planned = False
|
||||
for call in tool_calls:
|
||||
function = call.get("function") or {}
|
||||
name = str(function.get("name", ""))
|
||||
try:
|
||||
arguments = json.loads(function.get("arguments") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
arguments = {}
|
||||
if name in MUTATION_TOOLS:
|
||||
mutation_planned = True
|
||||
key = execution_call_key(name, arguments) if isinstance(arguments, dict) else None
|
||||
if key and not mutation_planned and failed_execution_revisions.get(key) == mutation_count:
|
||||
blocked_call_ids.add(str(call.get("id") or ""))
|
||||
results = await self._execute_calls(
|
||||
calls=tool_calls,
|
||||
spec=spec,
|
||||
@@ -328,9 +366,16 @@ class AgentLoop:
|
||||
context=tool_context,
|
||||
depth=depth,
|
||||
read_only=read_only,
|
||||
blocked_call_ids=blocked_call_ids,
|
||||
)
|
||||
execution_failures: list[str] = []
|
||||
for call, result in zip(tool_calls, results, strict=True):
|
||||
name = str((call.get("function") or {}).get("name", ""))
|
||||
function = call.get("function") or {}
|
||||
name = str(function.get("name", ""))
|
||||
try:
|
||||
arguments = json.loads(function.get("arguments") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
arguments = {}
|
||||
if result.get("ok", False):
|
||||
successful_tools += 1
|
||||
if name in MUTATION_TOOLS:
|
||||
@@ -342,6 +387,10 @@ class AgentLoop:
|
||||
unresolved_execution_failure = None
|
||||
elif name in EXECUTION_TOOLS:
|
||||
unresolved_execution_failure = f"{name}: {public_tool_summary(result, 1000)}"
|
||||
execution_failures.append(unresolved_execution_failure)
|
||||
key = execution_call_key(name, arguments) if isinstance(arguments, dict) else None
|
||||
if key:
|
||||
failed_execution_revisions[key] = mutation_count
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
@@ -349,6 +398,13 @@ class AgentLoop:
|
||||
"content": tool_result_text(result, self.max_tool_output_chars),
|
||||
}
|
||||
)
|
||||
if execution_failures:
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": execution_recovery_message(execution_failures),
|
||||
}
|
||||
)
|
||||
|
||||
messages.append(
|
||||
{
|
||||
@@ -388,7 +444,9 @@ class AgentLoop:
|
||||
context: ToolContext,
|
||||
depth: int,
|
||||
read_only: bool,
|
||||
blocked_call_ids: set[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
blocked_call_ids = blocked_call_ids or set()
|
||||
parsed: list[tuple[int, dict[str, Any], str, dict[str, Any], bool]] = []
|
||||
for index, call in enumerate(calls):
|
||||
function = call.get("function") or {}
|
||||
@@ -419,6 +477,7 @@ class AgentLoop:
|
||||
context=context,
|
||||
depth=depth,
|
||||
read_only=read_only,
|
||||
blocked_repeat=str(call.get("id") or "") in blocked_call_ids,
|
||||
)
|
||||
|
||||
index = 0
|
||||
@@ -445,6 +504,7 @@ class AgentLoop:
|
||||
context: ToolContext,
|
||||
depth: int,
|
||||
read_only: bool,
|
||||
blocked_repeat: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
call_id = str(call.get("id") or uuid.uuid4().hex)
|
||||
public_args = {
|
||||
@@ -456,6 +516,15 @@ class AgentLoop:
|
||||
)
|
||||
if "__parse_error__" in arguments:
|
||||
result = {"ok": False, "error": arguments["__parse_error__"]}
|
||||
elif blocked_repeat:
|
||||
result = {
|
||||
"ok": False,
|
||||
"error": (
|
||||
"Execution policy blocked this unchanged retry because the same command already failed "
|
||||
"and no repair was applied afterward. Inspect and edit the underlying files first, or run "
|
||||
"a materially different diagnostic command."
|
||||
),
|
||||
}
|
||||
elif name not in TOOL_METADATA:
|
||||
result = {"ok": False, "error": f"Unknown tool: {name}"}
|
||||
elif read_only and name not in READ_ONLY_TOOL_NAMES:
|
||||
|
||||
Reference in New Issue
Block a user