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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ shape of a coding-agent system without starting from a giant production
codebase.

```text
tau_coding → tau_agent tau_ai
tau_coding → tau_agent tau_ai
```

- `tau_ai` translates model providers into Tau's provider-neutral stream.
Expand Down
36 changes: 36 additions & 0 deletions dev-notes/architecture/decycle-agent-provider-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Breaking the tau_agent ↔ tau_ai import cycle

Issue: https://github.com/alejandro-ao/tau/issues/317

## What changed

`tau_agent.loop` and `tau_agent.harness` used to import `ModelProvider`,
`CancellationToken`, and the `Provider*Event` types from `tau_ai`, while every
`tau_ai` adapter imported message/tool types from `tau_agent`. That made the
two packages mutually dependent, contradicting the documented one-way layering.

The fix applies dependency inversion: the portable core now owns the contract.

- `tau_agent/provider.py` — canonical `ModelProvider` and `CancellationToken`
protocols.
- `tau_agent/provider_events.py` — canonical `ProviderEvent` types.
- `tau_ai/provider.py` and `tau_ai/events.py` are pure re-export shims, so all
existing `from tau_ai import ...` call sites keep working.

Dependencies now point inward: `tau_coding → tau_agent ← tau_ai`, and
`tau_agent` imports nothing from the other layers.

## Why re-export shims, not copies

The agent loop dispatches provider events with `isinstance`. If the classes
were redefined in both packages, adapters would emit old-class instances that
the loop's checks silently ignore. The shims import the same class objects, so
identity (and `isinstance`) is preserved across both import paths.

## How it is tested

`tests/test_layering.py`:

- scans `tau_agent` sources for any `tau_ai` import (the boundary itself)
- asserts the `tau_ai` re-exports are identical objects to the `tau_agent`
definitions (the isinstance trap)
21 changes: 21 additions & 0 deletions src/tau_agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@
)
from tau_agent.loop import run_agent_loop
from tau_agent.messages import AgentMessage, AssistantMessage, ToolResultMessage, UserMessage
from tau_agent.provider import CancellationToken, ModelProvider
from tau_agent.provider_events import (
ProviderErrorEvent,
ProviderEvent,
ProviderResponseEndEvent,
ProviderResponseStartEvent,
ProviderRetryEvent,
ProviderTextDeltaEvent,
ProviderThinkingDeltaEvent,
ProviderToolCallEvent,
)
from tau_agent.session import (
BranchSummaryEntry,
CompactionEntry,
Expand Down Expand Up @@ -56,6 +67,7 @@
"AgentToolResult",
"AssistantMessage",
"BranchSummaryEntry",
"CancellationToken",
"CompactionEntry",
"CustomEntry",
"ErrorEvent",
Expand All @@ -71,6 +83,15 @@
"MessageEntry",
"MessageStartEvent",
"ModelChangeEntry",
"ModelProvider",
"ProviderErrorEvent",
"ProviderEvent",
"ProviderResponseEndEvent",
"ProviderResponseStartEvent",
"ProviderRetryEvent",
"ProviderTextDeltaEvent",
"ProviderThinkingDeltaEvent",
"ProviderToolCallEvent",
"QueuedMessages",
"QueueUpdateEvent",
"RetryEvent",
Expand Down
2 changes: 1 addition & 1 deletion src/tau_agent/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
from tau_agent.events import AgentEvent, MessageEndEvent, MessageStartEvent, QueueUpdateEvent
from tau_agent.loop import run_agent_loop
from tau_agent.messages import AgentMessage, AssistantMessage, ToolResultMessage, UserMessage
from tau_agent.provider import ModelProvider
from tau_agent.tools import AgentTool
from tau_ai.provider import ModelProvider

EventListener = Callable[[AgentEvent], Awaitable[None] | None]
QueueMode = Literal["one_at_a_time", "all"]
Expand Down
8 changes: 4 additions & 4 deletions src/tau_agent/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,17 @@
TurnStartEvent,
)
from tau_agent.messages import AgentMessage, AssistantMessage, ToolResultMessage
from tau_agent.tools import AgentTool, AgentToolResult, ToolCall
from tau_agent.types import JSONValue
from tau_ai.events import (
from tau_agent.provider import CancellationToken, ModelProvider
from tau_agent.provider_events import (
ProviderErrorEvent,
ProviderResponseEndEvent,
ProviderResponseStartEvent,
ProviderRetryEvent,
ProviderTextDeltaEvent,
ProviderThinkingDeltaEvent,
)
from tau_ai.provider import CancellationToken, ModelProvider
from tau_agent.tools import AgentTool, AgentToolResult, ToolCall
from tau_agent.types import JSONValue


async def run_agent_loop(
Expand Down
39 changes: 39 additions & 0 deletions src/tau_agent/provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Provider protocol the agent loop depends on.

This is the canonical definition of the contract between the portable agent
core and model adapters. ``tau_ai`` implements this protocol and
``tau_ai.provider`` re-exports it for backwards compatibility.
"""

from __future__ import annotations

from collections.abc import AsyncIterator
from typing import Protocol

from tau_agent.messages import AgentMessage
from tau_agent.provider_events import ProviderEvent
from tau_agent.tools import AgentTool


class CancellationToken(Protocol):
"""Minimal cancellation interface accepted by providers."""

def is_cancelled(self) -> bool:
"""Return whether the current stream should stop."""
...


class ModelProvider(Protocol):
"""Provider-neutral interface for streaming model responses."""

def stream_response(
self,
*,
model: str,
system: str,
messages: list[AgentMessage],
tools: list[AgentTool],
signal: CancellationToken | None = None,
) -> AsyncIterator[ProviderEvent]:
"""Stream one model response as Tau provider events."""
...
97 changes: 97 additions & 0 deletions src/tau_agent/provider_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Provider-neutral streaming events consumed by the agent loop.

These are the canonical definitions. Provider adapters in ``tau_ai`` emit
these events; ``tau_ai.events`` re-exports them for backwards compatibility.
Keeping the definitions here means the portable agent core never imports
from the provider layer.
"""

from __future__ import annotations

from typing import Literal

from pydantic import BaseModel, ConfigDict

from tau_agent.messages import AssistantMessage
from tau_agent.tools import ToolCall
from tau_agent.types import JSONValue


class ProviderResponseStartEvent(BaseModel):
"""The provider has started a model response."""

model_config = ConfigDict(extra="forbid")

type: Literal["response_start"] = "response_start"
model: str


class ProviderRetryEvent(BaseModel):
"""The provider adapter is retrying a transient request failure."""

model_config = ConfigDict(extra="forbid")

type: Literal["retry"] = "retry"
attempt: int
max_attempts: int
delay_seconds: float
message: str
data: dict[str, JSONValue] | None = None


class ProviderTextDeltaEvent(BaseModel):
"""A streamed text fragment from the provider."""

model_config = ConfigDict(extra="forbid")

type: Literal["text_delta"] = "text_delta"
delta: str


class ProviderThinkingDeltaEvent(BaseModel):
"""A streamed thinking/reasoning fragment from the provider."""

model_config = ConfigDict(extra="forbid")

type: Literal["thinking_delta"] = "thinking_delta"
delta: str


class ProviderToolCallEvent(BaseModel):
"""A complete tool call requested by the model."""

model_config = ConfigDict(extra="forbid")

type: Literal["tool_call"] = "tool_call"
tool_call: ToolCall


class ProviderResponseEndEvent(BaseModel):
"""The provider has completed a model response."""

model_config = ConfigDict(extra="forbid")

type: Literal["response_end"] = "response_end"
message: AssistantMessage
finish_reason: str | None = None


class ProviderErrorEvent(BaseModel):
"""A provider-level error that can be surfaced by the agent layer."""

model_config = ConfigDict(extra="forbid")

type: Literal["error"] = "error"
message: str
data: dict[str, JSONValue] | None = None


type ProviderEvent = (
ProviderResponseStartEvent
| ProviderRetryEvent
| ProviderTextDeltaEvent
| ProviderThinkingDeltaEvent
| ProviderToolCallEvent
| ProviderResponseEndEvent
| ProviderErrorEvent
)
114 changes: 27 additions & 87 deletions src/tau_ai/events.py
Original file line number Diff line number Diff line change
@@ -1,91 +1,31 @@
"""Provider-neutral streaming events emitted by model adapters."""

from __future__ import annotations

from typing import Literal

from pydantic import BaseModel, ConfigDict

from tau_agent.messages import AssistantMessage
from tau_agent.tools import ToolCall
from tau_agent.types import JSONValue


class ProviderResponseStartEvent(BaseModel):
"""The provider has started a model response."""

model_config = ConfigDict(extra="forbid")

type: Literal["response_start"] = "response_start"
model: str


class ProviderRetryEvent(BaseModel):
"""The provider adapter is retrying a transient request failure."""

model_config = ConfigDict(extra="forbid")

type: Literal["retry"] = "retry"
attempt: int
max_attempts: int
delay_seconds: float
message: str
data: dict[str, JSONValue] | None = None


class ProviderTextDeltaEvent(BaseModel):
"""A streamed text fragment from the provider."""

model_config = ConfigDict(extra="forbid")

type: Literal["text_delta"] = "text_delta"
delta: str
"""Provider streaming events, re-exported from the agent core.

The canonical definitions live in ``tau_agent.provider_events`` so the
portable agent core never imports from the provider layer. This module
re-exports them unchanged — they are the same class objects, so
``isinstance`` checks work across both import paths.
"""

class ProviderThinkingDeltaEvent(BaseModel):
"""A streamed thinking/reasoning fragment from the provider."""

model_config = ConfigDict(extra="forbid")

type: Literal["thinking_delta"] = "thinking_delta"
delta: str


class ProviderToolCallEvent(BaseModel):
"""A complete tool call requested by the model."""

model_config = ConfigDict(extra="forbid")

type: Literal["tool_call"] = "tool_call"
tool_call: ToolCall


class ProviderResponseEndEvent(BaseModel):
"""The provider has completed a model response."""

model_config = ConfigDict(extra="forbid")

type: Literal["response_end"] = "response_end"
message: AssistantMessage
finish_reason: str | None = None


class ProviderErrorEvent(BaseModel):
"""A provider-level error that can be surfaced by the agent layer."""

model_config = ConfigDict(extra="forbid")

type: Literal["error"] = "error"
message: str
data: dict[str, JSONValue] | None = None

from __future__ import annotations

type ProviderEvent = (
ProviderResponseStartEvent
| ProviderRetryEvent
| ProviderTextDeltaEvent
| ProviderThinkingDeltaEvent
| ProviderToolCallEvent
| ProviderResponseEndEvent
| ProviderErrorEvent
from tau_agent.provider_events import (
ProviderErrorEvent,
ProviderEvent,
ProviderResponseEndEvent,
ProviderResponseStartEvent,
ProviderRetryEvent,
ProviderTextDeltaEvent,
ProviderThinkingDeltaEvent,
ProviderToolCallEvent,
)

__all__ = [
"ProviderErrorEvent",
"ProviderEvent",
"ProviderResponseEndEvent",
"ProviderResponseStartEvent",
"ProviderRetryEvent",
"ProviderTextDeltaEvent",
"ProviderThinkingDeltaEvent",
"ProviderToolCallEvent",
]
Loading