151 lines
5.0 KiB
Python
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
|