diff --git a/README.md b/README.md index e28697d06..d23e93b83 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/dev-notes/architecture/decycle-agent-provider-contract.md b/dev-notes/architecture/decycle-agent-provider-contract.md new file mode 100644 index 000000000..3cd04086a --- /dev/null +++ b/dev-notes/architecture/decycle-agent-provider-contract.md @@ -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) diff --git a/src/tau_agent/__init__.py b/src/tau_agent/__init__.py index e2077245a..75d98ecf5 100644 --- a/src/tau_agent/__init__.py +++ b/src/tau_agent/__init__.py @@ -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, @@ -56,6 +67,7 @@ "AgentToolResult", "AssistantMessage", "BranchSummaryEntry", + "CancellationToken", "CompactionEntry", "CustomEntry", "ErrorEvent", @@ -71,6 +83,15 @@ "MessageEntry", "MessageStartEvent", "ModelChangeEntry", + "ModelProvider", + "ProviderErrorEvent", + "ProviderEvent", + "ProviderResponseEndEvent", + "ProviderResponseStartEvent", + "ProviderRetryEvent", + "ProviderTextDeltaEvent", + "ProviderThinkingDeltaEvent", + "ProviderToolCallEvent", "QueuedMessages", "QueueUpdateEvent", "RetryEvent", diff --git a/src/tau_agent/harness.py b/src/tau_agent/harness.py index 0460fe1dd..302d93046 100644 --- a/src/tau_agent/harness.py +++ b/src/tau_agent/harness.py @@ -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"] diff --git a/src/tau_agent/loop.py b/src/tau_agent/loop.py index e7681fcb3..2c33ca26f 100644 --- a/src/tau_agent/loop.py +++ b/src/tau_agent/loop.py @@ -21,9 +21,8 @@ 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, @@ -31,7 +30,8 @@ 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( diff --git a/src/tau_agent/provider.py b/src/tau_agent/provider.py new file mode 100644 index 000000000..e59c1e2af --- /dev/null +++ b/src/tau_agent/provider.py @@ -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.""" + ... diff --git a/src/tau_agent/provider_events.py b/src/tau_agent/provider_events.py new file mode 100644 index 000000000..08bcac28c --- /dev/null +++ b/src/tau_agent/provider_events.py @@ -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 +) diff --git a/src/tau_ai/events.py b/src/tau_ai/events.py index 86901aefa..f7dd2384a 100644 --- a/src/tau_ai/events.py +++ b/src/tau_ai/events.py @@ -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", +] diff --git a/src/tau_ai/provider.py b/src/tau_ai/provider.py index 86e156219..32f8b9d39 100644 --- a/src/tau_ai/provider.py +++ b/src/tau_ai/provider.py @@ -1,34 +1,15 @@ -"""Provider protocol for Tau model adapters.""" - -from __future__ import annotations - -from collections.abc import AsyncIterator -from typing import Protocol - -from tau_agent.messages import AgentMessage -from tau_agent.tools import AgentTool -from tau_ai.events import ProviderEvent +"""Provider protocol, re-exported from the agent core. +The canonical definitions live in ``tau_agent.provider`` so the portable +agent core never imports from the provider layer. This module re-exports +them unchanged for backwards compatibility. +""" -class CancellationToken(Protocol): - """Minimal cancellation interface accepted by providers.""" - - def is_cancelled(self) -> bool: - """Return whether the current stream should stop.""" - ... - +from __future__ import annotations -class ModelProvider(Protocol): - """Provider-neutral interface for streaming model responses.""" +from tau_agent.provider import CancellationToken, ModelProvider - 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.""" - ... +__all__ = [ + "CancellationToken", + "ModelProvider", +] diff --git a/tests/test_layering.py b/tests/test_layering.py new file mode 100644 index 000000000..991d4f0d3 --- /dev/null +++ b/tests/test_layering.py @@ -0,0 +1,34 @@ +"""Layer-boundary regression tests for the tau_agent/tau_ai cycle (issue #317).""" + +from __future__ import annotations + +from pathlib import Path + +import tau_agent +import tau_ai + +TAU_AGENT_SRC = Path(tau_agent.__file__).resolve().parent + + +def test_tau_agent_does_not_import_tau_ai() -> None: + offenders = [ + str(path) + for path in sorted(TAU_AGENT_SRC.rglob("*.py")) + if "from tau_ai" in path.read_text(encoding="utf-8") + or "import tau_ai" in path.read_text(encoding="utf-8") + ] + assert offenders == [] + + +def test_tau_ai_reexports_are_the_same_objects() -> None: + # Duplicated class definitions would silently break the isinstance + # dispatch in the agent loop; re-exports must share identity. + assert tau_ai.ModelProvider is tau_agent.ModelProvider + assert tau_ai.CancellationToken is tau_agent.CancellationToken + assert tau_ai.ProviderErrorEvent is tau_agent.ProviderErrorEvent + assert tau_ai.ProviderResponseEndEvent is tau_agent.ProviderResponseEndEvent + assert tau_ai.ProviderResponseStartEvent is tau_agent.ProviderResponseStartEvent + assert tau_ai.ProviderRetryEvent is tau_agent.ProviderRetryEvent + assert tau_ai.ProviderTextDeltaEvent is tau_agent.ProviderTextDeltaEvent + assert tau_ai.ProviderThinkingDeltaEvent is tau_agent.ProviderThinkingDeltaEvent + assert tau_ai.ProviderToolCallEvent is tau_agent.ProviderToolCallEvent diff --git a/website/content/internals/architecture.md b/website/content/internals/architecture.md index ed936e14e..f4e24e498 100644 --- a/website/content/internals/architecture.md +++ b/website/content/internals/architecture.md @@ -10,7 +10,7 @@ paths, or rendering. Everything app-specific wraps around it. ## Three packages ```text -tau_coding → tau_agent → tau_ai +tau_coding → tau_agent ← tau_ai ``` ### `tau_ai` — talking to models @@ -36,8 +36,11 @@ Textual TUI. ## Dependency direction -Dependencies only point one way: `tau_coding → tau_agent → tau_ai`. UI code -*consumes* events; the core never reaches up to render anything. In one line: +Dependencies point one way — inward to the portable core. `tau_agent` defines +the contracts (`ModelProvider`, the provider events) and imports nothing from +the other layers; `tau_ai` implements those contracts; `tau_coding` wires both +together. UI code *consumes* events; the core never reaches up to render +anything. In one line: ```text AgentHarness = reusable brain