From 8a768f724ed455b129e44508a66ab3a9e759908a Mon Sep 17 00:00:00 2001 From: Dheerapat Tookkane Date: Sun, 2 Aug 2026 03:41:26 +0700 Subject: [PATCH 1/5] feat: Add render_turn callback for dynamic agent configuration --- src/tau_agent/__init__.py | 1 + src/tau_agent/events.py | 8 +- src/tau_agent/harness.py | 9 +- src/tau_agent/loop.py | 30 ++++- tests/test_agent_render_turn.py | 230 ++++++++++++++++++++++++++++++++ 5 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 tests/test_agent_render_turn.py diff --git a/src/tau_agent/__init__.py b/src/tau_agent/__init__.py index 6a9f48fea7..e8155f427c 100644 --- a/src/tau_agent/__init__.py +++ b/src/tau_agent/__init__.py @@ -9,6 +9,7 @@ MessageEndEvent, MessageStartEvent, MessageUpdateEvent, + ModelChangeEvent, ToolExecutionEndEvent, ToolExecutionStartEvent, ToolExecutionUpdateEvent, diff --git a/src/tau_agent/events.py b/src/tau_agent/events.py index d3e41bda6b..9549f955aa 100644 --- a/src/tau_agent/events.py +++ b/src/tau_agent/events.py @@ -21,6 +21,11 @@ class AgentEndEvent(WireModel): messages: list[AgentMessage] = Field(default_factory=list) +class ModelChangeEvent(WireModel): + type: Literal["model_change"] = "model_change" + model: str + + class TurnStartEvent(WireModel): type: Literal["turn_start"] = "turn_start" @@ -82,6 +87,7 @@ class ToolExecutionEndEvent(WireModel): | MessageEndEvent | ToolExecutionStartEvent | ToolExecutionUpdateEvent - | ToolExecutionEndEvent, + | ToolExecutionEndEvent + | ModelChangeEvent, Field(discriminator="type"), ] diff --git a/src/tau_agent/harness.py b/src/tau_agent/harness.py index 8873d306d6..2f40834bbf 100644 --- a/src/tau_agent/harness.py +++ b/src/tau_agent/harness.py @@ -10,7 +10,12 @@ from typing import Literal from tau_agent.events import AgentEvent -from tau_agent.loop import AfterToolCall, BeforeToolCall, run_agent_loop +from tau_agent.loop import ( + AfterToolCall, + BeforeToolCall, + TurnRenderer, + run_agent_loop, +) from tau_agent.messages import ( AgentMessage, AssistantMessage, @@ -45,6 +50,7 @@ class AgentHarnessConfig: queue_mode: QueueMode = "one_at_a_time" before_tool_call: BeforeToolCall | None = None after_tool_call: AfterToolCall | None = None + render_turn: TurnRenderer | None = None class SimpleCancellationToken: @@ -179,6 +185,7 @@ async def _run( get_follow_up_messages=self._drain_follow_up_messages, before_tool_call=self._config.before_tool_call, after_tool_call=self._config.after_tool_call, + render_turn=self._config.render_turn, ): await self._notify(event) yield event diff --git a/src/tau_agent/loop.py b/src/tau_agent/loop.py index 966b08d0b5..a2d498bed9 100644 --- a/src/tau_agent/loop.py +++ b/src/tau_agent/loop.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence +from inspect import isawaitable from tau_agent.events import ( AgentEndEvent, @@ -12,6 +13,7 @@ MessageEndEvent, MessageStartEvent, MessageUpdateEvent, + ModelChangeEvent, ToolExecutionEndEvent, ToolExecutionStartEvent, ToolExecutionUpdateEvent, @@ -40,6 +42,10 @@ Awaitable[tuple[AgentToolResult, bool]], ] +# Per-turn configuration returned by ``render_turn``: (model, system, tools). +TurnConfig = tuple[str, str, list[AgentTool]] +TurnRenderer = Callable[[], TurnConfig | Awaitable[TurnConfig] | None] + async def run_agent_loop( *, @@ -55,8 +61,16 @@ async def run_agent_loop( get_follow_up_messages: Callable[[], Sequence[AgentMessage]] | None = None, before_tool_call: BeforeToolCall | None = None, after_tool_call: AfterToolCall | None = None, + render_turn: TurnRenderer | None = None, ) -> AsyncIterator[AgentEvent]: - """Run the provider/tool loop and emit Pi-compatible agent events.""" + """Run the provider/tool loop and emit Pi-compatible agent events. + + When *render_turn* is provided, it is called before every provider request + and may return a ``(model, system, tools)`` tuple to override the agent + configuration for that turn (``None`` keeps the current configuration). A + changed model is announced with ``ModelChangeEvent`` before the request is + made; changed system prompts and tool sets apply silently. + """ new_messages = list(prompts) if prompts: messages.extend(prompts) @@ -78,6 +92,7 @@ async def run_agent_loop( return tool_by_name = {tool.name: tool for tool in tools} + last_model = model turn = 1 first_turn = True pending = tuple(get_steering_messages() if get_steering_messages else ()) @@ -96,6 +111,19 @@ async def run_agent_loop( yield MessageEndEvent(message=message) pending = () + # Re-render the agent between turns: the renderer may swap the + # model, system prompt, and tool set for the next request. + if render_turn is not None: + rendered = render_turn() + if isawaitable(rendered): + rendered = await rendered + if rendered is not None: + model, system, tools = rendered + tool_by_name = {tool.name: tool for tool in tools} + if model != last_model: + last_model = model + yield ModelChangeEvent(model=model) + if max_turns is not None and turn > max_turns: error = _error_message(model, f"Agent stopped after max_turns={max_turns}") messages.append(error) diff --git a/tests/test_agent_render_turn.py b/tests/test_agent_render_turn.py new file mode 100644 index 0000000000..36bf7210c1 --- /dev/null +++ b/tests/test_agent_render_turn.py @@ -0,0 +1,230 @@ +"""Tests for per-turn agent re-rendering (``render_turn``).""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Mapping + +import pytest + +from pi_event_helpers import ( + assistant_done, + assistant_start, + text_delta, + tool_call_end, +) +from tau_agent import ( + AgentEvent, + AgentHarness, + AgentHarnessConfig, + AgentMessage, + AgentTool, + AgentToolResult, + AssistantMessage, + ModelChangeEvent, + SimpleCancellationToken, + TextContent, + ToolCall, + ToolResultMessage, + UserMessage, +) +from tau_agent.loop import run_agent_loop +from tau_agent.types import JSONValue +from tau_ai import FakeProvider + + +async def _collect(stream: AsyncIterator[AgentEvent]) -> list[AgentEvent]: + return [event async for event in stream] + + +def _tool(name: str) -> AgentTool: + async def execute( + tool_call_id: str, + arguments: Mapping[str, JSONValue], + signal: SimpleCancellationToken | None = None, + on_update=None, # noqa: ANN001 + ) -> AgentToolResult: + del tool_call_id, arguments, signal, on_update + return AgentToolResult(content=[TextContent(text=f"{name} ran")]) + + return AgentTool( + name=name, + label=name.title(), + description=f"Run {name}.", + parameters={"type": "object"}, + execute_fn=execute, + ) + + +@pytest.mark.anyio +async def test_render_turn_swaps_model_system_and_tools_between_turns() -> None: + beta = _tool("beta") + renderer_calls: list[tuple[str, str, list[AgentTool]] | None] = [ + ("fake-2", "system-2", [beta]), + ("fake-3", "system-3", []), # beta is withdrawn before turn 2 + None, # turn 3 keeps the current configuration + ] + + def render_turn() -> tuple[str, str, list[AgentTool]] | None: + return renderer_calls.pop(0) + + first = AssistantMessage( + content=[ + TextContent(text="Calling beta."), + ToolCall(id="c1", name="beta", arguments={}), + ], + model="fake", + ) + again = AssistantMessage( + content=[ + TextContent(text="Calling beta again."), + ToolCall(id="c2", name="beta", arguments={}), + ], + model="fake", + ) + final = AssistantMessage(content="Done.", model="fake") + provider = FakeProvider( + [ + [ + assistant_start(), + tool_call_end(ToolCall(id="c1", name="beta", arguments={})), + assistant_done(first, "toolUse"), + ], + [ + assistant_start(), + tool_call_end(ToolCall(id="c2", name="beta", arguments={})), + assistant_done(again, "toolUse"), + ], + [assistant_start(), text_delta("Done."), assistant_done(final)], + ] + ) + messages: list[AgentMessage] = [UserMessage(content="Go")] + + events = await _collect( + run_agent_loop( + provider=provider, + model="fake", + system="system-1", + messages=messages, + tools=[_tool("alpha")], + render_turn=render_turn, + ) + ) + + # Every request uses the configuration from the latest render. + assert [call[0] for call in provider.calls] == ["fake-2", "fake-3", "fake-3"] + assert [call[1] for call in provider.calls] == ["system-2", "system-3", "system-3"] + assert [call[3] for call in provider.calls] == [[beta], [], []] + + # Model swaps are announced; unchanged turns are not. + assert [e.model for e in events if isinstance(e, ModelChangeEvent)] == [ + "fake-2", + "fake-3", + ] + + # The withdrawn tool is gone from the next turn: calling it errors. + results = [ + message + for message in messages + if isinstance(message, ToolResultMessage) and message.tool_name == "beta" + ] + assert len(results) == 2 + assert results[0].is_error is False + assert results[0].text == "beta ran" + assert results[1].is_error is True + assert "not found" in results[1].text + + +@pytest.mark.anyio +async def test_render_turn_none_keeps_current_configuration() -> None: + def render_turn() -> None: + return None + + final = AssistantMessage(content="Done.", model="fake") + provider = FakeProvider([[assistant_start(), text_delta("Done."), assistant_done(final)]]) + messages: list[AgentMessage] = [UserMessage(content="Go")] + + events = await _collect( + run_agent_loop( + provider=provider, + model="fake", + system="system-1", + messages=messages, + tools=[_tool("alpha")], + render_turn=render_turn, + ) + ) + + assert provider.calls[0][0] == "fake" + assert provider.calls[0][1] == "system-1" + assert [tool.name for tool in provider.calls[0][3]] == ["alpha"] + assert not any(isinstance(event, ModelChangeEvent) for event in events) + + +@pytest.mark.anyio +async def test_render_turn_supports_async_renderers() -> None: + async def render_turn() -> tuple[str, str, list[AgentTool]]: + return ("fake-2", "system-2", []) + + final = AssistantMessage(content="Done.", model="fake") + provider = FakeProvider([[assistant_start(), text_delta("Done."), assistant_done(final)]]) + messages: list[AgentMessage] = [UserMessage(content="Go")] + + await _collect( + run_agent_loop( + provider=provider, + model="fake", + system="system-1", + messages=messages, + tools=[], + render_turn=render_turn, + ) + ) + + assert provider.calls[0][0] == "fake-2" + assert provider.calls[0][1] == "system-2" + + +@pytest.mark.anyio +async def test_harness_forwards_render_turn_to_loop() -> None: + beta = _tool("beta") + renderer_calls = 0 + + def render_turn() -> tuple[str, str, list[AgentTool]] | None: + nonlocal renderer_calls + renderer_calls += 1 + return ("fake-2", "system-2", [beta]) if renderer_calls == 1 else None + + first = AssistantMessage( + content=[ + TextContent(text="Calling beta."), + ToolCall(id="c1", name="beta", arguments={}), + ], + model="fake", + ) + final = AssistantMessage(content="Done.", model="fake") + provider = FakeProvider( + [ + [ + assistant_start(), + tool_call_end(ToolCall(id="c1", name="beta", arguments={})), + assistant_done(first, "toolUse"), + ], + [assistant_start(), text_delta("Done."), assistant_done(final)], + ] + ) + harness = AgentHarness( + config=AgentHarnessConfig( + provider=provider, + model="fake", + system="system-1", + tools=[], + render_turn=render_turn, + ) + ) + + events = await _collect(harness.prompt("Go")) + + assert [call[0] for call in provider.calls] == ["fake-2", "fake-2"] + assert [call[3] for call in provider.calls] == [[beta], [beta]] + assert renderer_calls == 2 + assert [e.model for e in events if isinstance(e, ModelChangeEvent)] == ["fake-2"] From 5a0054f113d9fd0c860450fdb8ecc4dcd031ed4f Mon Sep 17 00:00:00 2001 From: Dheerapat Tookkane Date: Sun, 2 Aug 2026 03:44:57 +0700 Subject: [PATCH 2/5] docs: add dev-note for render_turn re-rendering --- dev-notes/render-turn-dynamic-agent-config.md | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 dev-notes/render-turn-dynamic-agent-config.md diff --git a/dev-notes/render-turn-dynamic-agent-config.md b/dev-notes/render-turn-dynamic-agent-config.md new file mode 100644 index 0000000000..8718c20b4d --- /dev/null +++ b/dev-notes/render-turn-dynamic-agent-config.md @@ -0,0 +1,45 @@ +# Per-turn agent re-rendering (`render_turn`) + +## What + +`run_agent_loop()` and `AgentHarnessConfig` now accept an optional +`render_turn` callback. It runs before every provider request and may return a +`(model, system, tools)` tuple to override the agent's configuration for that +turn — `None` keeps the current configuration. Both sync and async callables +are supported. + +When the model changes, a new `ModelChangeEvent` is emitted before the request, +so listeners (UI, sessions) can observe the switch. System-prompt and tool-set +changes apply silently. + +```python +def render_turn() -> tuple[str, str, list[AgentTool]] | None: + return ("bigger-model", system, tools) if step == "hard" else None + +run_agent_loop(..., render_turn=render_turn) +``` + +## Why + +The loop previously locked `model`/`system`/`tools` for an entire run, so an +agent could not change what it sees or can do between turns of one +conversation. Workflows that need conditional capabilities — a support agent +that only gains escalation tools after verifying a customer, a triage agent +that switches models mid-task — had to rebuild the harness between messages. +`render_turn` makes turn-level configuration changes a loop concern, where the +tools, system prompt, and model already live. + +## How it maps to the architecture + +Per `AGENTS.md`, the agent loop is a portable `tau_agent` concern: events are +the contract, and the loop owns tools/system/model per turn. The change adds +one optional callback plus one event; no layer boundaries move. `tau_coding` +and the TUI are untouched and can consume `ModelChangeEvent` like any other +agent event. + +## How to test or use it + +- `tests/test_agent_render_turn.py` covers per-turn config swaps, withdrawn + tools erroring on the next turn, `None` passthrough, async renderers, and + harness pass-through. +- Default behavior is unchanged: `render_turn=None` is identical to before. From 7dbb2e9d6e3bbd464d7e0b198e19cc5f832d2837 Mon Sep 17 00:00:00 2001 From: Dheerapat Tookkane Date: Sun, 2 Aug 2026 03:52:23 +0700 Subject: [PATCH 3/5] nit: typo --- src/tau_agent/loop.py | 12 +----------- tests/test_agent_render_turn.py | 11 +++-------- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/src/tau_agent/loop.py b/src/tau_agent/loop.py index a2d498bed9..c0731d04f8 100644 --- a/src/tau_agent/loop.py +++ b/src/tau_agent/loop.py @@ -42,7 +42,6 @@ Awaitable[tuple[AgentToolResult, bool]], ] -# Per-turn configuration returned by ``render_turn``: (model, system, tools). TurnConfig = tuple[str, str, list[AgentTool]] TurnRenderer = Callable[[], TurnConfig | Awaitable[TurnConfig] | None] @@ -63,14 +62,7 @@ async def run_agent_loop( after_tool_call: AfterToolCall | None = None, render_turn: TurnRenderer | None = None, ) -> AsyncIterator[AgentEvent]: - """Run the provider/tool loop and emit Pi-compatible agent events. - - When *render_turn* is provided, it is called before every provider request - and may return a ``(model, system, tools)`` tuple to override the agent - configuration for that turn (``None`` keeps the current configuration). A - changed model is announced with ``ModelChangeEvent`` before the request is - made; changed system prompts and tool sets apply silently. - """ + """Run the provider/tool loop and emit Pi-compatible agent events.""" new_messages = list(prompts) if prompts: messages.extend(prompts) @@ -111,8 +103,6 @@ async def run_agent_loop( yield MessageEndEvent(message=message) pending = () - # Re-render the agent between turns: the renderer may swap the - # model, system prompt, and tool set for the next request. if render_turn is not None: rendered = render_turn() if isawaitable(rendered): diff --git a/tests/test_agent_render_turn.py b/tests/test_agent_render_turn.py index 36bf7210c1..6ece7472f4 100644 --- a/tests/test_agent_render_turn.py +++ b/tests/test_agent_render_turn.py @@ -1,5 +1,3 @@ -"""Tests for per-turn agent re-rendering (``render_turn``).""" - from __future__ import annotations from collections.abc import AsyncIterator, Mapping @@ -41,7 +39,7 @@ async def execute( tool_call_id: str, arguments: Mapping[str, JSONValue], signal: SimpleCancellationToken | None = None, - on_update=None, # noqa: ANN001 + on_update=None, ) -> AgentToolResult: del tool_call_id, arguments, signal, on_update return AgentToolResult(content=[TextContent(text=f"{name} ran")]) @@ -60,8 +58,8 @@ async def test_render_turn_swaps_model_system_and_tools_between_turns() -> None: beta = _tool("beta") renderer_calls: list[tuple[str, str, list[AgentTool]] | None] = [ ("fake-2", "system-2", [beta]), - ("fake-3", "system-3", []), # beta is withdrawn before turn 2 - None, # turn 3 keeps the current configuration + ("fake-3", "system-3", []), + None, ] def render_turn() -> tuple[str, str, list[AgentTool]] | None: @@ -110,18 +108,15 @@ def render_turn() -> tuple[str, str, list[AgentTool]] | None: ) ) - # Every request uses the configuration from the latest render. assert [call[0] for call in provider.calls] == ["fake-2", "fake-3", "fake-3"] assert [call[1] for call in provider.calls] == ["system-2", "system-3", "system-3"] assert [call[3] for call in provider.calls] == [[beta], [], []] - # Model swaps are announced; unchanged turns are not. assert [e.model for e in events if isinstance(e, ModelChangeEvent)] == [ "fake-2", "fake-3", ] - # The withdrawn tool is gone from the next turn: calling it errors. results = [ message for message in messages From b3e3af30bd5a1ccf95e01d82c7ebe579cef27588 Mon Sep 17 00:00:00 2001 From: Dheerapat Tookkane Date: Sun, 2 Aug 2026 04:11:08 +0700 Subject: [PATCH 4/5] fix: isolate render_turn failures and skip renderer on max_turns boundary --- dev-notes/render-turn-dynamic-agent-config.md | 30 +++- src/tau_agent/loop.py | 41 ++++-- tests/test_agent_render_turn.py | 130 ++++++++++++++++++ 3 files changed, 183 insertions(+), 18 deletions(-) diff --git a/dev-notes/render-turn-dynamic-agent-config.md b/dev-notes/render-turn-dynamic-agent-config.md index 8718c20b4d..64367fc030 100644 --- a/dev-notes/render-turn-dynamic-agent-config.md +++ b/dev-notes/render-turn-dynamic-agent-config.md @@ -5,16 +5,18 @@ `run_agent_loop()` and `AgentHarnessConfig` now accept an optional `render_turn` callback. It runs before every provider request and may return a `(model, system, tools)` tuple to override the agent's configuration for that -turn — `None` keeps the current configuration. Both sync and async callables -are supported. +turn — `None` keeps the current configuration. Each field is optional +(`None` keeps that field's current value), so a renderer that only wants to +swap the model does not need to reproduce the system prompt or tool list. +Both sync and async callables are supported. When the model changes, a new `ModelChangeEvent` is emitted before the request, so listeners (UI, sessions) can observe the switch. System-prompt and tool-set changes apply silently. ```python -def render_turn() -> tuple[str, str, list[AgentTool]] | None: - return ("bigger-model", system, tools) if step == "hard" else None +def render_turn() -> tuple[str | None, str | None, list[AgentTool] | None] | None: + return ("bigger-model", None, None) if step == "hard" else None run_agent_loop(..., render_turn=render_turn) ``` @@ -29,6 +31,20 @@ that switches models mid-task — had to rebuild the harness between messages. `render_turn` makes turn-level configuration changes a loop concern, where the tools, system prompt, and model already live. +## State and persistence + +A rendered model swap is observable only as a transient `ModelChangeEvent`: +the harness never writes the new model back to `AgentHarnessConfig.model` +(which keeps its initial value), and the loop does not append a session +`ModelChangeEntry`. Consumers that need a durable record (session replay, UI +state) should capture the model from `ModelChangeEvent` as the stream runs and +reconcile it with `ModelChangeEntry` records themselves. + +Renderer failures are an isolation boundary: if `render_turn` raises or returns +a malformed tuple, the loop emits an in-band error turn (`render_turn failed: +...`) and terminates normally with `AgentEndEvent`, exactly like the +`max_turns` boundary path. + ## How it maps to the architecture Per `AGENTS.md`, the agent loop is a portable `tau_agent` concern: events are @@ -40,6 +56,8 @@ agent event. ## How to test or use it - `tests/test_agent_render_turn.py` covers per-turn config swaps, withdrawn - tools erroring on the next turn, `None` passthrough, async renderers, and - harness pass-through. + tools erroring on the next turn, `None` passthrough, async renderers, harness + pass-through, raising/malformed renderers terminating with `AgentEndEvent`, + the renderer being skipped on the `max_turns` boundary turn, and + `ModelChangeEvent` ordering before the turn it configures. - Default behavior is unchanged: `render_turn=None` is identical to before. diff --git a/src/tau_agent/loop.py b/src/tau_agent/loop.py index c0731d04f8..a3e2dc5cf6 100644 --- a/src/tau_agent/loop.py +++ b/src/tau_agent/loop.py @@ -42,7 +42,8 @@ Awaitable[tuple[AgentToolResult, bool]], ] -TurnConfig = tuple[str, str, list[AgentTool]] +# Optional fields: None keeps the current value for that field. +TurnConfig = tuple[str | None, str | None, list[AgentTool] | None] TurnRenderer = Callable[[], TurnConfig | Awaitable[TurnConfig] | None] @@ -103,17 +104,6 @@ async def run_agent_loop( yield MessageEndEvent(message=message) pending = () - if render_turn is not None: - rendered = render_turn() - if isawaitable(rendered): - rendered = await rendered - if rendered is not None: - model, system, tools = rendered - tool_by_name = {tool.name: tool for tool in tools} - if model != last_model: - last_model = model - yield ModelChangeEvent(model=model) - if max_turns is not None and turn > max_turns: error = _error_message(model, f"Agent stopped after max_turns={max_turns}") messages.append(error) @@ -124,6 +114,33 @@ async def run_agent_loop( yield AgentEndEvent(messages=new_messages) return + if render_turn is not None: + try: + rendered = render_turn() + if isawaitable(rendered): + rendered = await rendered + if rendered is not None: + new_model, new_system, new_tools = rendered + if new_model is not None: + if new_model != last_model: + last_model = new_model + yield ModelChangeEvent(model=new_model) + model = new_model + if new_system is not None: + system = new_system + if new_tools is not None: + tools = new_tools + tool_by_name = {tool.name: tool for tool in tools} + except Exception as exc: # noqa: BLE001 - renderers are an isolation boundary + error = _error_message(model, f"render_turn failed: {exc}") + messages.append(error) + new_messages.append(error) + yield MessageStartEvent(message=error) + yield MessageEndEvent(message=error) + yield TurnEndEvent(message=error) + yield AgentEndEvent(messages=new_messages) + return + # Python async generators cannot pass a yielding callback through a # normal await cleanly, so consume the assistant sub-generator and # retain its final message through the terminal event. diff --git a/tests/test_agent_render_turn.py b/tests/test_agent_render_turn.py index 6ece7472f4..b5a2e5acfa 100644 --- a/tests/test_agent_render_turn.py +++ b/tests/test_agent_render_turn.py @@ -11,6 +11,7 @@ tool_call_end, ) from tau_agent import ( + AgentEndEvent, AgentEvent, AgentHarness, AgentHarnessConfig, @@ -18,11 +19,13 @@ AgentTool, AgentToolResult, AssistantMessage, + MessageStartEvent, ModelChangeEvent, SimpleCancellationToken, TextContent, ToolCall, ToolResultMessage, + TurnEndEvent, UserMessage, ) from tau_agent.loop import run_agent_loop @@ -223,3 +226,130 @@ def render_turn() -> tuple[str, str, list[AgentTool]] | None: assert [call[3] for call in provider.calls] == [[beta], [beta]] assert renderer_calls == 2 assert [e.model for e in events if isinstance(e, ModelChangeEvent)] == ["fake-2"] + + +def _raising_renderer() -> tuple[str, str, list[AgentTool]] | None: + raise RuntimeError("renderer exploded") + + +def _malformed_renderer() -> tuple[str, str, list[AgentTool]] | None: + return ("only-model", "missing-tools") + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "renderer", + [_raising_renderer, _malformed_renderer], + ids=["raising", "malformed-tuple"], +) +async def test_render_turn_failure_terminates_stream_gracefully( + renderer, +) -> None: + """A broken renderer must not abort the stream without a terminal event.""" + final = AssistantMessage(content="Done.", model="fake") + provider = FakeProvider([[assistant_start(), text_delta("Done."), assistant_done(final)]]) + messages: list[AgentMessage] = [UserMessage(content="Go")] + + events = await _collect( + run_agent_loop( + provider=provider, + model="fake", + system="system-1", + messages=messages, + tools=[_tool("alpha")], + render_turn=renderer, + ) + ) + + assert not provider.calls + end = events[-1] + assert isinstance(end, AgentEndEvent) + error = end.messages[-1] + assert isinstance(error, AssistantMessage) + assert error.stop_reason == "error" + assert "render_turn failed" in error.error_message + assert error.model == "fake" + turn_ends = [e for e in events if isinstance(e, TurnEndEvent)] + assert len(turn_ends) == 1 + assert turn_ends[0].message == error + + +@pytest.mark.anyio +async def test_render_turn_skipped_on_max_turns_boundary_turn() -> None: + """The renderer must not run on a turn that makes no request.""" + first = AssistantMessage( + content=[ToolCall(id="c1", name="alpha", arguments={})], + model="fake", + ) + final = AssistantMessage(content="Done.", model="fake") + provider = FakeProvider( + [ + [ + assistant_start(), + tool_call_end(ToolCall(id="c1", name="alpha", arguments={})), + assistant_done(first, "toolUse"), + ], + [assistant_start(), text_delta("Done."), assistant_done(final)], + ] + ) + renderer_calls = 0 + + def render_turn() -> tuple[str, str, list[AgentTool]] | None: + nonlocal renderer_calls + renderer_calls += 1 + return ("fake-2", "system-2", []) if renderer_calls > 1 else None + + messages: list[AgentMessage] = [UserMessage(content="Go")] + + events = await _collect( + run_agent_loop( + provider=provider, + model="fake", + system="system-1", + messages=messages, + tools=[_tool("alpha")], + max_turns=1, + render_turn=render_turn, + ) + ) + + assert renderer_calls == 1 + assert [call[0] for call in provider.calls] == ["fake"] + assert not any(isinstance(e, ModelChangeEvent) for e in events) + ends = [e for e in events if isinstance(e, AgentEndEvent)] + assert len(ends) == 1 + error = ends[0].messages[-1] + assert isinstance(error, AssistantMessage) + assert error.error_message == "Agent stopped after max_turns=1" + assert error.model == "fake" + + +@pytest.mark.anyio +async def test_model_change_event_precedes_turn_messages() -> None: + """ModelChangeEvent must be emitted before the turn it configures.""" + final = AssistantMessage(content="Done.", model="fake") + provider = FakeProvider([[assistant_start(), text_delta("Done."), assistant_done(final)]]) + + def render_turn() -> tuple[str, str, list[AgentTool]]: + return ("fake-2", "system-2", []) + + events = await _collect( + run_agent_loop( + provider=provider, + model="fake", + system="system-1", + messages=[UserMessage(content="Go")], + tools=[], + render_turn=render_turn, + ) + ) + + model_change_idx = next(i for i, e in enumerate(events) if isinstance(e, ModelChangeEvent)) + first_assistant_start_idx = next( + i + for i, e in enumerate(events) + if isinstance(e, MessageStartEvent) and isinstance(e.message, AssistantMessage) + ) + assert model_change_idx < first_assistant_start_idx + assert events[model_change_idx].model == "fake-2" + assert provider.calls[0][0] == "fake-2" From c3060b0cb284733b30650d620aeff444e9fe0c45 Mon Sep 17 00:00:00 2001 From: Dheerapat Tookkane Date: Sun, 2 Aug 2026 04:12:20 +0700 Subject: [PATCH 5/5] nit: typo --- src/tau_agent/loop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tau_agent/loop.py b/src/tau_agent/loop.py index a3e2dc5cf6..7d4872e5e1 100644 --- a/src/tau_agent/loop.py +++ b/src/tau_agent/loop.py @@ -131,7 +131,7 @@ async def run_agent_loop( if new_tools is not None: tools = new_tools tool_by_name = {tool.name: tool for tool in tools} - except Exception as exc: # noqa: BLE001 - renderers are an isolation boundary + except Exception as exc: error = _error_message(model, f"render_turn failed: {exc}") messages.append(error) new_messages.append(error)