Separate bus messages from pipeline frames

BusMessage was a mixin tacked onto DataFrame / SystemFrame so the bus
could reuse the frame priority machinery. That made every bus message
also a Frame, which is misleading — bus messages travel on the bus, not
through pipelines. If a worker actually needs to ship a frame, it wraps
it in BusFrameMessage.

BusMessage is now a plain dataclass base carrying source/target.
BusDataMessage and BusSystemMessage are empty subclasses that exist
only as priority markers. The bus router and the priority queue check
``isinstance(item, BusSystemMessage)`` directly instead of
``isinstance(item, SystemFrame)``.

The serializer test that round-tripped DataFrame.name (a non-init
field) is rewritten against a local _MessageWithNonInit(BusDataMessage)
subclass so the serializer's init=False path stays covered.
This commit is contained in:
Aleix Conchillo Flaqué
2026-05-21 09:52:52 -07:00
parent b03247f360
commit d07ba562eb
4 changed files with 37 additions and 30 deletions

View File

@@ -15,10 +15,9 @@ from abc import abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import cast from typing import cast
from pipecat.bus.messages import BusLocalMessage, BusMessage from pipecat.bus.messages import BusLocalMessage, BusMessage, BusSystemMessage
from pipecat.bus.queue import BusMessageQueue from pipecat.bus.queue import BusMessageQueue
from pipecat.bus.subscriber import BusSubscriber from pipecat.bus.subscriber import BusSubscriber
from pipecat.frames.frames import SystemFrame
from pipecat.utils.base_object import BaseObject from pipecat.utils.base_object import BaseObject
@@ -167,7 +166,7 @@ class WorkerBus(BaseObject):
try: try:
while True: while True:
message = await sub.queue.get() message = await sub.queue.get()
if isinstance(message, SystemFrame): if isinstance(message, BusSystemMessage):
await sub.subscriber.on_bus_message(cast(BusMessage, message)) await sub.subscriber.on_bus_message(cast(BusMessage, message))
else: else:
sub.data_queue.put_nowait(message) sub.data_queue.put_nowait(message)

View File

@@ -15,7 +15,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pipecat.frames.frames import DataFrame, Frame, SystemFrame from pipecat.frames.frames import Frame
from pipecat.processors.frame_processor import FrameDirection from pipecat.processors.frame_processor import FrameDirection
from pipecat.registry.types import WorkerRegistryEntry from pipecat.registry.types import WorkerRegistryEntry
@@ -28,11 +28,19 @@ if TYPE_CHECKING:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@dataclass(kw_only=True)
class BusMessage: class BusMessage:
"""Mixin carrying source/target metadata for bus messages. """Base class for messages carried by the `WorkerBus`.
Not a frame itself. Combined with `DataFrame` or `SystemFrame` Bus messages are independent of pipeline `Frame`s — if a worker needs
to create concrete message types with appropriate priority. to ship a frame between pipelines it wraps it in a `BusFrameMessage`.
Subclasses choose delivery priority by extending :class:`BusDataMessage`
(normal priority, FIFO) or :class:`BusSystemMessage` (high priority,
delivered ahead of queued data messages).
Parameters:
source: Name of the worker or component that sent this message.
target: Name of the intended recipient worker, or None for broadcast.
""" """
source: str source: str
@@ -49,29 +57,24 @@ class BusLocalMessage:
@dataclass(kw_only=True) @dataclass(kw_only=True)
class BusDataMessage(BusMessage, DataFrame): class BusDataMessage(BusMessage):
"""Normal-priority bus message. """Normal-priority bus message.
Parameters: Delivered in FIFO order on the subscriber's data queue.
source: Name of the worker or component that sent this message.
target: Name of the intended recipient worker, or None for broadcast.
""" """
source: str pass
target: str | None = None
@dataclass(kw_only=True) @dataclass(kw_only=True)
class BusSystemMessage(BusMessage, SystemFrame): class BusSystemMessage(BusMessage):
"""High-priority bus message that preempts normal messages in subscriber queues. """High-priority bus message.
Parameters: Delivered ahead of any queued :class:`BusDataMessage` on the
source: Name of the worker or component that sent this message. subscriber's priority queue.
target: Name of the intended recipient worker, or None for broadcast.
""" """
source: str pass
target: str | None = None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -9,7 +9,7 @@
import asyncio import asyncio
from typing import Any from typing import Any
from pipecat.frames.frames import SystemFrame from pipecat.bus.messages import BusSystemMessage
HIGH_PRIORITY = 1 HIGH_PRIORITY = 1
LOW_PRIORITY = 2 LOW_PRIORITY = 2
@@ -18,9 +18,9 @@ LOW_PRIORITY = 2
class BusMessageQueue(asyncio.PriorityQueue): class BusMessageQueue(asyncio.PriorityQueue):
"""Priority queue that delivers system messages before normal messages. """Priority queue that delivers system messages before normal messages.
Messages that extend ``SystemFrame`` (e.g. cancel messages) get high Messages that extend :class:`BusSystemMessage` (e.g. cancel messages)
priority. All other messages are delivered in FIFO order at normal get high priority. All other messages are delivered in FIFO order at
priority. normal priority.
""" """
def __init__(self): def __init__(self):
@@ -35,7 +35,7 @@ class BusMessageQueue(asyncio.PriorityQueue):
Args: Args:
item: The bus message to enqueue. item: The bus message to enqueue.
""" """
if isinstance(item, SystemFrame): if isinstance(item, BusSystemMessage):
self._high_counter += 1 self._high_counter += 1
super().put_nowait((HIGH_PRIORITY, self._high_counter, item)) super().put_nowait((HIGH_PRIORITY, self._high_counter, item))
else: else:
@@ -48,7 +48,7 @@ class BusMessageQueue(asyncio.PriorityQueue):
Args: Args:
item: The bus message to enqueue. item: The bus message to enqueue.
""" """
if isinstance(item, SystemFrame): if isinstance(item, BusSystemMessage):
self._high_counter += 1 self._high_counter += 1
await super().put((HIGH_PRIORITY, self._high_counter, item)) await super().put((HIGH_PRIORITY, self._high_counter, item))
else: else:

View File

@@ -5,6 +5,7 @@
# #
import unittest import unittest
from dataclasses import dataclass, field
from pydantic import BaseModel from pydantic import BaseModel
@@ -35,6 +36,11 @@ class _UserInfo(BaseModel):
address: _Address | None = None address: _Address | None = None
@dataclass(kw_only=True)
class _MessageWithNonInit(BusDataMessage):
tag: str = field(init=False, default="default")
class TestJSONMessageSerializer(unittest.TestCase): class TestJSONMessageSerializer(unittest.TestCase):
def setUp(self): def setUp(self):
self.serializer = JSONMessageSerializer() self.serializer = JSONMessageSerializer()
@@ -232,14 +238,13 @@ class TestJSONMessageSerializer(unittest.TestCase):
def test_non_init_fields_preserved(self): def test_non_init_fields_preserved(self):
"""Non-init dataclass fields survive round-trip via setattr.""" """Non-init dataclass fields survive round-trip via setattr."""
msg = BusDataMessage(source="task_a", target="task_b") msg = _MessageWithNonInit(source="worker_a", target="worker_b")
# name is a non-init field on DataFrame msg.tag = "custom_tag"
msg.name = "custom_name"
data = self.serializer.serialize(msg) data = self.serializer.serialize(msg)
restored = self.serializer.deserialize(data) restored = self.serializer.deserialize(data)
self.assertEqual(restored.name, "custom_name") self.assertEqual(restored.tag, "custom_tag")
if __name__ == "__main__": if __name__ == "__main__":