Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions dev-notes/render-turn-dynamic-agent-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# 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. 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 | None, str | None, list[AgentTool] | None] | None:
return ("bigger-model", None, None) 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.

## 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
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, 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.
1 change: 1 addition & 0 deletions src/tau_agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
MessageEndEvent,
MessageStartEvent,
MessageUpdateEvent,
ModelChangeEvent,
ToolExecutionEndEvent,
ToolExecutionStartEvent,
ToolExecutionUpdateEvent,
Expand Down
8 changes: 7 additions & 1 deletion src/tau_agent/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -82,6 +87,7 @@ class ToolExecutionEndEvent(WireModel):
| MessageEndEvent
| ToolExecutionStartEvent
| ToolExecutionUpdateEvent
| ToolExecutionEndEvent,
| ToolExecutionEndEvent
| ModelChangeEvent,
Field(discriminator="type"),
]
9 changes: 8 additions & 1 deletion src/tau_agent/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@
from inspect import isawaitable
from typing import Literal

from tau_agent.loop import (
AfterToolCall,
BeforeToolCall,
TurnRenderer,
run_agent_loop,
)
from tau_agent.events import AgentEvent, MessageEndEvent, MessageStartEvent
from tau_agent.loop import AfterToolCall, BeforeToolCall, run_agent_loop
from tau_agent.messages import (
AgentMessage,
AssistantMessage,
Expand Down Expand Up @@ -46,6 +51,7 @@ class AgentHarnessConfig:
session_id: str | None = None
before_tool_call: BeforeToolCall | None = None
after_tool_call: AfterToolCall | None = None
render_turn: TurnRenderer | None = None


class SimpleCancellationToken:
Expand Down Expand Up @@ -186,6 +192,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
Expand Down
36 changes: 36 additions & 0 deletions src/tau_agent/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@

import asyncio
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
from inspect import isawaitable
from time import monotonic_ns


from tau_agent.events import (
AgentEndEvent,
AgentEvent,
AgentStartEvent,
MessageEndEvent,
MessageStartEvent,
MessageUpdateEvent,
ModelChangeEvent,
ToolExecutionEndEvent,
ToolExecutionStartEvent,
ToolExecutionUpdateEvent,
Expand Down Expand Up @@ -48,6 +51,10 @@
Awaitable[tuple[AgentToolResult, bool]],
]

# 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]


async def run_agent_loop(
*,
Expand All @@ -65,6 +72,7 @@ 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."""
new_messages = list(prompts)
Expand All @@ -91,6 +99,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 ())
Expand Down Expand Up @@ -119,6 +128,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:
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.
Expand Down
Loading