diff --git a/src/opensquilla/engine/agent.py b/src/opensquilla/engine/agent.py index ca116a01eb..66aaf7f4e9 100644 --- a/src/opensquilla/engine/agent.py +++ b/src/opensquilla/engine/agent.py @@ -443,13 +443,45 @@ def _resolve_turn_objective_reminder() -> tuple[bool, int]: _TEXT_ONLY_TOOL_RECOVERY_LIMIT = 2 _TEXT_ONLY_TOOL_RECOVERY_MESSAGE = ( "[Runtime recovery]\n" - "Previous assistant turn had text only and no tool calls. If the task still " - "requires repo inspection, editing, or verification, call the appropriate tool " - "now; if complete, answer briefly." + "The previous assistant message described a next action but did not call a tool. " + "If that action is still required, call the appropriate tool now; if it is already " + "complete, answer briefly with the evidence." +) +_ENGLISH_FOLLOW_UP_ACTION = re.compile( + r"\b(?:" + r"i(?:'ll| will| am going to| need to| should)|let me|" + r"(?:now|next|then)[,:]?\s+(?:i(?:'ll| will| am going to)|let me|let's)" + r")\s+(?!not\b|never\b)" + r"(?:(?:now|next|then|immediately|first|just)\s+)*" + r"(?:apply|build|call|change|check|commit|configure|create|delete|deploy|" + r"download|edit|execute|fetch|fix|inspect|install|launch|merge|open|push|" + r"remove|restart|run|send|start|stop|test|update|upload|verify|write)\b", + re.IGNORECASE, +) +_CHINESE_FOLLOW_UP_ACTION = re.compile( + r"(?:我(?:会|将|要|准备|打算|需要|应该)|现在|接下来|下一步|然后|随后|马上|立即)" + r"(?![^。!?\n]{0,24}(?:不会|不能|不再|无需|无须))" + r"[^。!?\n]{0,100}" + r"(?:启动|运行|执行|创建|写入|编辑|修改|更新|修复|删除|安装|配置|部署|" + r"重启|停止|测试|验证|检查|查看|打开|获取|下载|上传|发送|应用|构建|提交|" + r"推送|合并|调用|继续处理)", ) _PLAN_RUN_RECONCILIATION_LIMIT = 1 +def _promises_follow_up_action(text: str) -> bool: + """Recognize a concrete next-action promise in the response tail.""" + + tail = (text or "").strip()[-1_000:] + return bool( + tail + and ( + _ENGLISH_FOLLOW_UP_ACTION.search(tail) + or _CHINESE_FOLLOW_UP_ACTION.search(tail) + ) + ) + + def _plan_run_steps_ready_for_delivery(run: Any) -> bool: """Whether every bounded step is done while task delivery is still pending.""" @@ -13653,7 +13685,7 @@ def _active_stream_deadline() -> float | None: text_only_mode = getattr( self.config, "text_only_tool_recovery_mode", - "off", + "warn_model", ) self.config.metadata[ "text_only_tool_recovery_next_action_errors" @@ -13780,7 +13812,7 @@ def _active_stream_deadline() -> float | None: text_only_mode = getattr( self.config, "text_only_tool_recovery_mode", - "off", + "warn_model", ) next_action = ( "tool_call" @@ -14259,7 +14291,7 @@ def _active_stream_deadline() -> float | None: text_only_mode = getattr( self.config, "text_only_tool_recovery_mode", - "off", + "warn_model", ) tool_choice_none = ( isinstance(call_chat_cfg.tool_choice, str) @@ -14268,9 +14300,9 @@ def _active_stream_deadline() -> float | None: text_only_candidate = ( text_only_mode != "off" and bool(visible_text.strip()) + and _promises_follow_up_action(visible_text) and bool(provider_tools_for_call) and not tool_choice_none - and not last_executed_results and not max_iterations_finalization_pending and not artifact_delivery_final_response_pending and not post_write_convergence_finalization_pending @@ -14302,6 +14334,7 @@ def _active_stream_deadline() -> float | None: details={ "visible_text_chars": len(visible_text), "available_tool_count": len(provider_tools_for_call or []), + "prior_tool_result_count": len(last_executed_results), "recovery_injections": text_only_tool_recovery_injections, "limit": _TEXT_ONLY_TOOL_RECOVERY_LIMIT, }, @@ -14337,8 +14370,8 @@ def _active_stream_deadline() -> float | None: yield WarningEvent( code="text_only_tool_recovery", message=( - "The model returned text without a tool call; " - "asking it to call tools if the task is not complete." + "The model described a next action without calling a tool; " + "asking it to perform or close that action." ), ) continue diff --git a/src/opensquilla/engine/turn_runner/agent_bootstrap_stage.py b/src/opensquilla/engine/turn_runner/agent_bootstrap_stage.py index b0171d6aa1..7f44aa9f72 100644 --- a/src/opensquilla/engine/turn_runner/agent_bootstrap_stage.py +++ b/src/opensquilla/engine/turn_runner/agent_bootstrap_stage.py @@ -144,7 +144,7 @@ def _text_only_tool_recovery_mode_from_env( raw = os.environ.get("OPENSQUILLA_TEXT_ONLY_TOOL_RECOVERY_MODE") if raw is None: raw = config_value - return normalize_runtime_recovery_mode(raw, default="off") + return normalize_runtime_recovery_mode(raw, default="warn_model") def _reasoning_prefill_recovery_mode_from_env() -> Literal["off", "log", "recover"]: diff --git a/src/opensquilla/engine/turn_runner/harness.py b/src/opensquilla/engine/turn_runner/harness.py index 9a68452512..edbe6d60a1 100644 --- a/src/opensquilla/engine/turn_runner/harness.py +++ b/src/opensquilla/engine/turn_runner/harness.py @@ -954,7 +954,7 @@ def build_auxiliaries( text_only_tool_recovery_mode=getattr( runner._config, "text_only_tool_recovery_mode", - "off", + "warn_model", ), finalize_evidence_gate=bool( getattr( diff --git a/src/opensquilla/engine/types.py b/src/opensquilla/engine/types.py index b6f7b4fe52..b37c3412de 100644 --- a/src/opensquilla/engine/types.py +++ b/src/opensquilla/engine/types.py @@ -998,7 +998,7 @@ class AgentConfig: source_diff_candidate_mode: Literal["off", "log", "warn_model"] = "log" runtime_state_capsule_mode: Literal["off", "log", "inject"] = "off" post_tool_empty_recovery_mode: Literal["off", "log", "warn_model"] = "log" - text_only_tool_recovery_mode: Literal["off", "log", "warn_model"] = "off" + text_only_tool_recovery_mode: Literal["off", "log", "warn_model"] = "warn_model" reasoning_prefill_recovery_mode: Literal["off", "log", "recover"] = "log" runtime_events_path: str | None = None tool_result_store_dir: str | None = None diff --git a/src/opensquilla/gateway/config.py b/src/opensquilla/gateway/config.py index 9d7de77565..b3e67c6b72 100644 --- a/src/opensquilla/gateway/config.py +++ b/src/opensquilla/gateway/config.py @@ -2741,9 +2741,9 @@ def _validate_squilla_router_tier_profile_provider(self) -> GatewayConfig: # coding turns. ``log`` records telemetry only; ``inject`` adds it to the # provider request view. runtime_state_capsule_mode: Literal["off", "log", "inject"] = "off" - # Text-only tool recovery is an opt-in guard for tool-capable turns where - # a model emits prose instead of a tool call. - text_only_tool_recovery_mode: Literal["off", "log", "warn_model"] = "off" + # Recover a concrete next-action promise that contains no corresponding + # tool call. Explicit "off" keeps the legacy stop-on-text behavior. + text_only_tool_recovery_mode: Literal["off", "log", "warn_model"] = "warn_model" # Provider request timeout (single LLM HTTP/streaming request). llm_request_timeout_seconds: float = 120.0 # Agent stream liveness events. The heartbeat interval only affects diff --git a/tests/test_ci/test_upgrade_baselines.py b/tests/test_ci/test_upgrade_baselines.py index 2ad4b7699d..6e8c80b519 100644 --- a/tests/test_ci/test_upgrade_baselines.py +++ b/tests/test_ci/test_upgrade_baselines.py @@ -241,7 +241,7 @@ def _run_rehearsal_driver( capture_output=True, text=True, check=False, - timeout=15, + timeout=30 if os.name == "nt" else 15, ) diff --git a/tests/test_engine/test_agent_canonical_text_contract.py b/tests/test_engine/test_agent_canonical_text_contract.py index cf31192617..4c72b6cea2 100644 --- a/tests/test_engine/test_agent_canonical_text_contract.py +++ b/tests/test_engine/test_agent_canonical_text_contract.py @@ -233,7 +233,7 @@ async def test_recovery_failure_emits_authoritative_empty_terminal_snapshot() -> provider = _SequenceProvider( [ [ - ProviderText(text="superseded answer"), + ProviderText(text="I will inspect the repository next."), ProviderDone(stop_reason="stop", input_tokens=1, output_tokens=1), ], [ProviderError(message="fatal retry failure", code="400")], diff --git a/tests/test_engine/test_runtime_events.py b/tests/test_engine/test_runtime_events.py index 767b62ff67..2c6fcb802d 100644 --- a/tests/test_engine/test_runtime_events.py +++ b/tests/test_engine/test_runtime_events.py @@ -11,6 +11,7 @@ _runtime_recovery_mode_from_env, _source_diff_candidate_mode_from_env, _source_diff_preservation_mode_from_env, + _text_only_tool_recovery_mode_from_env, _tool_loop_observer_mode_from_env, ) @@ -74,6 +75,12 @@ def test_runtime_recovery_modes_from_env(monkeypatch) -> None: assert _reasoning_prefill_recovery_mode_from_env() == "log" +def test_text_only_action_promise_recovery_defaults_on_and_allows_opt_out(monkeypatch) -> None: + monkeypatch.delenv("OPENSQUILLA_TEXT_ONLY_TOOL_RECOVERY_MODE", raising=False) + assert _text_only_tool_recovery_mode_from_env() == "warn_model" + assert _text_only_tool_recovery_mode_from_env("off") == "off" + + def test_source_diff_preservation_mode_from_env(monkeypatch) -> None: monkeypatch.setenv("OPENSQUILLA_SOURCE_DIFF_PRESERVATION_MODE", "block") assert _source_diff_preservation_mode_from_env() == "block" diff --git a/tests/test_engine/test_text_only_tool_recovery.py b/tests/test_engine/test_text_only_tool_recovery.py index 96e88234e3..fd8bf70bdb 100644 --- a/tests/test_engine/test_text_only_tool_recovery.py +++ b/tests/test_engine/test_text_only_tool_recovery.py @@ -100,13 +100,13 @@ async def tool_handler(call: Any) -> ToolResult: assert any( msg.role == "user" and isinstance(msg.content, str) - and "Previous assistant turn had text only" in msg.content + and "described a next action" in msg.content for msg in provider.calls[1]["messages"] ) assert not any( msg.role == "user" and isinstance(msg.content, str) - and "Previous assistant turn had text only" in msg.content + and "described a next action" in msg.content for msg in agent._history ) logged = [json.loads(line) for line in runtime_events_path.read_text().splitlines()] @@ -148,3 +148,191 @@ async def test_text_only_recovery_log_mode_does_not_inject(tmp_path) -> None: assert any(event.kind == "done" for event in events) assert len(provider.calls) == 1 + + +@pytest.mark.asyncio +async def test_default_recovery_continues_after_a_post_tool_action_promise(tmp_path) -> None: + provider = _SequenceProvider( + [ + [ + ProviderToolUseStart(tool_use_id="check-1", tool_name="check_service"), + ProviderToolUseEnd( + tool_use_id="check-1", + tool_name="check_service", + arguments={}, + ), + ProviderDone(stop_reason="tool_use", input_tokens=3, output_tokens=1), + ], + [ + ProviderText( + text=( + "8317 后端与 5173 前端都正常响应。" + "现在按你的要求把 Manager Server 启动为常驻后台服务" + ) + ), + ProviderDone(stop_reason="stop", input_tokens=4, output_tokens=2), + ], + [ + ProviderToolUseStart(tool_use_id="start-1", tool_name="background_process"), + ProviderToolUseEnd( + tool_use_id="start-1", + tool_name="background_process", + arguments={}, + ), + ProviderDone(stop_reason="tool_use", input_tokens=4, output_tokens=1), + ], + [ + ProviderText(text="Manager Server 已启动。"), + ProviderDone(stop_reason="stop", input_tokens=3, output_tokens=1), + ], + ] + ) + executed: list[str] = [] + + async def tool_handler(call: Any) -> ToolResult: + executed.append(call.tool_name) + return ToolResult( + tool_use_id=call.tool_use_id, + tool_name=call.tool_name, + content="ok", + ) + + agent = Agent( + provider=provider, + config=AgentConfig( + max_iterations=5, + runtime_events_path=str(tmp_path / "runtime_events.jsonl"), + retry_base_backoff_ms=0, + retry_max_backoff_ms=0, + ), + tool_definitions=[ + ToolDefinition( + name=name, + description=name, + input_schema=ToolInputSchema(properties={}, required=[]), + ) + for name in ("check_service", "background_process") + ], + tool_handler=tool_handler, + ) + + events = [event async for event in agent.run_turn("启动 Manager Server")] + + assert AgentConfig().text_only_tool_recovery_mode == "warn_model" + assert executed == ["check_service", "background_process"] + assert len(provider.calls) == 4 + assert any( + event.kind == "warning" and event.code == "text_only_tool_recovery" + for event in events + ) + assert any(event.kind == "done" and event.text == "Manager Server 已启动。" for event in events) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "final_text", + [ + "Both services are responding; the requested check is complete.", + "I will not restart the service because the check is complete.", + "检查已经完成,现在无需重启服务。", + ], +) +async def test_default_recovery_does_not_retry_a_completed_tool_report( + final_text: str, +) -> None: + provider = _SequenceProvider( + [ + [ + ProviderToolUseStart(tool_use_id="check-1", tool_name="check_service"), + ProviderToolUseEnd( + tool_use_id="check-1", + tool_name="check_service", + arguments={}, + ), + ProviderDone(stop_reason="tool_use", input_tokens=3, output_tokens=1), + ], + [ + ProviderText(text=final_text), + ProviderDone(stop_reason="stop", input_tokens=4, output_tokens=2), + ], + ] + ) + + async def tool_handler(call: Any) -> ToolResult: + return ToolResult( + tool_use_id=call.tool_use_id, + tool_name=call.tool_name, + content="ok", + ) + + agent = Agent( + provider=provider, + config=AgentConfig(max_iterations=3), + tool_definitions=[ + ToolDefinition( + name="check_service", + description="Check a service.", + input_schema=ToolInputSchema(properties={}, required=[]), + ) + ], + tool_handler=tool_handler, + ) + + events = [event async for event in agent.run_turn("check services")] + + assert len(provider.calls) == 2 + assert not any(event.kind == "warning" for event in events) + assert any(event.kind == "done" for event in events) + + +@pytest.mark.asyncio +async def test_explicit_off_preserves_post_tool_stop_behavior() -> None: + provider = _SequenceProvider( + [ + [ + ProviderToolUseStart(tool_use_id="check-1", tool_name="check_service"), + ProviderToolUseEnd( + tool_use_id="check-1", + tool_name="check_service", + arguments={}, + ), + ProviderDone(stop_reason="tool_use", input_tokens=3, output_tokens=1), + ], + [ + ProviderText(text="I will start the service next."), + ProviderDone(stop_reason="stop", input_tokens=4, output_tokens=2), + ], + ] + ) + + async def tool_handler(call: Any) -> ToolResult: + return ToolResult( + tool_use_id=call.tool_use_id, + tool_name=call.tool_name, + content="ok", + ) + + agent = Agent( + provider=provider, + config=AgentConfig( + max_iterations=3, + text_only_tool_recovery_mode="off", + ), + tool_definitions=[ + ToolDefinition( + name="check_service", + description="Check a service.", + input_schema=ToolInputSchema(properties={}, required=[]), + ) + ], + tool_handler=tool_handler, + ) + + events = [event async for event in agent.run_turn("start service")] + + assert len(provider.calls) == 2 + assert not any(event.kind == "warning" for event in events) + assert any( + event.kind == "done" and event.text == "I will start the service next." + for event in events + ) diff --git a/tests/test_gateway_config_legacy.py b/tests/test_gateway_config_legacy.py index 14c4169a52..704e2e5b4a 100644 --- a/tests/test_gateway_config_legacy.py +++ b/tests/test_gateway_config_legacy.py @@ -672,3 +672,7 @@ def test_gateway_config_accepts_text_only_tool_recovery_mode() -> None: cfg = GatewayConfig.model_validate({"text_only_tool_recovery_mode": "warn_model"}) assert cfg.text_only_tool_recovery_mode == "warn_model" + + +def test_gateway_config_enables_bounded_action_promise_recovery_by_default() -> None: + assert GatewayConfig().text_only_tool_recovery_mode == "warn_model"