Files
2026-07-13 13:12:33 +08:00

151 lines
5.0 KiB
Python

from __future__ import annotations
import json
from collections.abc import AsyncIterator
from typing import Any
import pytest
from opensquilla.engine import Agent, AgentConfig, ToolResult
from opensquilla.provider import DoneEvent as ProviderDone
from opensquilla.provider import TextDeltaEvent as ProviderText
from opensquilla.provider import ToolDefinition, ToolInputSchema
from opensquilla.provider import ToolUseEndEvent as ProviderToolUseEnd
from opensquilla.provider import ToolUseStartEvent as ProviderToolUseStart
class _SequenceProvider:
provider_name = "fake"
def __init__(self, streams: list[list[Any]]) -> None:
self.streams = streams
self.calls: list[dict[str, Any]] = []
def chat(self, messages, tools=None, config=None) -> AsyncIterator[Any]: # noqa: ANN001
index = len(self.calls)
self.calls.append({"messages": messages, "tools": tools, "config": config})
events = self.streams[index] if index < len(self.streams) else self.streams[-1]
return self._stream(events)
async def _stream(self, events: list[Any]) -> AsyncIterator[Any]:
for event in events:
yield event
async def list_models(self) -> list[Any]:
return []
@pytest.mark.asyncio
async def test_text_only_recovery_warns_then_records_next_tool_action(tmp_path) -> None:
provider = _SequenceProvider(
[
[
ProviderText(text="I should inspect the repository."),
ProviderDone(stop_reason="stop", input_tokens=3, output_tokens=1),
],
[
ProviderToolUseStart(tool_use_id="tool-1", tool_name="echo"),
ProviderToolUseEnd(
tool_use_id="tool-1",
tool_name="echo",
arguments={"value": "ok"},
),
ProviderDone(stop_reason="tool_use", input_tokens=4, output_tokens=1),
],
[
ProviderText(text="done"),
ProviderDone(stop_reason="stop", input_tokens=5, output_tokens=1),
],
]
)
async def tool_handler(call: Any) -> ToolResult:
return ToolResult(
tool_use_id=call.tool_use_id,
tool_name=call.tool_name,
content="tool ok",
)
runtime_events_path = tmp_path / "runtime_events.jsonl"
agent = Agent(
provider=provider,
config=AgentConfig(
max_iterations=3,
text_only_tool_recovery_mode="warn_model",
runtime_events_path=str(runtime_events_path),
retry_base_backoff_ms=0,
retry_max_backoff_ms=0,
),
tool_definitions=[
ToolDefinition(
name="echo",
description="Echo.",
input_schema=ToolInputSchema(
properties={"value": {"type": "string"}},
required=["value"],
),
)
],
tool_handler=tool_handler,
)
events = [event async for event in agent.run_turn("hello")]
assert any(event.kind == "done" and event.text == "done" for event in events)
assert any(
event.kind == "warning" and event.code == "text_only_tool_recovery"
for event in events
)
assert len(provider.calls) == 3
assert any(
msg.role == "user"
and isinstance(msg.content, str)
and "Previous assistant turn had text only" 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
for msg in agent._history
)
logged = [json.loads(line) for line in runtime_events_path.read_text().splitlines()]
recovery = [
event for event in logged if event.get("mechanism") == "text_only_tool_recovery"
]
assert [event["action"] for event in recovery] == ["nudge", "observe"]
assert recovery[0]["injected_to_model"] is True
assert recovery[1]["details"]["next_action"] == "tool_call"
@pytest.mark.asyncio
async def test_text_only_recovery_log_mode_does_not_inject(tmp_path) -> None:
provider = _SequenceProvider(
[
[
ProviderText(text="I should inspect the repository."),
ProviderDone(stop_reason="stop", input_tokens=3, output_tokens=1),
]
]
)
agent = Agent(
provider=provider,
config=AgentConfig(
max_iterations=1,
text_only_tool_recovery_mode="log",
runtime_events_path=str(tmp_path / "runtime_events.jsonl"),
),
tool_definitions=[
ToolDefinition(
name="echo",
description="Echo.",
input_schema=ToolInputSchema(properties={}, required=[]),
)
],
)
events = [event async for event in agent.run_turn("hello")]
assert any(event.kind == "done" for event in events)
assert len(provider.calls) == 1