Add task bus package
Introduces `TaskBus`, the in-process `AsyncQueueBus`, the bus message hierarchy (lifecycle, jobs, frames, registry), a priority-aware bus queue, the `BusSubscriber` mixin, and the `BusBridgeProcessor` / internal `_BusEdgeProcessor` used to exchange frames between a local pipeline and the bus.
This commit is contained in:
88
src/pipecat/bus/__init__.py
Normal file
88
src/pipecat/bus/__init__.py
Normal file
@@ -0,0 +1,88 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Agent bus package -- pub/sub messaging between agents and the runner.
|
||||
|
||||
Provides the pub/sub infrastructure that connects agents to each other and to
|
||||
the runner. Key components:
|
||||
|
||||
- `TaskBus` -- abstract base class defining the send/receive interface.
|
||||
- `AsyncQueueBus` -- in-process implementation backed by ``asyncio.Queue``.
|
||||
- `BusBridgeProcessor` -- bidirectional mid-pipeline bridge for
|
||||
transport/session agents that exchanges frames with other agents
|
||||
through the bus.
|
||||
- `BusMessage` and its subclasses -- the typed message hierarchy used for
|
||||
agent lifecycle events (activation, cancellation, shutdown), task
|
||||
coordination, and frame transport.
|
||||
"""
|
||||
|
||||
from pipecat.bus.bridge_processor import BusBridgeProcessor
|
||||
from pipecat.bus.bus import TaskBus
|
||||
from pipecat.bus.local import AsyncQueueBus
|
||||
from pipecat.bus.messages import (
|
||||
BusActivateTaskMessage,
|
||||
BusAddTaskMessage,
|
||||
BusCancelMessage,
|
||||
BusCancelTaskMessage,
|
||||
BusDataMessage,
|
||||
BusDeactivateTaskMessage,
|
||||
BusEndMessage,
|
||||
BusEndTaskMessage,
|
||||
BusFrameMessage,
|
||||
BusJobCancelMessage,
|
||||
BusJobRequestMessage,
|
||||
BusJobResponseMessage,
|
||||
BusJobResponseUrgentMessage,
|
||||
BusJobStreamDataMessage,
|
||||
BusJobStreamEndMessage,
|
||||
BusJobStreamStartMessage,
|
||||
BusJobUpdateMessage,
|
||||
BusJobUpdateRequestMessage,
|
||||
BusJobUpdateUrgentMessage,
|
||||
BusLocalMessage,
|
||||
BusMessage,
|
||||
BusSystemMessage,
|
||||
BusTaskErrorMessage,
|
||||
BusTaskLocalErrorMessage,
|
||||
BusTaskReadyMessage,
|
||||
BusTaskRegistryMessage,
|
||||
)
|
||||
from pipecat.bus.subscriber import BusSubscriber
|
||||
from pipecat.registry.types import TaskRegistryEntry
|
||||
|
||||
__all__ = [
|
||||
"TaskBus",
|
||||
"AsyncQueueBus",
|
||||
"BusActivateTaskMessage",
|
||||
"BusAddTaskMessage",
|
||||
"BusTaskErrorMessage",
|
||||
"BusTaskLocalErrorMessage",
|
||||
"TaskRegistryEntry",
|
||||
"BusTaskReadyMessage",
|
||||
"BusTaskRegistryMessage",
|
||||
"BusBridgeProcessor",
|
||||
"BusCancelTaskMessage",
|
||||
"BusCancelMessage",
|
||||
"BusDeactivateTaskMessage",
|
||||
"BusEndTaskMessage",
|
||||
"BusEndMessage",
|
||||
"BusFrameMessage",
|
||||
"BusDataMessage",
|
||||
"BusLocalMessage",
|
||||
"BusMessage",
|
||||
"BusSubscriber",
|
||||
"BusSystemMessage",
|
||||
"BusJobCancelMessage",
|
||||
"BusJobRequestMessage",
|
||||
"BusJobResponseMessage",
|
||||
"BusJobResponseUrgentMessage",
|
||||
"BusJobStreamDataMessage",
|
||||
"BusJobStreamEndMessage",
|
||||
"BusJobStreamStartMessage",
|
||||
"BusJobUpdateMessage",
|
||||
"BusJobUpdateRequestMessage",
|
||||
"BusJobUpdateUrgentMessage",
|
||||
]
|
||||
21
src/pipecat/bus/adapters/__init__.py
Normal file
21
src/pipecat/bus/adapters/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Type adapters for bus message serialization.
|
||||
|
||||
Provides ready-made ``TypeAdapter`` implementations for common Pipecat types
|
||||
(``LLMContext``, ``ToolsSchema``) used in bus messages.
|
||||
"""
|
||||
|
||||
from pipecat.bus.adapters.base import TypeAdapter
|
||||
from pipecat.bus.adapters.llm_context_adapter import LLMContextAdapter
|
||||
from pipecat.bus.adapters.tools_schema_adapter import ToolsSchemaAdapter
|
||||
|
||||
__all__ = [
|
||||
"LLMContextAdapter",
|
||||
"ToolsSchemaAdapter",
|
||||
"TypeAdapter",
|
||||
]
|
||||
59
src/pipecat/bus/adapters/base.py
Normal file
59
src/pipecat/bus/adapters/base.py
Normal file
@@ -0,0 +1,59 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Abstract base class for type adapters used by message serializers."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
SerializeFunc = Callable[[Any], Any]
|
||||
DeserializeFunc = Callable[[Any], Any]
|
||||
|
||||
|
||||
class TypeAdapter(ABC):
|
||||
"""Serialize and deserialize instances of a specific type for network transport.
|
||||
|
||||
Each adapter handles one or more types, converting them to/from a
|
||||
JSON-compatible dict. Register adapters on a ``JSONMessageSerializer``
|
||||
to handle non-JSON-native field values (e.g. ``LLMContext``, ``ToolsSchema``).
|
||||
|
||||
Adapters receive ``serialize_value`` and ``deserialize_value`` callbacks
|
||||
from the serializer so they can recursively serialize nested fields
|
||||
without importing the serializer itself.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def serialize(self, obj: Any, serialize_value: SerializeFunc) -> dict[str, Any]:
|
||||
"""Convert an object to a JSON-compatible dict.
|
||||
|
||||
Args:
|
||||
obj: The object to serialize.
|
||||
serialize_value: Callback to recursively serialize nested values.
|
||||
|
||||
Returns:
|
||||
A dict representation of the object.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def deserialize(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
deserialize_value: DeserializeFunc,
|
||||
target_type: type | None = None,
|
||||
) -> Any:
|
||||
"""Reconstruct an object from a dict.
|
||||
|
||||
Args:
|
||||
data: The dict representation produced by ``serialize()``.
|
||||
deserialize_value: Callback to recursively deserialize nested values.
|
||||
target_type: The resolved target class. Defaults to None.
|
||||
|
||||
Returns:
|
||||
The reconstructed object.
|
||||
"""
|
||||
pass
|
||||
108
src/pipecat/bus/adapters/llm_context_adapter.py
Normal file
108
src/pipecat/bus/adapters/llm_context_adapter.py
Normal file
@@ -0,0 +1,108 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Type adapter for LLMContext serialization."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from openai import NOT_GIVEN as OPENAI_NOT_GIVEN
|
||||
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.bus.adapters.base import DeserializeFunc, SerializeFunc, TypeAdapter
|
||||
from pipecat.processors.aggregators.llm_context import (
|
||||
LLMContext,
|
||||
LLMSpecificMessage,
|
||||
NotGiven,
|
||||
)
|
||||
|
||||
|
||||
class LLMContextAdapter(TypeAdapter):
|
||||
"""Serialize and deserialize ``LLMContext`` instances.
|
||||
|
||||
The ``NOT_GIVEN`` sentinel is preserved across serialization: missing
|
||||
keys are restored as ``NOT_GIVEN`` on deserialization.
|
||||
"""
|
||||
|
||||
def serialize(self, obj: Any, serialize_value: SerializeFunc) -> dict[str, Any]:
|
||||
"""Serialize an ``LLMContext`` to a JSON-compatible dict.
|
||||
|
||||
Args:
|
||||
obj: An ``LLMContext`` instance.
|
||||
serialize_value: Callback to recursively serialize nested values.
|
||||
|
||||
Returns:
|
||||
A dict with ``messages`` and, optionally, ``tools`` and
|
||||
``tool_choice`` keys.
|
||||
"""
|
||||
result: dict[str, Any] = {
|
||||
"messages": [self._serialize_message(m, serialize_value) for m in obj.messages],
|
||||
}
|
||||
if not isinstance(obj.tools, NotGiven):
|
||||
result["tools"] = self._serialize_tools(obj.tools)
|
||||
if not isinstance(obj.tool_choice, NotGiven):
|
||||
result["tool_choice"] = serialize_value(obj.tool_choice)
|
||||
return result
|
||||
|
||||
def deserialize(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
deserialize_value: DeserializeFunc,
|
||||
target_type: type | None = None,
|
||||
) -> Any:
|
||||
"""Reconstruct an ``LLMContext`` from a serialized dict.
|
||||
|
||||
Missing ``tools`` and ``tool_choice`` keys are restored as
|
||||
OpenAI's ``NOT_GIVEN`` sentinel.
|
||||
|
||||
Args:
|
||||
data: A dict produced by ``serialize()``.
|
||||
deserialize_value: Callback to recursively deserialize nested values.
|
||||
target_type: Unused. ``LLMContext`` is always the target.
|
||||
|
||||
Returns:
|
||||
A new ``LLMContext`` instance.
|
||||
"""
|
||||
messages = [self._deserialize_message(m, deserialize_value) for m in data["messages"]]
|
||||
tools = self._deserialize_tools(data["tools"]) if "tools" in data else OPENAI_NOT_GIVEN
|
||||
tool_choice = (
|
||||
deserialize_value(data["tool_choice"]) if "tool_choice" in data else OPENAI_NOT_GIVEN
|
||||
)
|
||||
return LLMContext(messages=messages, tools=tools, tool_choice=tool_choice)
|
||||
|
||||
def _serialize_message(self, msg: Any, serialize_value: SerializeFunc) -> dict[str, Any]:
|
||||
if isinstance(msg, LLMSpecificMessage):
|
||||
return {
|
||||
"__specific__": True,
|
||||
"llm": msg.llm,
|
||||
"message": serialize_value(msg.message),
|
||||
}
|
||||
return serialize_value(msg)
|
||||
|
||||
def _deserialize_message(self, data: Any, deserialize_value: DeserializeFunc) -> Any:
|
||||
if isinstance(data, dict) and data.get("__specific__"):
|
||||
return LLMSpecificMessage(
|
||||
llm=data["llm"],
|
||||
message=deserialize_value(data["message"]),
|
||||
)
|
||||
return deserialize_value(data)
|
||||
|
||||
def _serialize_tools(self, tools: Any) -> list[dict[str, Any]]:
|
||||
return [tool.to_default_dict() for tool in tools.standard_tools]
|
||||
|
||||
def _deserialize_tools(self, data: list[dict[str, Any]]) -> Any:
|
||||
tools = []
|
||||
for item in data:
|
||||
params = item.get("parameters", {})
|
||||
tools.append(
|
||||
FunctionSchema(
|
||||
name=item["name"],
|
||||
description=item.get("description", ""),
|
||||
properties=params.get("properties", {}),
|
||||
required=params.get("required", []),
|
||||
)
|
||||
)
|
||||
return ToolsSchema(standard_tools=tools)
|
||||
58
src/pipecat/bus/adapters/tools_schema_adapter.py
Normal file
58
src/pipecat/bus/adapters/tools_schema_adapter.py
Normal file
@@ -0,0 +1,58 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Type adapter for ToolsSchema serialization."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.bus.adapters.base import DeserializeFunc, SerializeFunc, TypeAdapter
|
||||
|
||||
|
||||
class ToolsSchemaAdapter(TypeAdapter):
|
||||
"""Serialize and deserialize ``ToolsSchema`` instances for network transport."""
|
||||
|
||||
def serialize(self, obj: Any, serialize_value: SerializeFunc) -> dict[str, Any]:
|
||||
"""Serialize a ``ToolsSchema`` to a JSON-compatible dict.
|
||||
|
||||
Args:
|
||||
obj: A ``ToolsSchema`` instance.
|
||||
serialize_value: Callback to recursively serialize nested values.
|
||||
|
||||
Returns:
|
||||
A dict with a ``standard_tools`` list.
|
||||
"""
|
||||
return {"standard_tools": [tool.to_default_dict() for tool in obj.standard_tools]}
|
||||
|
||||
def deserialize(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
deserialize_value: DeserializeFunc,
|
||||
target_type: type | None = None,
|
||||
) -> Any:
|
||||
"""Reconstruct a ``ToolsSchema`` from a serialized dict.
|
||||
|
||||
Args:
|
||||
data: A dict produced by ``serialize()``.
|
||||
deserialize_value: Callback to recursively deserialize nested values.
|
||||
target_type: Unused. ``ToolsSchema`` is always the target.
|
||||
|
||||
Returns:
|
||||
A new ``ToolsSchema`` instance.
|
||||
"""
|
||||
tools = []
|
||||
for item in data["standard_tools"]:
|
||||
params = item.get("parameters", {})
|
||||
tools.append(
|
||||
FunctionSchema(
|
||||
name=item["name"],
|
||||
description=item.get("description", ""),
|
||||
properties=params.get("properties", {}),
|
||||
required=params.get("required", []),
|
||||
)
|
||||
)
|
||||
return ToolsSchema(standard_tools=tools)
|
||||
233
src/pipecat/bus/bridge_processor.py
Normal file
233
src/pipecat/bus/bridge_processor.py
Normal file
@@ -0,0 +1,233 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Bus bridge and edge processors for inter-task frame routing.
|
||||
|
||||
Provides:
|
||||
|
||||
- `BusBridgeProcessor`: a mid-pipeline processor that exchanges frames
|
||||
with other tasks through the bus, consuming local frames.
|
||||
- `_BusEdgeProcessor`: a pipeline-edge processor used internally by
|
||||
`PipelineTask` when ``bridged`` is set. Tees frames between the local
|
||||
pipeline and the bus (frames continue locally and are also forwarded
|
||||
to the bus).
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pipecat.bus.bus import TaskBus
|
||||
from pipecat.bus.messages import BusFrameMessage, BusMessage
|
||||
from pipecat.bus.subscriber import BusSubscriber
|
||||
from pipecat.frames.frames import (
|
||||
CancelFrame,
|
||||
EndFrame,
|
||||
Frame,
|
||||
OutputTransportMessageUrgentFrame,
|
||||
StartFrame,
|
||||
StopFrame,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, FrameProcessorSetup
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pipecat.pipeline.base_task import BaseTask
|
||||
|
||||
_LIFECYCLE_FRAMES = (StartFrame, EndFrame, CancelFrame, StopFrame)
|
||||
_PASSTHROUGH_FRAMES = (OutputTransportMessageUrgentFrame,)
|
||||
|
||||
|
||||
class BusBridgeProcessor(FrameProcessor, BusSubscriber):
|
||||
"""Bidirectional mid-pipeline bridge between a Pipecat pipeline and the bus.
|
||||
|
||||
Placed in a transport or session agent's pipeline to exchange frames
|
||||
with other agents via the `TaskBus`. Lifecycle and excluded frames
|
||||
pass through locally without crossing the bus.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bus: TaskBus,
|
||||
agent_name: str,
|
||||
target_agent: str | None = None,
|
||||
bridge: str | None = None,
|
||||
exclude_frames: tuple[type[Frame], ...] | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the BusBridgeProcessor.
|
||||
|
||||
Args:
|
||||
bus: The ``TaskBus`` to exchange frames with.
|
||||
agent_name: Name of this agent, used as message source.
|
||||
target_agent: When set, only exchange frames with this agent.
|
||||
bridge: Optional bridge name for routing. When set, outgoing
|
||||
frames are tagged with this name and only incoming frames
|
||||
with the same bridge name are accepted.
|
||||
exclude_frames: Extra frame types that should never cross the bus
|
||||
(on top of lifecycle frames which are always excluded).
|
||||
**kwargs: Additional arguments passed to ``FrameProcessor``.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._bus = bus
|
||||
self._agent_name = agent_name
|
||||
self._target_agent = target_agent
|
||||
self._bridge = bridge
|
||||
self._exclude_frames = exclude_frames or ()
|
||||
|
||||
async def setup(self, setup: FrameProcessorSetup):
|
||||
"""Subscribe to the bus during processor setup."""
|
||||
await super().setup(setup)
|
||||
await self._bus.subscribe(self)
|
||||
|
||||
async def cleanup(self):
|
||||
"""Unsubscribe from the bus on cleanup."""
|
||||
await super().cleanup()
|
||||
await self._bus.unsubscribe(self)
|
||||
|
||||
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||
"""Process a frame: send to bus, or pass through locally if excluded.
|
||||
|
||||
Args:
|
||||
frame: The frame to process.
|
||||
direction: The direction the frame is traveling.
|
||||
"""
|
||||
await super().process_frame(frame, direction)
|
||||
|
||||
# Lifecycle frames never cross the bus
|
||||
if isinstance(frame, _LIFECYCLE_FRAMES):
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
|
||||
# Urgent transport frames pass through directly. They need to
|
||||
# reach the transport even when no child agent is active yet.
|
||||
if isinstance(frame, _PASSTHROUGH_FRAMES):
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
|
||||
# Excluded frames never cross the bus
|
||||
if self._exclude_frames and isinstance(frame, self._exclude_frames):
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
|
||||
# Send to bus
|
||||
msg = BusFrameMessage(
|
||||
source=self._agent_name,
|
||||
frame=frame,
|
||||
direction=direction,
|
||||
bridge=self._bridge,
|
||||
)
|
||||
await self._bus.send(msg)
|
||||
|
||||
async def on_bus_message(self, message: BusMessage) -> None:
|
||||
"""Handle an incoming bus message by pushing its frame into the pipeline.
|
||||
|
||||
Args:
|
||||
message: The bus message to handle.
|
||||
"""
|
||||
if not isinstance(message, BusFrameMessage):
|
||||
return
|
||||
|
||||
# Skip own frames
|
||||
if message.source == self._agent_name:
|
||||
return
|
||||
|
||||
# Filter by bridge name
|
||||
if self._bridge and message.bridge != self._bridge:
|
||||
return
|
||||
|
||||
# If target_agent set, only accept from that agent
|
||||
if self._target_agent and message.source != self._target_agent:
|
||||
return
|
||||
|
||||
# If message targeted at someone else, skip
|
||||
if message.target and message.target != self._agent_name:
|
||||
return
|
||||
|
||||
await self.push_frame(message.frame, message.direction)
|
||||
|
||||
|
||||
class _BusEdgeProcessor(FrameProcessor, BusSubscriber):
|
||||
"""Pipeline-edge tee between a local pipeline and the bus.
|
||||
|
||||
Placed by `PipelineTask` at the source and sink of a bridged
|
||||
pipeline. Frames always continue through the local pipeline; in
|
||||
addition, frames travelling in ``direction`` are forwarded to the
|
||||
bus, and frames received from the bus in the opposite direction
|
||||
are injected into the pipeline.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bus: TaskBus,
|
||||
task: "BaseTask",
|
||||
direction: FrameDirection,
|
||||
bridges: tuple[str, ...] = (),
|
||||
exclude_frames: tuple[type[Frame], ...] | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the edge processor.
|
||||
|
||||
Args:
|
||||
bus: The ``TaskBus`` to exchange frames with.
|
||||
task: The owning task; ``task.name`` is the message source
|
||||
and ``task.active`` gates inbound frames.
|
||||
direction: Direction this edge captures and forwards to the
|
||||
bus. Inbound frames from the bus travelling in the
|
||||
opposite direction are injected here.
|
||||
bridges: Bridge names this edge accepts. Empty tuple accepts
|
||||
frames from all bridges.
|
||||
exclude_frames: Extra frame types that should never cross
|
||||
the bus (lifecycle frames are always excluded).
|
||||
**kwargs: Additional arguments passed to ``FrameProcessor``.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._bus = bus
|
||||
self._task = task
|
||||
self._direction = direction
|
||||
self._bridges = bridges
|
||||
self._exclude_frames = exclude_frames or ()
|
||||
|
||||
async def setup(self, setup: FrameProcessorSetup):
|
||||
"""Subscribe to the bus during processor setup."""
|
||||
await super().setup(setup)
|
||||
await self._bus.subscribe(self)
|
||||
|
||||
async def cleanup(self):
|
||||
"""Unsubscribe from the bus on cleanup."""
|
||||
await super().cleanup()
|
||||
await self._bus.unsubscribe(self)
|
||||
|
||||
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||
"""Pass the frame through locally and forward matching ones to the bus."""
|
||||
await super().process_frame(frame, direction)
|
||||
await self.push_frame(frame, direction)
|
||||
|
||||
if direction != self._direction:
|
||||
return
|
||||
if isinstance(frame, _LIFECYCLE_FRAMES):
|
||||
return
|
||||
if self._exclude_frames and isinstance(frame, self._exclude_frames):
|
||||
return
|
||||
|
||||
await self._bus.send(
|
||||
BusFrameMessage(source=self._task.name, frame=frame, direction=direction)
|
||||
)
|
||||
|
||||
async def on_bus_message(self, message: BusMessage) -> None:
|
||||
"""Inject incoming bus frames into the pipeline."""
|
||||
if not isinstance(message, BusFrameMessage):
|
||||
return
|
||||
if message.source == self._task.name:
|
||||
return
|
||||
if message.direction == self._direction:
|
||||
return
|
||||
if not self._task.active:
|
||||
return
|
||||
if message.target and message.target != self._task.name:
|
||||
return
|
||||
if self._bridges and message.bridge not in self._bridges:
|
||||
return
|
||||
await self.push_frame(message.frame, message.direction)
|
||||
183
src/pipecat/bus/bus.py
Normal file
183
src/pipecat/bus/bus.py
Normal file
@@ -0,0 +1,183 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Abstract agent bus for inter-agent pub/sub messaging.
|
||||
|
||||
Provides the abstract `TaskBus` base class. Concrete implementations
|
||||
(e.g. `AsyncQueueBus`) live in separate modules.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from abc import abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from pipecat.bus.messages import BusLocalMessage, BusMessage
|
||||
from pipecat.bus.queue import BusMessageQueue
|
||||
from pipecat.bus.subscriber import BusSubscriber
|
||||
from pipecat.frames.frames import SystemFrame
|
||||
from pipecat.utils.base_object import BaseObject
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusSubscription:
|
||||
"""A single subscriber's state on the bus.
|
||||
|
||||
Parameters:
|
||||
subscriber: The subscriber receiving messages.
|
||||
queue: Priority queue for incoming messages.
|
||||
data_queue: Secondary queue for data messages dispatched by
|
||||
the router task.
|
||||
router_task: Task that reads from the priority queue, handles
|
||||
system messages inline, and routes data messages to the
|
||||
data queue.
|
||||
data_task: Task that processes data messages sequentially from
|
||||
the data queue.
|
||||
"""
|
||||
|
||||
subscriber: BusSubscriber
|
||||
queue: BusMessageQueue = field(default_factory=BusMessageQueue, repr=False)
|
||||
data_queue: asyncio.Queue = field(default_factory=asyncio.Queue, repr=False)
|
||||
router_task: asyncio.Task | None = field(default=None, repr=False)
|
||||
data_task: asyncio.Task | None = field(default=None, repr=False)
|
||||
|
||||
|
||||
class TaskBus(BaseObject):
|
||||
"""Abstract base for inter-agent and runner-agent communication.
|
||||
|
||||
Provides pub/sub messaging where each subscriber receives messages
|
||||
independently through its own priority queue. System messages
|
||||
(e.g. cancel) are delivered before normal data messages.
|
||||
|
||||
Subclasses implement ``publish()`` for the specific transport.
|
||||
``send()`` handles local-only messages automatically. For network
|
||||
buses, override ``start()``/``stop()`` to manage connections and
|
||||
call ``on_message_received()`` when messages arrive from the
|
||||
network.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize the TaskBus.
|
||||
|
||||
Args:
|
||||
**kwargs: Additional arguments passed to `BaseObject`.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._subscriptions: dict[str, BusSubscription] = {}
|
||||
self._running = False
|
||||
|
||||
async def start(self):
|
||||
"""Start dispatch tasks for all registered subscribers."""
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
for sub in self._subscriptions.values():
|
||||
self._start_dispatch_task(sub)
|
||||
# Schedule tasks right away.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
async def stop(self):
|
||||
"""Stop all dispatch tasks."""
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
for sub in self._subscriptions.values():
|
||||
if sub.router_task:
|
||||
await self.cancel_task(sub.router_task)
|
||||
sub.router_task = None
|
||||
if sub.data_task:
|
||||
await self.cancel_task(sub.data_task)
|
||||
sub.data_task = None
|
||||
|
||||
async def subscribe(self, subscriber: BusSubscriber) -> None:
|
||||
"""Register a subscriber to receive messages from the bus.
|
||||
|
||||
Args:
|
||||
subscriber: The `BusSubscriber` to register.
|
||||
"""
|
||||
sub = BusSubscription(subscriber=subscriber)
|
||||
if self._running:
|
||||
self._start_dispatch_task(sub)
|
||||
# Schedule task right away.
|
||||
await asyncio.sleep(0)
|
||||
if subscriber.name in self._subscriptions:
|
||||
raise ValueError(f"Subscriber '{subscriber.name}' is already registered on the bus")
|
||||
self._subscriptions[subscriber.name] = sub
|
||||
|
||||
async def unsubscribe(self, subscriber: BusSubscriber) -> None:
|
||||
"""Remove a subscriber and cancel its dispatch tasks.
|
||||
|
||||
Args:
|
||||
subscriber: The `BusSubscriber` to remove.
|
||||
"""
|
||||
sub = self._subscriptions.pop(subscriber.name, None)
|
||||
if sub:
|
||||
if sub.router_task:
|
||||
await self.cancel_task(sub.router_task)
|
||||
if sub.data_task:
|
||||
await self.cancel_task(sub.data_task)
|
||||
|
||||
async def send(self, message: BusMessage) -> None:
|
||||
"""Send a message through the bus.
|
||||
|
||||
Local-only messages are delivered directly to subscribers.
|
||||
All other messages are passed to ``publish()`` for transport.
|
||||
|
||||
Args:
|
||||
message: The bus message to send.
|
||||
"""
|
||||
if isinstance(message, BusLocalMessage):
|
||||
self.on_message_received(message)
|
||||
return
|
||||
await self.publish(message)
|
||||
|
||||
@abstractmethod
|
||||
async def publish(self, message: BusMessage) -> None:
|
||||
"""Publish a message to the transport.
|
||||
|
||||
Subclasses implement this for the specific transport. Called
|
||||
by ``send()`` after filtering local-only messages.
|
||||
|
||||
Args:
|
||||
message: The bus message to publish.
|
||||
"""
|
||||
pass
|
||||
|
||||
def on_message_received(self, message: BusMessage) -> None:
|
||||
"""Deliver a message to all local subscribers via their priority queues.
|
||||
|
||||
Called by bus implementations when a message arrives (either from
|
||||
a local ``send()`` or from a network transport).
|
||||
"""
|
||||
for sub in self._subscriptions.values():
|
||||
sub.queue.put_nowait(message)
|
||||
|
||||
def _start_dispatch_task(self, sub: BusSubscription) -> None:
|
||||
"""Start the router and data dispatch tasks for a subscriber."""
|
||||
sub.router_task = self.create_task(self._router_task(sub), f"bus_router_{sub.subscriber}")
|
||||
sub.data_task = self.create_task(
|
||||
self._data_dispatch_task(sub), f"bus_data_{sub.subscriber}"
|
||||
)
|
||||
|
||||
async def _router_task(self, sub: BusSubscription):
|
||||
"""Route system messages inline, data messages to the data queue."""
|
||||
try:
|
||||
while True:
|
||||
message = await sub.queue.get()
|
||||
if isinstance(message, SystemFrame):
|
||||
await sub.subscriber.on_bus_message(message)
|
||||
else:
|
||||
sub.data_queue.put_nowait(message)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _data_dispatch_task(self, sub: BusSubscription):
|
||||
"""Process data messages sequentially from the data queue."""
|
||||
try:
|
||||
while True:
|
||||
message = await sub.data_queue.get()
|
||||
await sub.subscriber.on_bus_message(message)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
11
src/pipecat/bus/local/__init__.py
Normal file
11
src/pipecat/bus/local/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Local (in-process) bus implementations."""
|
||||
|
||||
from pipecat.bus.local.async_queue import AsyncQueueBus
|
||||
|
||||
__all__ = ["AsyncQueueBus"]
|
||||
25
src/pipecat/bus/local/async_queue.py
Normal file
25
src/pipecat/bus/local/async_queue.py
Normal file
@@ -0,0 +1,25 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""In-process agent bus backed by asyncio queues."""
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.bus.bus import TaskBus
|
||||
from pipecat.bus.messages import BusMessage
|
||||
|
||||
|
||||
class AsyncQueueBus(TaskBus):
|
||||
"""In-process bus that delivers messages via priority queues."""
|
||||
|
||||
async def publish(self, message: BusMessage) -> None:
|
||||
"""Deliver a message to all local subscriber queues.
|
||||
|
||||
Args:
|
||||
message: The bus message to deliver.
|
||||
"""
|
||||
logger.trace(f"{self}: sending {message}")
|
||||
self.on_message_received(message)
|
||||
407
src/pipecat/bus/messages.py
Normal file
407
src/pipecat/bus/messages.py
Normal file
@@ -0,0 +1,407 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Bus message types for inter-agent communication.
|
||||
|
||||
Defines the message hierarchy used by the `TaskBus` for pub/sub messaging
|
||||
between agents, the session, and the runner.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pipecat.frames.frames import DataFrame, Frame, SystemFrame
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from pipecat.registry.types import TaskRegistryEntry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pipecat.pipeline.base_task import BaseTask
|
||||
from pipecat.pipeline.job_context import JobStatus
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base types and mixins
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BusMessage:
|
||||
"""Mixin carrying source/target metadata for bus messages.
|
||||
|
||||
Not a frame itself. Combined with ``DataFrame`` or ``SystemFrame``
|
||||
to create concrete message types with appropriate priority.
|
||||
"""
|
||||
|
||||
source: str
|
||||
target: str | None = None
|
||||
|
||||
def __str__(self):
|
||||
return f"{type(self).__name__} (source={self.source}, target={self.target})"
|
||||
|
||||
|
||||
class BusLocalMessage:
|
||||
"""Mixin: message stays on the local bus, never forwarded to remote buses."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class BusDataMessage(BusMessage, DataFrame):
|
||||
"""Normal-priority bus message.
|
||||
|
||||
Parameters:
|
||||
source: Name of the agent or component that sent this message.
|
||||
target: Name of the intended recipient agent, or None for broadcast.
|
||||
"""
|
||||
|
||||
source: str
|
||||
target: str | None = None
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class BusSystemMessage(BusMessage, SystemFrame):
|
||||
"""High-priority bus message that preempts normal messages in subscriber queues.
|
||||
|
||||
Parameters:
|
||||
source: Name of the agent or component that sent this message.
|
||||
target: Name of the intended recipient agent, or None for broadcast.
|
||||
"""
|
||||
|
||||
source: str
|
||||
target: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frame transport
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusFrameMessage(BusDataMessage):
|
||||
"""Wraps a Pipecat `Frame` for transport over the bus.
|
||||
|
||||
Parameters:
|
||||
frame: The Pipecat frame to transport.
|
||||
direction: Direction the frame should travel in the recipient's pipeline.
|
||||
bridge: Optional bridge name for routing in multi-bridge setups.
|
||||
"""
|
||||
|
||||
frame: Frame
|
||||
direction: FrameDirection
|
||||
bridge: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusActivateTaskMessage(BusDataMessage):
|
||||
"""Tells a targeted agent to become active and start processing.
|
||||
|
||||
Parameters:
|
||||
args: Optional activation arguments forwarded to ``on_activated``.
|
||||
"""
|
||||
|
||||
args: dict | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusDeactivateTaskMessage(BusDataMessage):
|
||||
"""Tells a targeted agent to become inactive and stop processing."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusEndMessage(BusDataMessage):
|
||||
"""Request a graceful end of the session.
|
||||
|
||||
Sent by an agent to the runner, which responds by sending
|
||||
`BusEndTaskMessage` to each agent.
|
||||
|
||||
Parameters:
|
||||
reason: Optional human-readable reason for ending.
|
||||
"""
|
||||
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusEndTaskMessage(BusDataMessage):
|
||||
"""Tells a targeted agent to end its pipeline gracefully.
|
||||
|
||||
Sent by the runner to individual agents during shutdown.
|
||||
|
||||
Parameters:
|
||||
reason: Optional human-readable reason for ending.
|
||||
"""
|
||||
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusCancelMessage(BusSystemMessage):
|
||||
"""Request a hard cancel of the session.
|
||||
|
||||
Sent by an agent to the runner, which responds by sending
|
||||
`BusCancelTaskMessage` to each agent.
|
||||
|
||||
Parameters:
|
||||
reason: Optional human-readable reason for the cancellation.
|
||||
"""
|
||||
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusCancelTaskMessage(BusSystemMessage):
|
||||
"""Tells a targeted agent to cancel its pipeline task.
|
||||
|
||||
Sent by the runner to individual agents during cancellation.
|
||||
|
||||
Parameters:
|
||||
reason: Optional human-readable reason for the cancellation.
|
||||
"""
|
||||
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent registry and errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusAddTaskMessage(BusSystemMessage, BusLocalMessage):
|
||||
"""Request to add a task to the local runner.
|
||||
|
||||
Local-only: carries an in-memory task reference that cannot be
|
||||
serialized over the network.
|
||||
|
||||
Parameters:
|
||||
task: The task instance to add.
|
||||
"""
|
||||
|
||||
task: BaseTask
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusTaskRegistryMessage(BusSystemMessage):
|
||||
"""Snapshot of tasks managed by a runner.
|
||||
|
||||
Sent by the runner on startup and when new runners connect,
|
||||
so that remote runners can discover each other's tasks.
|
||||
|
||||
Parameters:
|
||||
runner: Name of the runner that owns these tasks.
|
||||
tasks: List of task entries with their state.
|
||||
"""
|
||||
|
||||
runner: str
|
||||
tasks: list[TaskRegistryEntry]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusTaskReadyMessage(BusDataMessage):
|
||||
"""Announces that an agent is ready.
|
||||
|
||||
Sent when any agent (root or child) becomes ready. Carries the
|
||||
agent's parent name so observers can reconstruct the full hierarchy.
|
||||
|
||||
Parameters:
|
||||
runner: Name of the runner managing this agent.
|
||||
parent: Name of the parent agent, or None for root agents.
|
||||
active: Whether the agent started active.
|
||||
bridged: Whether the agent is bridged (receives pipeline frames
|
||||
from the bus).
|
||||
started_at: Unix timestamp when the agent became ready.
|
||||
"""
|
||||
|
||||
runner: str
|
||||
parent: str | None = None
|
||||
active: bool = False
|
||||
bridged: bool = False
|
||||
started_at: float | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusTaskErrorMessage(BusSystemMessage):
|
||||
"""Reports an error from a root agent.
|
||||
|
||||
Sent over the network so remote agents can react. For child agent
|
||||
errors, see ``BusTaskLocalErrorMessage``.
|
||||
|
||||
Parameters:
|
||||
error: Description of the error.
|
||||
"""
|
||||
|
||||
error: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusTaskLocalErrorMessage(BusSystemMessage, BusLocalMessage):
|
||||
"""Reports an error from a child agent to its parent.
|
||||
|
||||
Local-only: never crosses the network. The parent receives it
|
||||
via ``on_task_failed()``.
|
||||
|
||||
Parameters:
|
||||
error: Description of the error.
|
||||
"""
|
||||
|
||||
error: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tasks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusJobRequestMessage(BusDataMessage):
|
||||
"""Requests a task agent to start work.
|
||||
|
||||
Parameters:
|
||||
job_id: Unique identifier for this task.
|
||||
job_name: Optional task name for routing to named handlers.
|
||||
payload: Optional structured data describing the work.
|
||||
"""
|
||||
|
||||
job_id: str
|
||||
job_name: str | None = None
|
||||
payload: dict | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusJobResponseMessage(BusDataMessage):
|
||||
"""Response from a task agent when it completes.
|
||||
|
||||
Parameters:
|
||||
job_id: The task identifier.
|
||||
status: Completion status.
|
||||
response: Optional result data.
|
||||
"""
|
||||
|
||||
job_id: str
|
||||
status: JobStatus
|
||||
response: dict | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusJobResponseUrgentMessage(BusSystemMessage):
|
||||
"""High-priority response from a task agent.
|
||||
|
||||
Same semantics as ``BusJobResponseMessage`` but delivered with
|
||||
system priority, preempting queued data messages.
|
||||
|
||||
Parameters:
|
||||
job_id: The task identifier.
|
||||
status: Completion status.
|
||||
response: Optional result data.
|
||||
"""
|
||||
|
||||
job_id: str
|
||||
status: JobStatus
|
||||
response: dict | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusJobUpdateMessage(BusDataMessage):
|
||||
"""Progress update from a task agent.
|
||||
|
||||
Parameters:
|
||||
job_id: The task identifier.
|
||||
update: Optional progress data.
|
||||
"""
|
||||
|
||||
job_id: str
|
||||
update: dict | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusJobUpdateUrgentMessage(BusSystemMessage):
|
||||
"""High-priority progress update from a task agent.
|
||||
|
||||
Same semantics as ``BusJobUpdateMessage`` but delivered with
|
||||
system priority, preempting queued data messages.
|
||||
|
||||
Parameters:
|
||||
job_id: The task identifier.
|
||||
update: Optional progress data.
|
||||
"""
|
||||
|
||||
job_id: str
|
||||
update: dict | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusJobUpdateRequestMessage(BusDataMessage):
|
||||
"""Request a progress update from a task agent.
|
||||
|
||||
Parameters:
|
||||
job_id: The task identifier.
|
||||
"""
|
||||
|
||||
job_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusJobCancelMessage(BusSystemMessage):
|
||||
"""Cancel a running task.
|
||||
|
||||
Parameters:
|
||||
job_id: The task identifier.
|
||||
reason: Optional human-readable reason for cancellation.
|
||||
"""
|
||||
|
||||
job_id: str
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task streaming
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusJobStreamStartMessage(BusDataMessage):
|
||||
"""Signals the start of a streaming task response.
|
||||
|
||||
Parameters:
|
||||
job_id: The task identifier.
|
||||
data: Optional metadata (e.g. content type).
|
||||
"""
|
||||
|
||||
job_id: str
|
||||
data: dict | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusJobStreamDataMessage(BusDataMessage):
|
||||
"""A chunk of streaming task data.
|
||||
|
||||
Parameters:
|
||||
job_id: The task identifier.
|
||||
data: The chunk payload.
|
||||
"""
|
||||
|
||||
job_id: str
|
||||
data: dict | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusJobStreamEndMessage(BusDataMessage):
|
||||
"""Signals the end of a streaming task response.
|
||||
|
||||
Parameters:
|
||||
job_id: The task identifier.
|
||||
data: Optional final metadata.
|
||||
"""
|
||||
|
||||
job_id: str
|
||||
data: dict | None = None
|
||||
26
src/pipecat/bus/network/__init__.py
Normal file
26
src/pipecat/bus/network/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Network bus implementations for distributed agents.
|
||||
|
||||
Each adapter has its own optional dependency. Imports are lazy so the
|
||||
package can be loaded with only the extras you need; importing a specific
|
||||
bus without its extra raises a clear error from that submodule.
|
||||
"""
|
||||
|
||||
__all__ = ["PgmqBus", "RedisBus"]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "PgmqBus":
|
||||
from pipecat.bus.network.pgmq import PgmqBus
|
||||
|
||||
return PgmqBus
|
||||
if name == "RedisBus":
|
||||
from pipecat.bus.network.redis import RedisBus
|
||||
|
||||
return RedisBus
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
107
src/pipecat/bus/network/redis.py
Normal file
107
src/pipecat/bus/network/redis.py
Normal file
@@ -0,0 +1,107 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Redis pub/sub agent bus for distributed agents."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.bus.bus import TaskBus
|
||||
from pipecat.bus.messages import BusMessage
|
||||
from pipecat.bus.serializers import JSONMessageSerializer
|
||||
from pipecat.bus.serializers.base import MessageSerializer
|
||||
|
||||
try:
|
||||
from redis.asyncio import Redis
|
||||
from redis.asyncio.client import PubSub
|
||||
except ModuleNotFoundError as e:
|
||||
logger.error(f"Exception: {e}")
|
||||
logger.error("In order to use RedisBus, you need to `pip install pipecat-ai-subagents[redis]`.")
|
||||
raise Exception(f"Missing module: {e}")
|
||||
|
||||
|
||||
class RedisBus(TaskBus):
|
||||
"""Distributed agent bus backed by Redis pub/sub.
|
||||
|
||||
Publishes serialized messages to a Redis channel for cross-process
|
||||
communication. ``BusLocalMessage`` messages bypass Redis and are
|
||||
delivered directly to local subscribers.
|
||||
|
||||
Requires the ``redis[hiredis]`` package (``redis.asyncio``).
|
||||
|
||||
Example::
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
redis = Redis.from_url("redis://localhost:6379")
|
||||
bus = RedisBus(redis=redis, channel="my-session")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
redis: Redis,
|
||||
serializer: MessageSerializer | None = None,
|
||||
channel: str = "pipecat:bus",
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the RedisBus.
|
||||
|
||||
Args:
|
||||
redis: A ``redis.asyncio.Redis`` client instance.
|
||||
serializer: The `MessageSerializer` for encoding/decoding messages.
|
||||
Defaults to `JSONMessageSerializer`.
|
||||
channel: The Redis pub/sub channel name. Defaults to ``"pipecat:bus"``.
|
||||
**kwargs: Additional arguments passed to `TaskBus`.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._redis = redis
|
||||
self._serializer = serializer or JSONMessageSerializer()
|
||||
self._channel = channel
|
||||
self._pubsub: PubSub | None = None
|
||||
self._reader_task: asyncio.Task | None = None
|
||||
|
||||
async def start(self):
|
||||
"""Subscribe to Redis channel and start the reader task."""
|
||||
await super().start()
|
||||
self._pubsub = self._redis.pubsub()
|
||||
await self._pubsub.subscribe(self._channel)
|
||||
self._reader_task = self.create_task(self._reader_loop(), f"{self}::redis_reader")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the reader task and unsubscribe from Redis."""
|
||||
await super().stop()
|
||||
if self._reader_task:
|
||||
await self.cancel_task(self._reader_task)
|
||||
self._reader_task = None
|
||||
if self._pubsub:
|
||||
await self._pubsub.unsubscribe(self._channel)
|
||||
await self._pubsub.close()
|
||||
self._pubsub = None
|
||||
|
||||
async def publish(self, message: BusMessage) -> None:
|
||||
"""Publish a message to the Redis channel.
|
||||
|
||||
Args:
|
||||
message: The bus message to publish.
|
||||
"""
|
||||
logger.trace(f"{self}: publishing {message} to {self._channel}")
|
||||
data = self._serializer.serialize(message)
|
||||
await self._redis.publish(self._channel, data)
|
||||
|
||||
async def _reader_loop(self) -> None:
|
||||
"""Read messages from Redis pub/sub and deliver to subscribers."""
|
||||
async for raw_message in self._pubsub.listen():
|
||||
if raw_message["type"] != "message":
|
||||
continue
|
||||
try:
|
||||
message = self._serializer.deserialize(raw_message["data"])
|
||||
if message:
|
||||
self.on_message_received(message)
|
||||
except Exception:
|
||||
logger.exception(f"{self}: failed to deserialize message")
|
||||
61
src/pipecat/bus/queue.py
Normal file
61
src/pipecat/bus/queue.py
Normal file
@@ -0,0 +1,61 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Priority queue for bus messages."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from pipecat.frames.frames import SystemFrame
|
||||
|
||||
HIGH_PRIORITY = 1
|
||||
LOW_PRIORITY = 2
|
||||
|
||||
|
||||
class BusMessageQueue(asyncio.PriorityQueue):
|
||||
"""Priority queue that delivers system messages before normal messages.
|
||||
|
||||
Messages that extend ``SystemFrame`` (e.g. cancel messages) get high
|
||||
priority. All other messages are delivered in FIFO order at normal
|
||||
priority.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the BusMessageQueue."""
|
||||
super().__init__()
|
||||
self._high_counter = 0
|
||||
self._low_counter = 0
|
||||
|
||||
def put_nowait(self, item) -> None:
|
||||
"""Add a message to the queue with automatic priority assignment.
|
||||
|
||||
Args:
|
||||
item: The bus message to enqueue.
|
||||
"""
|
||||
if isinstance(item, SystemFrame):
|
||||
self._high_counter += 1
|
||||
super().put_nowait((HIGH_PRIORITY, self._high_counter, item))
|
||||
else:
|
||||
self._low_counter += 1
|
||||
super().put_nowait((LOW_PRIORITY, self._low_counter, item))
|
||||
|
||||
async def put(self, item) -> None:
|
||||
"""Add a message to the queue with automatic priority assignment.
|
||||
|
||||
Args:
|
||||
item: The bus message to enqueue.
|
||||
"""
|
||||
if isinstance(item, SystemFrame):
|
||||
self._high_counter += 1
|
||||
await super().put((HIGH_PRIORITY, self._high_counter, item))
|
||||
else:
|
||||
self._low_counter += 1
|
||||
await super().put((LOW_PRIORITY, self._low_counter, item))
|
||||
|
||||
async def get(self) -> Any:
|
||||
"""Get the next message, with system messages prioritized."""
|
||||
_, _, message = await super().get()
|
||||
return message
|
||||
19
src/pipecat/bus/serializers/__init__.py
Normal file
19
src/pipecat/bus/serializers/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Bus message serialization for network transport.
|
||||
|
||||
Provides the abstract `MessageSerializer` interface and a default
|
||||
`JSONMessageSerializer` implementation.
|
||||
"""
|
||||
|
||||
from pipecat.bus.serializers.base import MessageSerializer
|
||||
from pipecat.bus.serializers.json import JSONMessageSerializer
|
||||
|
||||
__all__ = [
|
||||
"JSONMessageSerializer",
|
||||
"MessageSerializer",
|
||||
]
|
||||
43
src/pipecat/bus/serializers/base.py
Normal file
43
src/pipecat/bus/serializers/base.py
Normal file
@@ -0,0 +1,43 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Abstract base classes for bus message serialization."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from pipecat.bus.messages import BusMessage
|
||||
|
||||
|
||||
class MessageSerializer(ABC):
|
||||
"""Serialize and deserialize `BusMessage` instances for network transport.
|
||||
|
||||
Network bus implementations use a `MessageSerializer` to convert messages
|
||||
to bytes for transmission and reconstruct them on the receiving end.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def serialize(self, message: BusMessage) -> bytes:
|
||||
"""Convert a bus message to bytes.
|
||||
|
||||
Args:
|
||||
message: The bus message to serialize.
|
||||
|
||||
Returns:
|
||||
The serialized bytes.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def deserialize(self, data: bytes) -> BusMessage | None:
|
||||
"""Reconstruct a bus message from bytes.
|
||||
|
||||
Args:
|
||||
data: The serialized bytes produced by `serialize()`.
|
||||
|
||||
Returns:
|
||||
The reconstructed `BusMessage`.
|
||||
"""
|
||||
pass
|
||||
212
src/pipecat/bus/serializers/json.py
Normal file
212
src/pipecat/bus/serializers/json.py
Normal file
@@ -0,0 +1,212 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""JSON-based bus message serializer with pluggable type adapters."""
|
||||
|
||||
import base64
|
||||
import dataclasses
|
||||
import importlib
|
||||
import json
|
||||
from enum import Enum
|
||||
from functools import cache
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from pipecat.bus.adapters.base import TypeAdapter
|
||||
from pipecat.bus.messages import BusMessage
|
||||
from pipecat.bus.serializers.base import MessageSerializer
|
||||
|
||||
# JSON-native types that don't need an adapter.
|
||||
_JSON_NATIVE = (str, int, float, bool, type(None))
|
||||
|
||||
|
||||
class JSONMessageSerializer(MessageSerializer):
|
||||
"""Serialize bus messages as JSON with pluggable type adapters.
|
||||
|
||||
Handles JSON-native types, enums, bytes, dataclasses, and any type
|
||||
with a registered ``TypeAdapter`` (e.g. ``LLMContext``, ``ToolsSchema``).
|
||||
Adapters for common Pipecat types are registered by default.
|
||||
Additional type adapters can be registered via ``register_adapter()``.
|
||||
|
||||
Example::
|
||||
|
||||
serializer = JSONMessageSerializer()
|
||||
|
||||
data = serializer.serialize(message)
|
||||
restored = serializer.deserialize(data)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Create a serializer with default adapters for `LLMContext` and `ToolsSchema`."""
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.bus.adapters import LLMContextAdapter, ToolsSchemaAdapter
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
|
||||
self._adapters: dict[type, TypeAdapter] = {
|
||||
LLMContext: LLMContextAdapter(),
|
||||
ToolsSchema: ToolsSchemaAdapter(),
|
||||
}
|
||||
|
||||
def register_adapter(self, type_: type, adapter: TypeAdapter) -> None:
|
||||
"""Register a type adapter.
|
||||
|
||||
Args:
|
||||
type_: The type to handle.
|
||||
adapter: The adapter that serializes/deserializes instances of this type.
|
||||
"""
|
||||
self._adapters[type_] = adapter
|
||||
|
||||
def serialize(self, message: BusMessage) -> bytes:
|
||||
"""Convert a bus message to JSON bytes.
|
||||
|
||||
Args:
|
||||
message: The bus message to serialize.
|
||||
|
||||
Returns:
|
||||
UTF-8 encoded JSON bytes.
|
||||
"""
|
||||
data = self._serialize_value(message)
|
||||
return json.dumps(data, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
def deserialize(self, data: bytes) -> BusMessage | None:
|
||||
"""Reconstruct a bus message from JSON bytes.
|
||||
|
||||
Args:
|
||||
data: The JSON bytes produced by `serialize()`.
|
||||
|
||||
Returns:
|
||||
The reconstructed `BusMessage`, or None if deserialization fails.
|
||||
"""
|
||||
payload = json.loads(data)
|
||||
return self._deserialize_value(payload)
|
||||
|
||||
def _serialize_value(self, value: Any) -> Any:
|
||||
"""Recursively serialize a value to a JSON-compatible representation."""
|
||||
if isinstance(value, _JSON_NATIVE):
|
||||
return value
|
||||
if isinstance(value, Enum):
|
||||
return {
|
||||
"__type__": f"{type(value).__module__}.{type(value).__name__}",
|
||||
"__data__": value.name,
|
||||
}
|
||||
if isinstance(value, dict):
|
||||
return {k: self._serialize_value(v) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [self._serialize_value(v) for v in value]
|
||||
if isinstance(value, bytes):
|
||||
return {"__type__": "bytes", "__data__": base64.b64encode(value).decode("ascii")}
|
||||
if isinstance(value, BaseModel):
|
||||
return {
|
||||
"__type__": f"{type(value).__module__}.{type(value).__name__}",
|
||||
"__data__": {
|
||||
k: self._serialize_value(v) for k, v in value.__dict__.items() if v is not None
|
||||
},
|
||||
}
|
||||
if callable(value):
|
||||
return None
|
||||
adapter = self._find_adapter(type(value))
|
||||
if adapter is not None:
|
||||
return {
|
||||
"__type__": f"{type(value).__module__}.{type(value).__name__}",
|
||||
"__data__": adapter.serialize(value, self._serialize_value),
|
||||
}
|
||||
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
||||
fields = {}
|
||||
for f in dataclasses.fields(value):
|
||||
v = getattr(value, f.name)
|
||||
if v is None:
|
||||
continue
|
||||
serialized = self._serialize_value(v)
|
||||
if serialized is not None:
|
||||
fields[f.name] = serialized
|
||||
return {
|
||||
"__type__": f"{type(value).__module__}.{type(value).__name__}",
|
||||
"__data__": fields,
|
||||
}
|
||||
logger.warning(
|
||||
f"JSONMessageSerializer: skipping field with unserializable type {type(value).__name__}"
|
||||
)
|
||||
return None
|
||||
|
||||
def _deserialize_value(self, value: Any) -> Any:
|
||||
"""Recursively deserialize a value from its JSON representation."""
|
||||
if isinstance(value, _JSON_NATIVE):
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
return [self._deserialize_value(v) for v in value]
|
||||
if isinstance(value, dict):
|
||||
if "__type__" in value and "__data__" in value:
|
||||
return self._deserialize_typed(value["__type__"], value["__data__"])
|
||||
return {k: self._deserialize_value(v) for k, v in value.items()}
|
||||
return value
|
||||
|
||||
def _deserialize_typed(self, type_name: str, data: Any) -> Any:
|
||||
"""Deserialize a tagged value using its fully qualified type name."""
|
||||
if type_name == "bytes":
|
||||
return base64.b64decode(data)
|
||||
cls = _resolve_type(type_name)
|
||||
if cls is None:
|
||||
logger.warning(f"JSONMessageSerializer: could not resolve type {type_name}")
|
||||
return None
|
||||
if issubclass(cls, Enum):
|
||||
return cls[data]
|
||||
adapter = self._find_adapter(cls)
|
||||
if adapter is not None:
|
||||
return adapter.deserialize(data, self._deserialize_value, target_type=cls)
|
||||
if isinstance(data, dict) and issubclass(cls, BaseModel):
|
||||
return cls.model_validate({k: self._deserialize_value(v) for k, v in data.items()})
|
||||
if dataclasses.is_dataclass(cls) and isinstance(data, dict):
|
||||
init_fields = {f.name: f for f in dataclasses.fields(cls) if f.init}
|
||||
init_kwargs = {}
|
||||
post_init = {}
|
||||
for key, value in data.items():
|
||||
deserialized = self._deserialize_value(value)
|
||||
if key in init_fields:
|
||||
init_kwargs[key] = deserialized
|
||||
else:
|
||||
post_init[key] = deserialized
|
||||
for name, f in init_fields.items():
|
||||
if name not in init_kwargs:
|
||||
if (
|
||||
f.default is dataclasses.MISSING
|
||||
and f.default_factory is dataclasses.MISSING
|
||||
):
|
||||
init_kwargs[name] = None
|
||||
obj = cls(**init_kwargs)
|
||||
for key, value in post_init.items():
|
||||
setattr(obj, key, value)
|
||||
return obj
|
||||
logger.warning(f"JSONMessageSerializer: no adapter registered for type {type_name}")
|
||||
return None
|
||||
|
||||
def _find_adapter(self, type_: type) -> TypeAdapter | None:
|
||||
"""Find an adapter for a type, checking parent classes via MRO."""
|
||||
for cls in type_.__mro__:
|
||||
if cls in self._adapters:
|
||||
return self._adapters[cls]
|
||||
return None
|
||||
|
||||
|
||||
@cache
|
||||
def _resolve_type(qualified_name: str) -> type | None:
|
||||
"""Resolve a fully qualified type name to its class.
|
||||
|
||||
Args:
|
||||
qualified_name: Dotted path like ``"pipecat.frames.frames.TextFrame"``.
|
||||
|
||||
Returns:
|
||||
The resolved class, or None if it cannot be found.
|
||||
"""
|
||||
module_path, _, class_name = qualified_name.rpartition(".")
|
||||
if not module_path:
|
||||
return None
|
||||
try:
|
||||
module = importlib.import_module(module_path)
|
||||
return getattr(module, class_name, None)
|
||||
except ImportError:
|
||||
return None
|
||||
31
src/pipecat/bus/subscriber.py
Normal file
31
src/pipecat/bus/subscriber.py
Normal file
@@ -0,0 +1,31 @@
|
||||
#
|
||||
# Copyright (c) 2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Bus subscriber mixin for receiving messages from an TaskBus."""
|
||||
|
||||
from pipecat.bus.messages import BusMessage
|
||||
|
||||
|
||||
class BusSubscriber:
|
||||
"""Mixin for objects that receive messages from an `TaskBus`.
|
||||
|
||||
Implementors override `on_bus_message()` to handle incoming messages.
|
||||
Concrete subscribers must provide a ``name`` property (typically
|
||||
inherited from ``BaseObject``).
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Unique name identifying this subscriber on the bus."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def on_bus_message(self, message: BusMessage) -> None:
|
||||
"""Handle an incoming bus message.
|
||||
|
||||
Args:
|
||||
message: The bus message to handle.
|
||||
"""
|
||||
...
|
||||
Reference in New Issue
Block a user