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

264 lines
9.5 KiB
Python

"""Agent re-emit hop: provider ReasoningDeltaEvent -> engine ThinkingEvent.
The agent layer translates provider-level stream events into engine-level
events. Reasoning must cross this hop as a first-class ThinkingEvent (not folded
into assistant text), so the TUI can render it as a distinct thinking stream.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any
from opensquilla.engine import Agent, AgentConfig
from opensquilla.engine.types import ThinkingEvent
from opensquilla.provider import ChatConfig, Message
from opensquilla.provider import DoneEvent as ProviderDone
from opensquilla.provider import ReasoningDeltaEvent as ProviderReasoning
from opensquilla.provider import TextDeltaEvent as ProviderText
class _ReasoningProvider:
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 ProviderReasoning(text="Let me ")
yield ProviderReasoning(text="think.")
yield ProviderText(text="The answer.")
yield ProviderDone(
stop_reason="stop",
input_tokens=5,
output_tokens=3,
reasoning_content="Let me think.",
)
async def list_models(self) -> list[Any]:
return []
def test_agent_reemits_reasoning_as_thinking_event() -> None:
import asyncio
async def run() -> list[Any]:
agent = Agent(provider=_ReasoningProvider(), config=AgentConfig(max_iterations=1))
return [event async for event in agent.run_turn("hi")]
events = asyncio.run(run())
thinking = [e for e in events if isinstance(e, ThinkingEvent)]
assert [t.text for t in thinking] == ["Let me ", "think."]
# reasoning must NOT be folded into the assistant answer text
answer = "".join(
e.text for e in events if type(e).__name__ == "TextDeltaEvent"
)
assert answer == "The answer."
assert "think" not in answer
from opensquilla.engine import ToolResult # noqa: E402
from opensquilla.engine.types import TextDeltaEvent, ToolCall, ToolUseStartEvent # noqa: E402
from opensquilla.provider import ( # noqa: E402
ToolDefinition,
ToolInputSchema,
)
from opensquilla.provider import ToolUseEndEvent as ProviderToolEnd # noqa: E402
from opensquilla.provider import ToolUseStartEvent as ProviderToolStart # noqa: E402
class _TextThenToolThenAnswerProvider:
"""Round 1: narration text + a tool call. Round 2: final answer text."""
provider_name = "fake"
def __init__(self) -> None:
self.calls = 0
def chat(
self,
messages: list[Message],
tools: list[Any] | None = None,
config: ChatConfig | None = None,
) -> AsyncIterator[Any]:
self.calls += 1
return self._stream(self.calls)
async def _stream(self, call: int) -> AsyncIterator[Any]:
if call == 1:
# text the model speaks before deciding to call a tool
yield ProviderText(text="Let me look ")
yield ProviderText(text="at the file.")
yield ProviderToolStart(tool_use_id="t1", tool_name="read")
yield ProviderToolEnd(tool_use_id="t1", tool_name="read", arguments={})
yield ProviderDone(stop_reason="tool_use", input_tokens=5, output_tokens=2)
return
# final answer round: no tool
yield ProviderText(text="Here is the answer.")
yield ProviderDone(stop_reason="end_turn", input_tokens=6, output_tokens=3)
def test_text_streams_live_as_answer_until_a_tool_appears() -> None:
"""Issue #358 regression guard: assistant text streams live, token by token, the
moment it arrives instead of being buffered until the provider call ends. Until a
tool appears the running text is the answer (the common plain-Q&A case), so nothing
is held back and the UI updates incrementally rather than freezing then dumping the
whole reply at once."""
import asyncio
async def tool_handler(call: ToolCall) -> ToolResult:
return ToolResult(
tool_use_id=call.tool_use_id,
tool_name=call.tool_name,
content="{}",
)
async def run() -> list[Any]:
agent = Agent(
provider=_TextThenToolThenAnswerProvider(),
config=AgentConfig(max_iterations=3),
tool_definitions=[
ToolDefinition(
name="read",
description="read a file",
input_schema=ToolInputSchema(properties={}, required=[]),
)
],
tool_handler=tool_handler,
)
return [event async for event in agent.run_turn("hi")]
events = asyncio.run(run())
text_events = [e for e in events if isinstance(e, TextDeltaEvent)]
# Every provider text delta is surfaced as its own live event — no buffering and
# no end-of-call flush that lumps the reply into one blob.
assert [e.text for e in text_events] == [
"Let me look ",
"at the file.",
"Here is the answer.",
]
# No tool has appeared when any of this text arrives, so all of it streams as the
# answer; nothing is withheld as intermediate.
intermediate = "".join(e.text for e in text_events if e.presentation == "intermediate")
answer = "".join(e.text for e in text_events if e.presentation == "answer")
assert intermediate == ""
assert answer == "Let me look at the file.Here is the answer."
class _ToolThenTextThenAnswerProvider:
"""Round 1: a tool call, then narration text *after* it. Round 2: final answer."""
provider_name = "fake"
def __init__(self) -> None:
self.calls = 0
def chat(
self,
messages: list[Message],
tools: list[Any] | None = None,
config: ChatConfig | None = None,
) -> AsyncIterator[Any]:
self.calls += 1
return self._stream(self.calls)
async def _stream(self, call: int) -> AsyncIterator[Any]:
if call == 1:
yield ProviderToolStart(tool_use_id="t1", tool_name="read")
yield ProviderToolEnd(tool_use_id="t1", tool_name="read", arguments={})
# narration the model speaks between tool calls
yield ProviderText(text="Now let me ")
yield ProviderText(text="explain.")
yield ProviderDone(stop_reason="tool_use", input_tokens=5, output_tokens=2)
return
yield ProviderText(text="Final answer.")
yield ProviderDone(stop_reason="end_turn", input_tokens=6, output_tokens=3)
def test_text_after_a_tool_is_intermediate_narration() -> None:
"""Once a tool has appeared in a round, later text in that round is intermediate
narration between tools (purple), while the final no-tool round is the answer."""
import asyncio
async def tool_handler(call: ToolCall) -> ToolResult:
return ToolResult(
tool_use_id=call.tool_use_id,
tool_name=call.tool_name,
content="{}",
)
async def run() -> list[Any]:
agent = Agent(
provider=_ToolThenTextThenAnswerProvider(),
config=AgentConfig(max_iterations=3),
tool_definitions=[
ToolDefinition(
name="read",
description="read a file",
input_schema=ToolInputSchema(properties={}, required=[]),
)
],
tool_handler=tool_handler,
)
return [event async for event in agent.run_turn("hi")]
events = asyncio.run(run())
text_events = [e for e in events if isinstance(e, TextDeltaEvent)]
intermediate = "".join(e.text for e in text_events if e.presentation == "intermediate")
answer = "".join(e.text for e in text_events if e.presentation == "answer")
assert intermediate == "Now let me explain."
assert answer == "Final answer."
def test_tool_use_start_carries_server_start_timestamp() -> None:
"""Issue #329: the engine stamps ToolUseStartEvent with a server wall-clock start
time (epoch ms). A client seeds the running tool's elapsed timer from this instead
of its own clock, so the timer survives page switches / stream replay (where the
component remounts) rather than restarting from zero on every remount."""
import asyncio
import time
async def tool_handler(call: ToolCall) -> ToolResult:
return ToolResult(
tool_use_id=call.tool_use_id,
tool_name=call.tool_name,
content="{}",
)
async def run() -> list[Any]:
agent = Agent(
provider=_ToolThenTextThenAnswerProvider(),
config=AgentConfig(max_iterations=3),
tool_definitions=[
ToolDefinition(
name="read",
description="read a file",
input_schema=ToolInputSchema(properties={}, required=[]),
)
],
tool_handler=tool_handler,
)
return [event async for event in agent.run_turn("hi")]
before_ms = int(time.time() * 1000)
events = asyncio.run(run())
after_ms = int(time.time() * 1000)
starts = [e for e in events if isinstance(e, ToolUseStartEvent)]
assert starts, "expected at least one tool_use_start event"
for ev in starts:
# Stamped with a real server time within the turn's wall-clock window, not the
# 0 "unstamped" sentinel.
assert ev.started_at > 0
assert before_ms <= ev.started_at <= after_ms