From c53e221117229d9a093d7473ed9740d86c035427 Mon Sep 17 00:00:00 2001 From: steven_li Date: Fri, 22 May 2026 11:37:02 +0800 Subject: [PATCH] feat(engine): finalize after tool iteration limit --- app-instance/backend/beaver/engine/loop.py | 48 ++++++++++++++++++- .../tests/unit/test_phase5_skills_runtime.py | 10 +++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/app-instance/backend/beaver/engine/loop.py b/app-instance/backend/beaver/engine/loop.py index a8d8925..35ce61d 100644 --- a/app-instance/backend/beaver/engine/loop.py +++ b/app-instance/backend/beaver/engine/loop.py @@ -708,8 +708,19 @@ class AgentLoop: break if iterations >= resolved_max_tool_iterations: - final_text = response.content or "Tool loop stopped after reaching the configured iteration limit." - final_finish_reason = "max_tool_iterations" + finalized = await self._finalize_after_tool_limit( + provider=provider, + messages=messages, + model=final_model, + max_tokens=resolved_max_tokens, + temperature=resolved_temperature, + thinking_enabled=thinking_enabled, + ) + final_text = finalized or ( + "Tool loop stopped after reaching the configured iteration limit, " + "and no final answer was produced." + ) + final_finish_reason = "max_tool_iterations_finalized" if finalized else "max_tool_iterations" session_manager.append_message( resolved_session_id, run_id=resolved_run_id, @@ -853,6 +864,39 @@ class AgentLoop: raise RuntimeError(f"Engine loader did not provide required dependency {field_name!r}") return value + @staticmethod + async def _finalize_after_tool_limit( + *, + provider: Any, + messages: list[dict[str, Any]], + model: str, + max_tokens: int, + temperature: float, + thinking_enabled: bool | None, + ) -> str: + final_messages = [ + *messages, + { + "role": "system", + "content": ( + "The configured tool iteration budget is exhausted. Do not call tools. " + "Produce the best final answer from the existing conversation and tool results. " + "State uncertainty explicitly." + ), + }, + ] + kwargs: dict[str, Any] = { + "messages": final_messages, + "tools": None, + "model": model, + "max_tokens": max_tokens, + "temperature": temperature, + } + if thinking_enabled is not None: + kwargs["thinking_enabled"] = thinking_enabled + response = await provider.chat(**kwargs) + return (response.content or "").strip() + @staticmethod def _load_pinned_skill_contexts(skills_loader: Any, skill_names: list[str]) -> list[SkillContext]: contexts: list[SkillContext] = [] diff --git a/app-instance/backend/tests/unit/test_phase5_skills_runtime.py b/app-instance/backend/tests/unit/test_phase5_skills_runtime.py index 98f8447..29e810a 100644 --- a/app-instance/backend/tests/unit/test_phase5_skills_runtime.py +++ b/app-instance/backend/tests/unit/test_phase5_skills_runtime.py @@ -608,6 +608,12 @@ def test_agent_loop_records_max_tool_iterations_as_failed_skill_effect(tmp_path: provider_name="stub", model="stub-model", ), + LLMResponse( + content="Based on the available tool result, the container likely failed during startup.", + finish_reason="stop", + provider_name="stub", + model="stub-model", + ), ] ), ) @@ -621,7 +627,9 @@ def test_agent_loop_records_max_tool_iterations_as_failed_skill_effect(tmp_path: ) loaded = loop.boot() - assert result.finish_reason == "max_tool_iterations" + assert result.finish_reason == "max_tool_iterations_finalized" + assert "Based on the available tool result" in result.output_text + assert "Tool loop stopped" not in result.output_text effect_records = loaded.run_memory_store.list_skill_effects("docker-debug", version="v0007") assert effect_records[-1].run_id == result.run_id assert effect_records[-1].success is False