Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 42 additions & 9 deletions src/opensquilla/engine/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Expand Down
2 changes: 1 addition & 1 deletion src/opensquilla/engine/turn_runner/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/opensquilla/engine/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/opensquilla/gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/test_ci/test_upgrade_baselines.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down
2 changes: 1 addition & 1 deletion tests/test_engine/test_agent_canonical_text_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")],
Expand Down
7 changes: 7 additions & 0 deletions tests/test_engine/test_runtime_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down Expand Up @@ -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"
Expand Down
192 changes: 190 additions & 2 deletions tests/test_engine/test_text_only_tool_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()]
Expand Down Expand Up @@ -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
)
Loading
Loading