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

624 lines
20 KiB
Python

from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any
import pytest
from opensquilla.engine import Agent, AgentConfig, ToolResult
from opensquilla.engine.subagent import SubagentSpec
from opensquilla.engine.types import ArtifactEvent, ErrorEvent
from opensquilla.engine.usage import UsageTracker
from opensquilla.provider import (
ChatConfig,
Message,
ToolDefinition,
ToolInputSchema,
)
from opensquilla.provider import (
DoneEvent as ProviderDone,
)
from opensquilla.provider import (
ErrorEvent as ProviderError,
)
from opensquilla.provider import (
TextDeltaEvent as ProviderText,
)
from opensquilla.provider import (
ToolUseEndEvent as ProviderToolUseEnd,
)
from opensquilla.provider import (
ToolUseStartEvent as ProviderToolUseStart,
)
class _LoopingToolProvider:
provider_name = "fake"
def __init__(self, *, final_on_call: int | None = None) -> None:
self.final_on_call = final_on_call
self.calls: list[list[Message]] = []
def chat(
self,
messages: list[Message],
tools: list[Any] | None = None,
config: ChatConfig | None = None,
) -> AsyncIterator[Any]:
self.calls.append(messages)
call_number = len(self.calls)
return self._stream(call_number)
async def _stream(self, call_number: int) -> AsyncIterator[Any]:
if self.final_on_call == call_number:
yield ProviderText(text="done")
yield ProviderDone(stop_reason="stop", input_tokens=1, output_tokens=1)
return
tool_id = f"tool-{call_number}"
yield ProviderToolUseStart(tool_use_id=tool_id, tool_name="echo")
yield ProviderToolUseEnd(
tool_use_id=tool_id,
tool_name="echo",
arguments={"value": "again"},
)
yield ProviderDone(stop_reason="tool_use", input_tokens=1, output_tokens=1)
async def list_models(self) -> list[Any]:
return []
class _ToolThenProviderErrorProvider:
provider_name = "fake"
def __init__(self) -> None:
self.calls: list[list[Message]] = []
def chat(
self,
messages: list[Message],
tools: list[Any] | None = None,
config: ChatConfig | None = None,
) -> AsyncIterator[Any]:
self.calls.append(messages)
call_number = len(self.calls)
return self._stream(call_number)
async def _stream(self, call_number: int) -> AsyncIterator[Any]:
if call_number == 1:
yield ProviderToolUseStart(tool_use_id="tool-1", tool_name="echo")
yield ProviderToolUseEnd(
tool_use_id="tool-1",
tool_name="echo",
arguments={"value": "again"},
)
yield ProviderDone(stop_reason="tool_use", input_tokens=1, output_tokens=1)
return
yield ProviderError(message="request timed out", code="request_error")
async def list_models(self) -> list[Any]:
return []
class _DoneUsageProvider:
provider_name = "fake"
def __init__(
self,
*,
input_tokens: int = 1,
output_tokens: int = 1,
billed_cost: float = 0.0,
) -> None:
self.input_tokens = input_tokens
self.output_tokens = output_tokens
self.billed_cost = billed_cost
self.calls: list[list[Message]] = []
def chat(
self,
messages: list[Message],
tools: list[Any] | None = None,
config: ChatConfig | None = None,
) -> AsyncIterator[Any]:
self.calls.append(messages)
return self._stream()
async def _stream(self) -> AsyncIterator[Any]:
yield ProviderText(text="done")
yield ProviderDone(
stop_reason="stop",
input_tokens=self.input_tokens,
output_tokens=self.output_tokens,
billed_cost=self.billed_cost,
)
async def list_models(self) -> list[Any]:
return []
class _DoneBreakdownProvider:
provider_name = "fake"
def chat(
self,
messages: list[Message],
tools: list[Any] | None = None,
config: ChatConfig | None = None,
) -> AsyncIterator[Any]:
return self._stream()
async def _stream(self) -> AsyncIterator[Any]:
yield ProviderText(text="done")
yield ProviderDone(
stop_reason="stop",
input_tokens=2000,
output_tokens=1000,
billed_cost=0.01,
model="z-ai/glm-5.2",
cost_source="mixed",
model_usage_breakdown=[
{
"role": "proposer",
"provider": "openrouter",
"model": "deepseek/deepseek-v4-pro-20260423",
"input_tokens": 1000,
"output_tokens": 1000,
"billed_cost": 0.0,
"cost_source": "none",
},
{
"role": "aggregator",
"provider": "openrouter",
"model": "z-ai/glm-5.2",
"input_tokens": 1000,
"output_tokens": 0,
"billed_cost": 0.01,
"cost_source": "provider_billed",
},
],
)
async def list_models(self) -> list[Any]:
return []
class _ArtifactThenProviderErrorProvider:
provider_name = "fake"
def __init__(self) -> None:
self.calls: list[list[Message]] = []
def chat(
self,
messages: list[Message],
tools: list[Any] | None = None,
config: ChatConfig | None = None,
) -> AsyncIterator[Any]:
self.calls.append(messages)
call_number = len(self.calls)
return self._stream(call_number)
async def _stream(self, call_number: int) -> AsyncIterator[Any]:
if call_number == 1:
tool_id = "publish-1"
yield ProviderToolUseStart(
tool_use_id=tool_id,
tool_name="publish_artifact",
)
yield ProviderToolUseEnd(
tool_use_id=tool_id,
tool_name="publish_artifact",
arguments={"path": "report.txt"},
)
yield ProviderDone(stop_reason="tool_use", input_tokens=1, output_tokens=1)
return
yield ProviderError(message="request timed out", code="request_error")
async def list_models(self) -> list[Any]:
return []
async def _echo_tool(call: Any) -> ToolResult:
return ToolResult(
tool_use_id=call.tool_use_id,
tool_name=call.tool_name,
content="ok",
)
async def _error_tool(call: Any) -> ToolResult:
return ToolResult(
tool_use_id=call.tool_use_id,
tool_name=call.tool_name,
content="invalid arguments",
is_error=True,
)
def _echo_definition() -> ToolDefinition:
return ToolDefinition(
name="echo",
description="Echo.",
input_schema=ToolInputSchema(
properties={"value": {"type": "string"}},
required=["value"],
),
)
def _publish_artifact_definition() -> ToolDefinition:
return ToolDefinition(
name="publish_artifact",
description="Publish an artifact.",
input_schema=ToolInputSchema(
properties={"path": {"type": "string"}},
required=["path"],
),
)
async def _artifact_tool(call: Any) -> ToolResult:
return ToolResult(
tool_use_id=call.tool_use_id,
tool_name=call.tool_name,
content="published",
artifacts=[
{
"id": "art-1",
"name": "report.txt",
"mime": "text/plain",
"size": 12,
"sha256": "a" * 64,
"session_id": "session-1",
"session_key": "agent:main:webchat:session-1",
"source": "publish_artifact",
"created_at": "2026-05-06T12:00:00Z",
"download_url": "/api/v1/artifacts/art-1",
}
],
)
def test_agent_iteration_defaults_are_unbounded() -> None:
assert AgentConfig().max_iterations == 0
assert SubagentSpec(task="check").max_iterations == 0
assert AgentConfig().max_turn_llm_calls == 0
assert AgentConfig().max_turn_input_tokens == 0
assert AgentConfig().max_turn_output_tokens == 0
assert AgentConfig().max_turn_billed_cost_usd == 0.0
assert AgentConfig().max_turn_tool_errors == 0
assert AgentConfig().length_capped_continuations == 3
@pytest.mark.asyncio
async def test_agent_default_max_iterations_allows_long_tool_loop_to_finish() -> None:
provider = _LoopingToolProvider(final_on_call=101)
agent = Agent(
provider=provider,
config=AgentConfig(),
tool_definitions=[_echo_definition()],
tool_handler=_echo_tool,
)
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 101
assert not any(event.kind == "error" and event.code == "max_iterations" for event in events)
assert any(event.kind == "done" and event.text == "done" for event in events)
@pytest.mark.asyncio
async def test_agent_finalizes_when_tool_loop_reaches_max_iterations() -> None:
provider = _LoopingToolProvider(final_on_call=2)
agent = Agent(
provider=provider,
config=AgentConfig(max_iterations=1),
tool_definitions=[_echo_definition()],
tool_handler=_echo_tool,
)
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 2
assert provider.calls[-1][-1].role == "user"
assert "Do not call tools" in str(provider.calls[-1][-1].content)
assert "Do not call tools" not in "\n".join(
str(message.content) for message in agent._history
)
assert not any(event.kind == "state" and event.state.value == "error" for event in events)
assert not any(event.kind == "error" and event.code == "max_iterations" for event in events)
assert any(event.kind == "done" and event.text == "done" for event in events)
@pytest.mark.asyncio
async def test_agent_reports_partial_max_iterations_after_finalization_attempt_fails() -> None:
provider = _LoopingToolProvider()
agent = Agent(
provider=provider,
config=AgentConfig(max_iterations=1),
tool_definitions=[_echo_definition()],
tool_handler=_echo_tool,
)
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 2
assert not any(event.kind == "state" and event.state.value == "error" for event in events)
assert any(
event.kind == "done"
and "best partial result" in event.text
for event in events
)
@pytest.mark.asyncio
async def test_agent_returns_partial_when_max_iteration_finalization_provider_fails() -> None:
provider = _ToolThenProviderErrorProvider()
agent = Agent(
provider=provider,
config=AgentConfig(max_iterations=1, max_provider_retries=0),
tool_definitions=[_echo_definition()],
tool_handler=_echo_tool,
)
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 2
assert not any(event.kind == "state" and event.state.value == "error" for event in events)
assert not any(event.kind == "error" for event in events)
done_texts = [event.text for event in events if event.kind == "done"]
assert len(done_texts) == 1
assert done_texts[0].count("best partial result") == 1
@pytest.mark.asyncio
async def test_agent_allows_final_response_on_last_iteration() -> None:
provider = _LoopingToolProvider(final_on_call=2)
agent = Agent(
provider=provider,
config=AgentConfig(max_iterations=2),
tool_definitions=[_echo_definition()],
tool_handler=_echo_tool,
)
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 2
assert not any(event.kind == "error" and event.code == "max_iterations" for event in events)
assert any(event.kind == "done" and event.text == "done" for event in events)
@pytest.mark.asyncio
async def test_agent_emits_artifact_event_independent_of_tool_result_text() -> None:
provider = _LoopingToolProvider()
agent = Agent(
provider=provider,
config=AgentConfig(max_iterations=1),
tool_definitions=[_echo_definition()],
tool_handler=_artifact_tool,
)
events = [event async for event in agent.run_turn("hello")]
artifact_events = [event for event in events if isinstance(event, ArtifactEvent)]
assert len(artifact_events) == 1
assert artifact_events[0].id == "art-1"
assert artifact_events[0].download_url == "/api/v1/artifacts/art-1"
@pytest.mark.asyncio
async def test_agent_synthesizes_final_artifact_response_without_provider_call() -> None:
provider = _ArtifactThenProviderErrorProvider()
agent = Agent(
provider=provider,
config=AgentConfig(max_iterations=3, max_provider_retries=0),
tool_definitions=[_publish_artifact_definition()],
tool_handler=_artifact_tool,
)
events = [event async for event in agent.run_turn("publish a report")]
assert len(provider.calls) == 1
assert any(isinstance(event, ArtifactEvent) for event in events)
assert not any(isinstance(event, ErrorEvent) for event in events)
assert not any(event.kind == "warning" for event in events)
assert any(
event.kind == "done"
and event.text == "The generated file is ready: report.txt."
for event in events
)
@pytest.mark.asyncio
async def test_agent_synthesizes_final_artifact_response_before_extra_llm_call() -> None:
provider = _ArtifactThenProviderErrorProvider()
agent = Agent(
provider=provider,
config=AgentConfig(max_iterations=3, max_turn_llm_calls=1),
tool_definitions=[_publish_artifact_definition()],
tool_handler=_artifact_tool,
)
events = [event async for event in agent.run_turn("publish a report")]
assert len(provider.calls) == 1
assert any(isinstance(event, ArtifactEvent) for event in events)
assert not any(isinstance(event, ErrorEvent) for event in events)
assert not any(event.kind == "warning" for event in events)
assert any(
event.kind == "done"
and event.text == "The generated file is ready: report.txt."
for event in events
)
@pytest.mark.asyncio
async def test_agent_stops_when_turn_input_token_budget_is_exceeded() -> None:
provider = _LoopingToolProvider()
agent = Agent(
provider=provider,
config=AgentConfig(
max_iterations=100,
max_turn_input_tokens=1,
max_turn_output_tokens=0,
max_turn_billed_cost_usd=0,
max_turn_tool_errors=0,
),
tool_definitions=[_echo_definition()],
tool_handler=_echo_tool,
)
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 2
assert any(
event.kind == "error" and event.code == "turn_input_token_budget_exceeded"
for event in events
)
@pytest.mark.asyncio
async def test_agent_stops_when_turn_llm_call_budget_is_exceeded() -> None:
provider = _LoopingToolProvider()
agent = Agent(
provider=provider,
config=AgentConfig(
max_iterations=100,
max_turn_llm_calls=1,
),
tool_definitions=[_echo_definition()],
tool_handler=_echo_tool,
)
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 1
assert any(
event.kind == "error" and event.code == "turn_llm_call_budget_exceeded"
for event in events
)
@pytest.mark.asyncio
async def test_agent_stops_when_turn_output_token_budget_is_exceeded() -> None:
provider = _DoneUsageProvider(output_tokens=10)
agent = Agent(
provider=provider,
config=AgentConfig(max_turn_output_tokens=5),
)
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 1
assert any(
event.kind == "error" and event.code == "turn_output_token_budget_exceeded"
for event in events
)
@pytest.mark.asyncio
async def test_agent_done_event_uses_current_turn_real_billed_usage_delta() -> None:
tracker = UsageTracker()
session_key = "agent:test:webchat:s1"
tracker.add(
session_key,
input_tokens=100,
output_tokens=10,
model_id="deepseek/deepseek-v4-pro-20260423",
billed_cost=0.050,
)
provider = _DoneUsageProvider(input_tokens=9, output_tokens=4, billed_cost=0.123)
agent = Agent(
provider=provider,
config=AgentConfig(model_id="deepseek/deepseek-v4-pro-20260423"),
usage_tracker=tracker,
session_key=session_key,
)
events = [event async for event in agent.run_turn("hello")]
done = next(event for event in events if event.kind == "done")
assert done.input_tokens == 9
assert done.output_tokens == 4
assert done.cost_usd == pytest.approx(0.123)
assert done.billed_cost == pytest.approx(0.123)
assert done.cost_source == "provider_billed"
assert done.session_totals is not None
assert done.session_totals.billed_cost == pytest.approx(0.173)
@pytest.mark.asyncio
async def test_agent_enriches_model_usage_breakdown_with_estimated_costs() -> None:
tracker = UsageTracker()
session_key = "agent:test:webchat:ensemble-costs"
agent = Agent(
provider=_DoneBreakdownProvider(),
config=AgentConfig(model_id="z-ai/glm-5.2"),
usage_tracker=tracker,
session_key=session_key,
)
events = [event async for event in agent.run_turn("hello")]
done = next(event for event in events if event.kind == "done")
# deepseek/deepseek-v4-pro static price is $0.435/M in, $0.87/M out (see
# engine/pricing.py): (1000 * 0.435 + 1000 * 0.87) / 1e6 == 0.001305.
assert done.cost_source == "mixed"
assert done.cost_usd == pytest.approx(0.011305)
assert done.billed_cost == pytest.approx(0.01)
deepseek_row = done.model_usage_breakdown[0]
assert deepseek_row["model"] == "deepseek/deepseek-v4-pro-20260423"
assert deepseek_row["cost_usd"] == pytest.approx(0.001305)
assert deepseek_row["estimated_cost_usd"] == pytest.approx(0.001305)
assert deepseek_row["billed_cost_usd"] == 0.0
assert deepseek_row["cost_source"] == "opensquilla_estimate"
aggregator_row = done.model_usage_breakdown[1]
assert aggregator_row["cost_usd"] == pytest.approx(0.01)
assert aggregator_row["billed_cost_usd"] == pytest.approx(0.01)
assert aggregator_row["cost_source"] == "provider_billed"
@pytest.mark.asyncio
async def test_agent_stops_when_turn_billed_cost_budget_is_exceeded() -> None:
provider = _DoneUsageProvider(billed_cost=0.25)
agent = Agent(
provider=provider,
config=AgentConfig(max_turn_billed_cost_usd=0.1),
)
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 1
assert any(
event.kind == "error" and event.code == "turn_billed_cost_budget_exceeded"
for event in events
)
@pytest.mark.asyncio
async def test_agent_stops_when_turn_tool_error_budget_is_exceeded() -> None:
provider = _LoopingToolProvider()
agent = Agent(
provider=provider,
config=AgentConfig(
max_iterations=100,
max_turn_input_tokens=0,
max_turn_output_tokens=0,
max_turn_billed_cost_usd=0,
max_turn_tool_errors=2,
),
tool_definitions=[_echo_definition()],
tool_handler=_error_tool,
)
events = [event async for event in agent.run_turn("hello")]
assert len(provider.calls) == 2
assert any(
event.kind == "error" and event.code == "turn_tool_error_budget_exceeded"
for event in events
)