From c9774e541e0e2b40f41c86bd2dcacc26fe3c0b61 Mon Sep 17 00:00:00 2001 From: Y1fe1Zh0u Date: Fri, 14 Aug 2026 20:21:10 +0800 Subject: [PATCH] End unrecoverable tool repair loops visibly A depleted Tool repair budget previously parked the Run in waiting_user, leaving users with an apparently active task and only a plain assistant message. Treat the exhausted budget as a structured terminal failure so the existing run_failed delivery and frontend error paths are used. Constraint: Historical waiting_user checkpoints for repair exhaustion must remain resumable. Rejected: Add a special frontend waiting card | the exhausted repair budget is not recoverable without starting a new turn and should release the Run lane. Confidence: high Scope-risk: narrow Directive: Keep repair-budget exhaustion terminal unless a bounded automatic recovery mechanism is introduced. Tested: 62 Runtime node, checkpoint, chat stream, and repair budget tests; scoped Ruff; git diff --check. Not-tested: Live WebSocket rendering against the deployed frontend. --- .../services/agent_runtime/node_executor.py | 24 ++++++---------- .../tests/test_agent_runtime_node_executor.py | 28 ++++++------------- 2 files changed, 17 insertions(+), 35 deletions(-) diff --git a/backend/app/services/agent_runtime/node_executor.py b/backend/app/services/agent_runtime/node_executor.py index bb9d380e7..0414faa11 100644 --- a/backend/app/services/agent_runtime/node_executor.py +++ b/backend/app/services/agent_runtime/node_executor.py @@ -1051,24 +1051,16 @@ async def _tool( lifecycle.pop("step_tool_context", None) lifecycle.update( { - "status": "waiting_user", - "next_route": "wait", + "status": "failed", + "next_route": "terminal", "reason": repair_pause_reason, "pending_tool_calls": [], - "waiting_request": { - "waiting_type": "user", - "correlation_id": _runtime_message_id( - context, - f"tool-repair:{repair_pause_reason}:{repair_pause_tool}", - ), - "reason": repair_pause_reason, - "question": ( - f"Tool {repair_pause_tool or 'unknown'} reached its " - "repair safety limit. Provide corrected requirements " - "or arguments to continue." - ), - }, - "error": None, + "waiting_request": None, + "error": _error( + repair_pause_reason, + f"Tool {repair_pause_tool or 'unknown'} reached its " + "repair safety limit.", + ), } ) else: diff --git a/backend/tests/test_agent_runtime_node_executor.py b/backend/tests/test_agent_runtime_node_executor.py index f47c6ae9f..4d07cdb2c 100644 --- a/backend/tests/test_agent_runtime_node_executor.py +++ b/backend/tests/test_agent_runtime_node_executor.py @@ -784,7 +784,7 @@ async def no_sleep(_seconds: float) -> None: @pytest.mark.asyncio -async def test_tenth_same_tool_failure_pauses_before_next_model_call() -> None: +async def test_tenth_same_tool_failure_fails_run_before_next_model_call() -> None: run_id = uuid.uuid4() proposals = tuple( ModelStepResult( @@ -822,10 +822,17 @@ async def test_tenth_same_tool_failure_pauses_before_next_model_call() -> None: result = await _invoke(run_id, executor) lifecycle = result["lifecycle"] - assert lifecycle["status"] == "waiting_user" + assert lifecycle["status"] == "failed" + assert lifecycle["next_route"] == "terminal" assert lifecycle["reason"] == ( "tool_repair_same_fingerprint_limit_reached" ) + assert lifecycle["error"] == { + "code": "tool_repair_same_fingerprint_limit_reached", + "message": "Tool read_file reached its repair safety limit.", + } + assert lifecycle["pending_tool_calls"] == [] + assert lifecycle.get("waiting_request") is None assert lifecycle["model_step_count"] == 10 repair_episode = lifecycle["tool_repair_episodes"]["by_tool"]["read_file"] assert repair_episode["total_failures"] == 10 @@ -833,23 +840,6 @@ async def test_tenth_same_tool_failure_pauses_before_next_model_call() -> None: assert model.calls == 10 assert len(tools.calls) == 10 - resumed = await executor.execute( - "wait", - cast(RuntimeGraphState, result), - _context(run_id, executor, "command-user-correction"), - resume_value={ - "resume_type": "user_input", - "payload": {"content": "Use README.md as the path."}, - }, - ) - assert resumed["lifecycle"]["tool_repair_episodes"] == { - "version": 1, - "by_tool": {}, - } - assert resumed["lifecycle"]["tool_repair_reset"]["reason"] == ( - "explicit_user_correction" - ) - @pytest.mark.asyncio async def test_duplicate_tool_call_ids_fail_before_any_provider_execution() -> None: