diff --git a/agents-core/vision_agents/core/agents/agents.py b/agents-core/vision_agents/core/agents/agents.py index e44e3e0c2..b26e2284d 100644 --- a/agents-core/vision_agents/core/agents/agents.py +++ b/agents-core/vision_agents/core/agents/agents.py @@ -7,35 +7,30 @@ from contextlib import asynccontextmanager, contextmanager from pathlib import Path from typing import ( - TYPE_CHECKING, Any, AsyncIterator, - Dict, Iterator, - List, Optional, TypeGuard, ) from uuid import uuid4 -import getstream.models from aiortc import VideoStreamTrack -from getstream.video.rtc import Call -from getstream.video.rtc.pb.stream.video.sfu.models.models_pb2 import TrackType +from getstream.video.rtc import AudioStreamTrack, PcmData from opentelemetry import context as otel_context from opentelemetry import trace from opentelemetry.context import Token from opentelemetry.trace import Tracer, set_span_in_context from opentelemetry.trace.propagation import Context, Span -from ..edge import sfu_events +from ..edge import Call, EdgeTransport from ..edge.events import ( AudioReceivedEvent, CallEndedEvent, TrackAddedEvent, TrackRemovedEvent, ) -from ..edge.types import OutputAudioTrack, Participant, PcmData, User +from ..edge.types import Connection, Participant, TrackType, User from ..events.manager import EventManager from ..instructions import Instructions from ..llm import events as llm_events @@ -78,12 +73,6 @@ from .conversation import Conversation from .transcript_buffer import TranscriptBuffer -if TYPE_CHECKING: - from vision_agents.plugins.getstream.stream_edge_transport import ( - StreamConnection, - StreamEdge, - ) - logger = logging.getLogger(__name__) tracer: Tracer = trace.get_tracer("agents") @@ -114,7 +103,7 @@ class Agent: Note: Don't reuse the agent object. Create a new agent object each time. Dev guidelines - - Small methods so its easy to subclass/change behaviour + - Small methods so it's easy to subclass/change behaviour """ options: AgentOptions @@ -122,7 +111,7 @@ class Agent: def __init__( self, # edge network for video & audio - edge: "StreamEdge", + edge: EdgeTransport, # llm, optionally with sts/realtime capabilities llm: LLM | AudioLLM | VideoLLM, # the agent's user info @@ -137,9 +126,9 @@ def __init__( # - roboflow/ yolo typically run continuously # - often combined with API calls to fetch stats etc # - state from each processor is passed to the LLM - processors: Optional[List[Processor]] = None, + processors: Optional[list[Processor]] = None, # MCP servers for external tool and resource access - mcp_servers: Optional[List[MCPBaseServer]] = None, + mcp_servers: Optional[list[MCPBaseServer]] = None, options: Optional[AgentOptions] = None, tracer: Tracer = trace.get_tracer("agents"), profiler: Optional[Profiler] = None, @@ -178,9 +167,7 @@ def __init__( self.logger = _AgentLoggerAdapter(logger, {"agent_id": self.agent_user.id}) self.events = EventManager() - self.events.register_events_from_module(getstream.models, "call.") self.events.register_events_from_module(events) - self.events.register_events_from_module(sfu_events) self.events.register_events_from_module(llm_events) self.llm = llm @@ -205,13 +192,13 @@ def __init__( self.conversation: Optional[Conversation] = None # Track pending transcripts for turn-based response triggering - self._pending_user_transcripts: Dict[str, TranscriptBuffer] = defaultdict( + self._pending_user_transcripts: dict[str, TranscriptBuffer] = defaultdict( TranscriptBuffer ) # Merge plugin events BEFORE subscribing to any events for plugin in [stt, tts, turn_detection, llm, edge, profiler]: - if plugin and hasattr(plugin, "events"): + if plugin is not None: self.logger.debug(f"Register events from plugin {plugin}") self.events.merge(plugin.events) @@ -224,16 +211,16 @@ def __init__( self.events.subscribe(self._on_agent_say) # Track metadata: track_id -> TrackInfo - self._active_video_tracks: Dict[str, TrackInfo] = {} - self._video_forwarders: List[VideoForwarder] = [] - self._connection: Optional[StreamConnection] = None + self._active_video_tracks: dict[str, TrackInfo] = {} + self._video_forwarders: list[VideoForwarder] = [] + self._connection: Optional[Connection] = None # Optional local video track override for debugging. # This track will play instead of any incoming video track. self._video_track_override_path: Optional[str | Path] = None # the outgoing audio track - self._audio_track: Optional[OutputAudioTrack] = None + self._audio_track: Optional[AudioStreamTrack] = None # the outgoing video track self._video_track: Optional[VideoStreamTrack] = None @@ -323,15 +310,23 @@ async def _on_tts_audio_write_to_output(event: TTSAudioEvent): # listen to video tracks added/removed @self.edge.events.subscribe async def on_video_track_added(event: TrackAddedEvent | TrackRemovedEvent): - if event.track_id is None or event.track_type is None or event.user is None: + if ( + event.track_id is None + or event.track_type is None + or event.participant is None + ): return if isinstance(event, TrackRemovedEvent): asyncio.create_task( - self._on_track_removed(event.track_id, event.track_type, event.user) + self._on_track_removed( + event.track_id, event.track_type, event.participant + ) ) else: asyncio.create_task( - self._on_track_added(event.track_id, event.track_type, event.user) + self._on_track_added( + event.track_id, event.track_type, event.participant + ) ) # audio event for the user talking to the AI @@ -343,7 +338,7 @@ async def on_audio_received(event: AudioReceivedEvent): await self._incoming_audio_queue.put(event.pcm_data) @self.edge.events.subscribe - async def on_call_ended(event: CallEndedEvent): + async def on_call_ended(_: CallEndedEvent): if self._call_ended_event is not None: self._call_ended_event.set() @@ -711,8 +706,14 @@ def _start_tracing(self, call: Call) -> None: self._context_token = otel_context.attach(self._root_ctx) async def _apply(self, function_name: str, *args, **kwargs): - subclasses = [self.llm, self.stt, self.tts, self.turn_detection, self.edge] - subclasses.extend(self.processors) + subclasses = [ + self.llm, + self.stt, + self.tts, + self.turn_detection, + self.edge, + *self.processors, + ] for subclass in subclasses: if ( subclass is not None @@ -858,9 +859,9 @@ async def create_user(self) -> None: async def create_call(self, call_type: str, call_id: str) -> Call: """Shortcut for creating a call/room etc.""" - call = self.edge.client.video.call(call_type, call_id) - await call.get_or_create(data={"created_by_id": self.agent_user.id}) - + call = await self.edge.create_call( + call_id=call_id, agent_user_id=self.agent_user.id, call_type=call_type + ) return call def _on_rtc_reconnect(self): @@ -886,13 +887,18 @@ async def _on_agent_say(self, event: events.AgentSayEvent): start_time = time.time() if self.tts is not None: - # Call TTS with user metadata - user_metadata = {"user_id": event.user_id} - if event.metadata: - user_metadata.update(event.metadata) + # Create participant from event + participant = ( + Participant( + original=event.metadata or {}, + user_id=event.user_id, + ) + if event.user_id + else None + ) sanitized_text = self._sanitize_text(event.text) - await self.tts.send(sanitized_text, user_metadata) + await self.tts.send(sanitized_text, participant) # Calculate duration duration_ms = (time.time() - start_time) * 1000 @@ -928,7 +934,7 @@ async def say( self, text: str, user_id: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, + metadata: Optional[dict[str, Any]] = None, ): """ Make the agent say something using TTS. @@ -1058,21 +1064,17 @@ async def _track_to_video_processors(self, track: TrackInfo): ) async def _on_track_removed( - self, track_id: str, track_type: int, participant: Participant + self, track_id: str, track_type: TrackType, participant: Participant ): # We only process video tracks (camera video or screenshare) if track_type not in ( - TrackType.TRACK_TYPE_VIDEO, - TrackType.TRACK_TYPE_SCREEN_SHARE, + TrackType.VIDEO, + TrackType.SCREEN_SHARE, ): return - track_type_name = ( - "SCREEN_SHARE" - if track_type == TrackType.TRACK_TYPE_SCREEN_SHARE - else "VIDEO" - ) + self.logger.info( - f"📺 Track removed: {track_type_name} from {participant.user_id}" + f"📺 Track removed: {track_type.name} from {participant.user_id}" ) track = self._active_video_tracks.pop(track_id, None) @@ -1081,7 +1083,7 @@ async def _on_track_removed( track.track.stop() await self._on_track_change(track_id) - async def _on_track_change(self, track_id: str): + async def _on_track_change(self, _: str): # shared logic between track remove and added # Select a track. Prioritize screenshare over regular # This is the track without processing @@ -1120,24 +1122,20 @@ async def _on_track_change(self, track_id: str): ) async def _on_track_added( - self, track_id: str, track_type: int, participant: Participant + self, track_id: str, track_type: TrackType, participant: Participant ): # We only process video tracks (camera video or screenshare) if track_type not in ( - TrackType.TRACK_TYPE_VIDEO, - TrackType.TRACK_TYPE_SCREEN_SHARE, + TrackType.VIDEO, + TrackType.SCREEN_SHARE, ): return - track_type_name = ( - "SCREEN_SHARE" - if track_type == TrackType.TRACK_TYPE_SCREEN_SHARE - else "VIDEO" - ) self.logger.info( - f"📺 Track added: {track_type_name} from {participant.user_id}" + f"📺 Track added: {track_type.name} from {participant.user_id}" ) + track: VideoStreamTrack | None if self._video_track_override_path is not None: # If local video track is set, we override all other video tracks with it. # We override tracks instead of simply playing one in order to keep the same lifecycle within the call. @@ -1163,7 +1161,7 @@ async def _on_track_added( processor="", track=track, participant=participant, - priority=1 if track_type == TrackType.TRACK_TYPE_SCREEN_SHARE else 0, + priority=1 if track_type == TrackType.SCREEN_SHARE else 0, forwarder=forwarder, ) @@ -1401,11 +1399,7 @@ def _prepare_rtc(self): # Variables are now initialized in __init__ if self.publish_audio: - framerate = 48000 - stereo = True - self._audio_track = self.edge.create_audio_track( - framerate=framerate, stereo=stereo - ) + self._audio_track = self.edge.create_audio_track() @self.events.subscribe async def forward_audio(event: RealtimeAudioOutputEvent): @@ -1426,7 +1420,7 @@ async def forward_audio(event: RealtimeAudioOutputEvent): ) self._active_video_tracks[self._video_track.id] = TrackInfo( id=self._video_track.id, - type=TrackType.TRACK_TYPE_VIDEO, + type=TrackType.VIDEO.value, processor=video_publisher.name, track=self._video_track, participant=None, diff --git a/agents-core/vision_agents/core/edge/__init__.py b/agents-core/vision_agents/core/edge/__init__.py index 02ddf4805..a8d4eadb8 100644 --- a/agents-core/vision_agents/core/edge/__init__.py +++ b/agents-core/vision_agents/core/edge/__init__.py @@ -1,9 +1,9 @@ -"""Stream Edge Transport Package. +"""Edge Transport Package. -This package provides edge transport abstraction for Stream Agents. +This package provides edge transport abstraction for vision agents. """ +from vision_agents.core.edge.call import Call from vision_agents.core.edge.edge_transport import EdgeTransport -from vision_agents.core.edge import sfu_events -__all__ = ["EdgeTransport", "sfu_events"] +__all__ = ["Call", "EdgeTransport"] diff --git a/agents-core/vision_agents/core/edge/call.py b/agents-core/vision_agents/core/edge/call.py new file mode 100644 index 000000000..8ff394302 --- /dev/null +++ b/agents-core/vision_agents/core/edge/call.py @@ -0,0 +1,14 @@ +from typing import Protocol + + +class Call(Protocol): + """Protocol for call/room abstraction. + + Any EdgeTransport implementation must return objects conforming to this protocol + from their create_call or join methods. + """ + + @property + def id(self) -> str: + """The unique identifier of the call.""" + ... diff --git a/agents-core/vision_agents/core/edge/edge_transport.py b/agents-core/vision_agents/core/edge/edge_transport.py index 357e91c4f..a4eea1a3f 100644 --- a/agents-core/vision_agents/core/edge/edge_transport.py +++ b/agents-core/vision_agents/core/edge/edge_transport.py @@ -1,63 +1,138 @@ -""" -Abstraction for stream vs other services here -""" - import abc - -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, Generic, Optional, TypeVar import aiortc -from pyee.asyncio import AsyncIOEventEmitter - -from vision_agents.core.edge.types import User, OutputAudioTrack +from getstream.video.rtc import AudioStreamTrack +from vision_agents.core.events.manager import EventManager + +from .call import Call +from .events import ( + AudioReceivedEvent, + CallEndedEvent, + TrackAddedEvent, + TrackRemovedEvent, +) +from .types import Connection, User if TYPE_CHECKING: - pass + from vision_agents.core import Agent +T_Call = TypeVar("T_Call", bound=Call) -class EdgeTransport(AsyncIOEventEmitter, abc.ABC): - """ - TODO: what's not done yet - - call type - - participant type - - audio track type - - pcm data type +class EdgeTransport(abc.ABC, Generic[T_Call]): + """Abstract base class for edge transports. + Required Events (implementations must emit these): + - AudioReceivedEvent: When audio is received from a participant + - TrackAddedEvent: When a media track is added to the call + - TrackRemovedEvent: When a media track is removed from the call + - CallEndedEvent: When the call ends """ + events: EventManager + + def __init__(self): + super().__init__() + self.events = EventManager() + # Register required events that all EdgeTransport implementations must emit + self.events.register( + AudioReceivedEvent, + TrackAddedEvent, + TrackRemovedEvent, + CallEndedEvent, + ) + @abc.abstractmethod async def create_user(self, user: User): + """Create or update a user in the transport system. + + Args: + user: User object containing id, name, and optional image. + """ pass @abc.abstractmethod - def create_audio_track(self) -> OutputAudioTrack: + async def create_call(self, call_id: str, **kwargs) -> T_Call: + """Create a new call or retrieve an existing one. + + Args: + call_id: Unique identifier for the call. + **kwargs: Additional transport-specific call configuration. + + Returns: + Call: A Call object representing the call session. + """ + pass + + @abc.abstractmethod + def create_audio_track(self) -> AudioStreamTrack: + """Create an audio stream track for sending audio to the call. + + Returns: + AudioStreamTrack: A track that can be used to stream audio data. + """ pass @abc.abstractmethod async def close(self): + """Close the transport and clean up all resources. + + This should disconnect from any active calls, release network resources, + and perform any necessary cleanup. + """ pass @abc.abstractmethod def open_demo(self, *args, **kwargs): + """Open a demo/preview interface for the call. + + Args: + *args: Transport-specific positional arguments. + **kwargs: Transport-specific keyword arguments. + """ pass @abc.abstractmethod - async def join(self, *args, **kwargs): + async def join(self, agent: "Agent", call: T_Call, **kwargs) -> Connection: + """Join a call and establish a connection. + + This method connects the agent to an active call session, setting up + the necessary infrastructure for real-time audio/video communication. + Implementations should configure media subscriptions, set up event handlers, + and establish the transport-specific connection. + + Args: + agent: The Agent instance joining the call. + call: Call object representing the call session to join. + **kwargs: Additional transport-specific configuration options. + + Returns: + Connection: An active connection implementing the Connection interface, + which provides methods for managing the connection lifecycle. + """ pass @abc.abstractmethod - async def publish_tracks(self, audio_track, video_track): + async def publish_tracks( + self, + audio_track: Optional[aiortc.MediaStreamTrack], + video_track: Optional[aiortc.MediaStreamTrack], + ): + """Publish audio and/or video tracks to the active call. + + Args: + audio_track: Optional audio track to publish. + video_track: Optional video track to publish. + """ pass @abc.abstractmethod - async def create_conversation(self, call: Any, user: User, instructions): + async def create_conversation(self, call: Call, user: User, instructions: str): pass @abc.abstractmethod - def add_track_subscriber( - self, track_id: str - ) -> Optional[aiortc.mediastreams.MediaStreamTrack]: + def add_track_subscriber(self, track_id: str) -> Optional[aiortc.VideoStreamTrack]: pass @abc.abstractmethod diff --git a/agents-core/vision_agents/core/edge/events.py b/agents-core/vision_agents/core/edge/events.py index 78fdf467a..439d4d5e7 100644 --- a/agents-core/vision_agents/core/edge/events.py +++ b/agents-core/vision_agents/core/edge/events.py @@ -1,8 +1,10 @@ from dataclasses import dataclass, field +from typing import Optional from getstream.video.rtc.track_util import PcmData from vision_agents.core.events import PluginBaseEvent -from typing import Optional, Any + +from .types import TrackType @dataclass @@ -11,7 +13,6 @@ class AudioReceivedEvent(PluginBaseEvent): type: str = field(default="plugin.edge.audio_received", init=False) pcm_data: Optional[PcmData] = None - participant: Optional[Any] = None @dataclass @@ -20,8 +21,7 @@ class TrackAddedEvent(PluginBaseEvent): type: str = field(default="plugin.edge.track_added", init=False) track_id: Optional[str] = None - track_type: Optional[int] = None - user: Optional[Any] = None + track_type: Optional[TrackType] = None @dataclass @@ -30,8 +30,7 @@ class TrackRemovedEvent(PluginBaseEvent): type: str = field(default="plugin.edge.track_removed", init=False) track_id: Optional[str] = None - track_type: Optional[int] = None - user: Optional[Any] = None + track_type: Optional[TrackType] = None @dataclass diff --git a/agents-core/vision_agents/core/edge/types.py b/agents-core/vision_agents/core/edge/types.py index d07b9a89b..100203091 100644 --- a/agents-core/vision_agents/core/edge/types.py +++ b/agents-core/vision_agents/core/edge/types.py @@ -1,14 +1,7 @@ +import abc +import enum from dataclasses import dataclass -from typing import ( - Any, - Optional, - Protocol, - runtime_checkable, -) - - -from getstream.video.rtc import PcmData -from pyee.asyncio import AsyncIOEventEmitter +from typing import Any, Optional @dataclass @@ -24,26 +17,62 @@ class Participant: user_id: str -class Connection(AsyncIOEventEmitter): +class TrackType(enum.IntEnum): + UNSPECIFIED = 0 + AUDIO = 1 + VIDEO = 2 + SCREEN_SHARE = 3 + SCREEN_SHARE_AUDIO = 4 + + +class Connection(abc.ABC): """ - To standardize we need to have a method to close - and a way to receive a callback when the call is ended - In the future we might want to forward more events + Represents an active connection to a real-time communication session. + + A Connection manages the lifecycle of an agent's participation in a call or session, + tracking participant presence and providing control over the connection state. + + This abstraction allows different transport implementations (e.g., WebRTC) + to provide consistent connection management to the + Agent without exposing transport-specific details. + + Lifecycle: + 1. Connection is established by EdgeTransport.join() + 2. wait_for_participant() can be used to wait for other participants + 3. idle_since() tracks when all participants (except agent) have left + 4. close() terminates the connection and cleans up resources + + Example: + connection = await edge.join(agent, call) + await connection.wait_for_participant(timeout=30.0) + # ... call is active ... + await connection.close() """ - async def close(self): + @abc.abstractmethod + async def close(self) -> None: + """Close the connection and clean up resources.""" pass + @abc.abstractmethod + async def wait_for_participant(self, timeout: Optional[float] = None) -> None: + """ + Wait for at least one participant (other than the agent) to join. -@runtime_checkable -class OutputAudioTrack(Protocol): - """ - A protocol describing an output audio track, the actual implementation depends on the edge transported used - eg. getstream.video.rtc.audio_track.AudioStreamTrack - """ + Args: + timeout: Maximum time to wait in seconds. None means wait indefinitely. - async def write(self, data: PcmData) -> None: ... + Raises: + asyncio.TimeoutError: If timeout is reached before a participant joins. + """ + pass - def stop(self) -> None: ... + @abc.abstractmethod + def idle_since(self) -> float: + """ + Return the timestamp when all participants left (except the agent). - async def flush(self) -> None: ... + Returns: + Timestamp (from time.time()) when connection became idle, or 0.0 if active. + """ + pass diff --git a/agents-core/vision_agents/core/events/__init__.py b/agents-core/vision_agents/core/events/__init__.py index 1a22577af..9c9a37173 100644 --- a/agents-core/vision_agents/core/events/__init__.py +++ b/agents-core/vision_agents/core/events/__init__.py @@ -1,58 +1,3 @@ -from getstream.models import ( - BlockedUserEvent, - CallAcceptedEvent, - CallClosedCaptionsFailedEvent, - CallClosedCaptionsStartedEvent, - CallClosedCaptionsStoppedEvent, - CallCreatedEvent, - CallDeletedEvent, - CallEndedEvent, - CallFrameRecordingFailedEvent, - CallFrameRecordingFrameReadyEvent, - CallFrameRecordingStartedEvent, - CallFrameRecordingStoppedEvent, - CallHLSBroadcastingFailedEvent, - CallHLSBroadcastingStartedEvent, - CallHLSBroadcastingStoppedEvent, - CallLiveStartedEvent, - CallMemberAddedEvent, - CallMemberRemovedEvent, - CallMemberUpdatedEvent, - CallMemberUpdatedPermissionEvent, - CallMissedEvent, - CallModerationBlurEvent, - CallModerationWarningEvent, - CallNotificationEvent, - CallReactionEvent, - CallRecordingFailedEvent, - CallRecordingReadyEvent, - CallRecordingStartedEvent, - CallRecordingStoppedEvent, - CallRejectedEvent, - CallRingEvent, - CallRtmpBroadcastFailedEvent, - CallRtmpBroadcastStartedEvent, - CallRtmpBroadcastStoppedEvent, - CallSessionEndedEvent, - CallSessionParticipantCountsUpdatedEvent, - CallSessionParticipantJoinedEvent, - CallSessionParticipantLeftEvent, - CallSessionStartedEvent, - CallStatsReportReadyEvent, - CallTranscriptionFailedEvent, - CallTranscriptionReadyEvent, - CallTranscriptionStartedEvent, - CallTranscriptionStoppedEvent, - CallUpdatedEvent, - CallUserFeedbackSubmittedEvent, - CallUserMutedEvent, - ClosedCaptionEvent, - KickedUserEvent, - PermissionRequestEvent, - UnblockedUserEvent, - UpdatedCallPermissionsEvent, -) - from .base import ( AudioFormat, BaseEvent, @@ -63,65 +8,10 @@ from .manager import EventManager __all__ = [ - "BlockedUserEvent", - "CallAcceptedEvent", - "CallClosedCaptionsFailedEvent", - "CallClosedCaptionsStartedEvent", - "CallClosedCaptionsStoppedEvent", - "CallCreatedEvent", - "CallDeletedEvent", - "CallEndedEvent", - "CallFrameRecordingFailedEvent", - "CallFrameRecordingFrameReadyEvent", - "CallFrameRecordingStartedEvent", - "CallFrameRecordingStoppedEvent", - "CallHLSBroadcastingFailedEvent", - "CallHLSBroadcastingStartedEvent", - "CallHLSBroadcastingStoppedEvent", - "CallLiveStartedEvent", - "CallMemberAddedEvent", - "CallMemberRemovedEvent", - "CallMemberUpdatedEvent", - "CallMemberUpdatedPermissionEvent", - "CallMissedEvent", - "CallModerationBlurEvent", - "CallModerationWarningEvent", - "CallNotificationEvent", - "CallReactionEvent", - "CallRecordingFailedEvent", - "CallRecordingReadyEvent", - "CallRecordingStartedEvent", - "CallRecordingStoppedEvent", - "CallRejectedEvent", - "CallRingEvent", - "CallRtmpBroadcastFailedEvent", - "CallRtmpBroadcastStartedEvent", - "CallRtmpBroadcastStoppedEvent", - "CallSessionEndedEvent", - "CallSessionParticipantCountsUpdatedEvent", - "CallSessionParticipantJoinedEvent", - "CallSessionParticipantLeftEvent", - "CallSessionStartedEvent", - "CallStatsReportReadyEvent", - "CallTranscriptionFailedEvent", - "CallTranscriptionReadyEvent", - "CallTranscriptionStartedEvent", - "CallTranscriptionStoppedEvent", - "CallUpdatedEvent", - "CallUserFeedbackSubmittedEvent", - "CallUserMutedEvent", - "ClosedCaptionEvent", - "KickedUserEvent", - "PermissionRequestEvent", - "UnblockedUserEvent", - "UpdatedCallPermissionsEvent", -] - -__all__ += [ - "ConnectionState", "AudioFormat", "BaseEvent", + "ConnectionState", + "EventManager", "PluginBaseEvent", "VideoProcessorDetectionEvent", - "EventManager", ] diff --git a/agents-core/vision_agents/core/events/base.py b/agents-core/vision_agents/core/events/base.py index 7250c6a42..00846b147 100644 --- a/agents-core/vision_agents/core/events/base.py +++ b/agents-core/vision_agents/core/events/base.py @@ -7,7 +7,7 @@ from typing import Any, Optional from dataclasses_json import DataClassJsonMixin -from getstream.video.rtc.pb.stream.video.sfu.models.models_pb2 import Participant +from vision_agents.core.edge.types import Participant class ConnectionState(Enum): diff --git a/agents-core/vision_agents/core/events/manager.py b/agents-core/vision_agents/core/events/manager.py index 75f8f0c5a..6a32ebaeb 100644 --- a/agents-core/vision_agents/core/events/manager.py +++ b/agents-core/vision_agents/core/events/manager.py @@ -142,9 +142,13 @@ def __init__(self, ignore_unknown_events: bool = True): # Start background processing task self._start_processing_task() - def register(self, event_class, ignore_not_compatible=False): + def register( + self, + *event_classes: type, + ignore_not_compatible=False, + ): """ - Register an event class for use with the event manager. + Register event classes for use with the event manager. Event classes must: - Have a name ending with 'Event' @@ -156,31 +160,31 @@ def register(self, event_class, ignore_not_compatible=False): from vision_agents.core.stt.events import STTTranscriptEvent manager = EventManager() - manager.register(VADSpeechStartEvent) - manager.register(STTTranscriptEvent) + manager.register(VADSpeechStartEvent, STTTranscriptEvent) ``` Args: - event_class: The event class to register + event_classes: The event classes to register ignore_not_compatible (bool): If True, log warning instead of raising error for incompatible classes. Defaults to False. Raises: ValueError: If event_class doesn't meet requirements and ignore_not_compatible is False """ - if event_class.__name__.endswith("Event") and hasattr(event_class, "type"): - self._events[event_class.type] = event_class - logger.debug(f"Registered new event {event_class} - {event_class.type}") - elif event_class.__name__.endswith("BaseEvent"): - return - elif not ignore_not_compatible: - raise ValueError( - f"Provide valid class that ends on '*Event' and 'type' attribute: {event_class}" - ) - else: - logger.warning( - f"Provide valid class that ends on '*Event' and 'type' attribute: {event_class}" - ) + for event_class in event_classes: + if event_class.__name__.endswith("Event") and hasattr(event_class, "type"): + self._events[event_class.type] = event_class + logger.debug(f"Registered new event {event_class} - {event_class.type}") + elif event_class.__name__.endswith("BaseEvent"): + continue + elif not ignore_not_compatible: + raise ValueError( + f"Provide valid class that ends on '*Event' and 'type' attribute: {event_class}" + ) + else: + logger.warning( + f"Provide valid class that ends on '*Event' and 'type' attribute: {event_class}" + ) def merge(self, em: "EventManager"): # Stop the processing task in the merged manager diff --git a/agents-core/vision_agents/core/llm/llm.py b/agents-core/vision_agents/core/llm/llm.py index b8eb10ca2..d315294ce 100644 --- a/agents-core/vision_agents/core/llm/llm.py +++ b/agents-core/vision_agents/core/llm/llm.py @@ -25,7 +25,7 @@ from vision_agents.core.agents.conversation import Conversation from getstream.video.rtc import PcmData -from getstream.video.rtc.pb.stream.video.sfu.models.models_pb2 import Participant +from vision_agents.core.edge.types import Participant from vision_agents.core.events.manager import EventManager from vision_agents.core.processors import Processor diff --git a/agents-core/vision_agents/core/tts/tts.py b/agents-core/vision_agents/core/tts/tts.py index c03784e5c..067f69228 100644 --- a/agents-core/vision_agents/core/tts/tts.py +++ b/agents-core/vision_agents/core/tts/tts.py @@ -2,15 +2,16 @@ import logging import time import uuid -from typing import Any, AsyncGenerator, AsyncIterator, Dict, Iterator, Optional, Union +from typing import Any, AsyncGenerator, AsyncIterator, Iterator, Optional, Union import av +from getstream.video.rtc import PcmData +from vision_agents.core.edge.types import Participant from vision_agents.core.events import ( AudioFormat, ) from vision_agents.core.events.manager import EventManager -from ..edge.types import PcmData from . import events from .events import ( TTSAudioEvent, @@ -119,7 +120,7 @@ def _emit_chunk( is_final: bool, synthesis_id: str, text: str, - user: Optional[Dict[str, Any]], + participant: Optional[Participant], ) -> tuple[int, float]: """Emit TTSAudioEvent; return (bytes_len, duration_ms).""" @@ -136,7 +137,7 @@ def _emit_chunk( data=pcm, synthesis_id=synthesis_id, text_source=text, - participant=user, + participant=participant, chunk_index=idx, is_final_chunk=is_final, ) @@ -184,16 +185,20 @@ async def stop_audio(self) -> None: pass async def send( - self, text: str, user: Optional[Dict[str, Any]] = None, *args, **kwargs + self, + text: str, + participant: Optional[Participant] = None, + *args, + **kwargs, ): """ Convert text to speech and emit audio events with the desired format. Args: text: The text to convert to speech - user: Optional user metadata to include with the audio event - *args: Additional arguments - **kwargs: Additional keyword arguments + participant: Optional participant to associate with the audio event + *args: Additional arguments passed to stream_audio() + **kwargs: Additional keyword arguments passed to stream_audio() """ start_time = time.perf_counter() @@ -209,7 +214,7 @@ async def send( plugin_name=self.provider_name, text=text, synthesis_id=synthesis_id, - participant=user, + participant=participant, ) ) @@ -226,7 +231,7 @@ async def send( synthesis_time = time.perf_counter() - start_time if isinstance(response, (PcmData,)): bytes_len, dur_ms = self._emit_chunk( - response, 0, True, synthesis_id, text, user + response, 0, True, synthesis_id, text, participant ) total_audio_bytes += bytes_len total_audio_ms += dur_ms @@ -237,7 +242,7 @@ async def send( if chunk_index == 0: synthesis_time = time.perf_counter() - start_time bytes_len, dur_ms = self._emit_chunk( - pcm, chunk_index, False, synthesis_id, text, user + pcm, chunk_index, False, synthesis_id, text, participant ) total_audio_bytes += bytes_len total_audio_ms += dur_ms @@ -257,7 +262,7 @@ async def send( plugin_name=self.provider_name, synthesis_id=synthesis_id, text=text, - participant=user, + participant=participant, total_audio_bytes=total_audio_bytes, synthesis_time_ms=synthesis_time * 1000, audio_duration_ms=estimated_audio_duration_ms, @@ -274,7 +279,7 @@ async def send( context="synthesis", text_source=text, synthesis_id=synthesis_id or None, - participant=user, + participant=participant, ) ) raise diff --git a/agents-core/vision_agents/core/utils/audio_track.py b/agents-core/vision_agents/core/utils/audio_track.py deleted file mode 100644 index a22c03a1d..000000000 --- a/agents-core/vision_agents/core/utils/audio_track.py +++ /dev/null @@ -1,11 +0,0 @@ -from av.frame import Frame -from getstream.video.rtc.audio_track import AudioStreamTrack - -import logging - -logger = logging.getLogger(__name__) - - -class QueuedAudioTrack(AudioStreamTrack): - async def recv(self) -> Frame: - return await super().recv() diff --git a/docs/ai/instructions/ai-utils.md b/docs/ai/instructions/ai-utils.md index 65eb9b416..ab8af9027 100644 --- a/docs/ai/instructions/ai-utils.md +++ b/docs/ai/instructions/ai-utils.md @@ -11,6 +11,7 @@ Audio resampling code lives in getstream library (https://github.com/GetStream/s ## Creating PcmData ### from_bytes + Build from raw PCM bytes ```python @@ -20,6 +21,7 @@ PcmData.from_bytes(audio_bytes, sample_rate=16000, format=AudioFormat.S16, chann ``` ### from_numpy + Build from numpy arrays with automatic dtype/shape conversion ```python @@ -28,6 +30,7 @@ PcmData.from_numpy(np.array([1, 2], np.int16), sample_rate=16000, format=AudioFo ``` ### from_response + Construct from API response (bytes, iterators, async iterators, objects with .data) ```python @@ -38,6 +41,7 @@ PcmData.from_response( ``` ### from_av_frame + Create from PyAV AudioFrame ```python @@ -47,6 +51,7 @@ PcmData.from_av_frame(frame) ## Converting Format ### to_float32 + Convert samples to float32 in [-1, 1] ```python @@ -54,6 +59,7 @@ pcm_f32 = pcm.to_float32() ``` ### to_int16 + Convert samples to int16 PCM format ```python @@ -61,6 +67,7 @@ pcm_s16 = pcm.to_int16() ``` ### to_bytes + Return interleaved PCM bytes ```python @@ -68,6 +75,7 @@ audio_bytes = pcm.to_bytes() ``` ### to_wav_bytes + Return WAV file bytes (header + frames) ```python @@ -87,6 +95,7 @@ pcm = pcm.resample(16000, target_channels=1) # to 16khz, mono ## Manipulating Audio ### append + Append another PcmData in-place (adjusts format/rate automatically) ```python @@ -94,6 +103,7 @@ pcm.append(other_pcm) ``` ### copy + Create a deep copy ```python @@ -101,6 +111,7 @@ pcm_copy = pcm.copy() ``` ### clear + Clear all samples in-place (keeps metadata) ```python @@ -110,6 +121,7 @@ pcm.clear() ## Slicing and Chunking ### head + Keep only the first N seconds ```python @@ -117,6 +129,7 @@ pcm_head = pcm.head(duration_s=3.0) ``` ### tail + Keep only the last N seconds ```python @@ -124,6 +137,7 @@ pcm_tail = pcm.tail(duration_s=5.0) ``` ### chunks + Iterate over fixed-size chunks with optional overlap ```python @@ -157,12 +171,12 @@ Use `getstream.video.rtc.AudioTrack` if you need to publish audio using PyAV, th - Use `.write()` method to enqueue audio (PcmData) - Use `.flush()` to empty all the enqueued audio (eg. barge-in event) -By default AudioTrack holds 30s of audio in the buffer. +By default, AudioTrack holds 30s of audio in the buffer. # Video track * VideoForwarder to forward video. see video_forwarder.py * AudioForwarder to forward audio. See audio_forwarder.py * QueuedVideoTrack to have a writable video track -* QueuedAudioTrack to have a writable audio track +* AudioStreamTrack to have a writable audio track * AudioQueue enables you to buffer audio, and read a certain number of ms or number of samples of audio diff --git a/agents-core/vision_agents/PROTOBUF_GENERATION.md b/plugins/getstream/PROTOBUF_GENERATION.md similarity index 90% rename from agents-core/vision_agents/PROTOBUF_GENERATION.md rename to plugins/getstream/PROTOBUF_GENERATION.md index cd4d7bef0..1cb307b70 100644 --- a/agents-core/vision_agents/PROTOBUF_GENERATION.md +++ b/plugins/getstream/PROTOBUF_GENERATION.md @@ -6,8 +6,8 @@ The `_generate_sfu_events.py` script automatically generates Python dataclass wr ## Location -- **Generator Script**: `agents-core/vision_agents/_generate_sfu_events.py` -- **Generated Output**: `agents-core/vision_agents/core/edge/sfu_events.py` +- **Generator Script**: `plugins/getstream/_generate_sfu_events.py` +- **Generated Output**: `plugins/getstream/vision_agents/plugins/getstream/sfu_events.py` ## Key Features @@ -91,8 +91,8 @@ Each generated class provides: ### Regenerating Events ```bash -cd agents-core -uv run python vision_agents/_generate_sfu_events.py +cd plugins/getstream +uv run python _generate_sfu_events.py ``` ### Verification @@ -101,19 +101,19 @@ Verify type mappings and generated classes: ```bash # Show type mappings -uv run python vision_agents/_generate_sfu_events.py --verify-types +uv run python _generate_sfu_events.py --verify-types # Verify generated classes -uv run python vision_agents/_generate_sfu_events.py --verify +uv run python _generate_sfu_events.py --verify # Both -uv run python vision_agents/_generate_sfu_events.py --verify-types --verify +uv run python _generate_sfu_events.py --verify-types --verify ``` ### Example Usage ```python -from vision_agents.core.edge.sfu_events import ( +from vision_agents.plugins.getstream.sfu_events import ( AudioLevelEvent, TrackUnpublishedEvent, Participant # Now properly typed! @@ -225,12 +225,12 @@ class AudioLevelEvent(BaseEvent): ## Import Strategy -The edge module uses absolute imports instead of relative imports to avoid naming conflicts with standard library modules (specifically avoiding conflicts with Python's `types` module). +The sfu_events module is part of the GetStream plugin and should be imported from there: ```python -# In edge/__init__.py -from vision_agents.core.edge.edge_transport import EdgeTransport -from vision_agents.core.edge import sfu_events +# Import sfu_events from the getstream plugin +from vision_agents.plugins.getstream import sfu_events +from vision_agents.plugins.getstream.sfu_events import AudioLevelEvent ``` ## Event Manager Integration @@ -242,8 +242,8 @@ The EventManager has been updated to seamlessly handle the new protobuf events: 1. **Register protobuf event classes** like any other event: ```python from vision_agents.core.events.manager import EventManager - from vision_agents.core.edge.sfu_events import AudioLevelEvent - + from vision_agents.plugins.getstream.sfu_events import AudioLevelEvent + manager = EventManager() manager.register(AudioLevelEvent) ``` diff --git a/agents-core/vision_agents/_generate_sfu_events.py b/plugins/getstream/_generate_sfu_events.py similarity index 98% rename from agents-core/vision_agents/_generate_sfu_events.py rename to plugins/getstream/_generate_sfu_events.py index d039a245a..6515a0c5f 100644 --- a/agents-core/vision_agents/_generate_sfu_events.py +++ b/plugins/getstream/_generate_sfu_events.py @@ -10,6 +10,8 @@ import pathlib from typing import ( Dict as TypingDict, +) +from typing import ( Iterable, List, Optional, @@ -19,12 +21,10 @@ Type, ) -from google.protobuf.descriptor import FieldDescriptor -from google.protobuf.message import Message - from getstream.video.rtc.pb.stream.video.sfu.event import events_pb2 from getstream.video.rtc.pb.stream.video.sfu.models import models_pb2 - +from google.protobuf.descriptor import FieldDescriptor +from google.protobuf.message import Message HEADER_LINES: Sequence[str] = ( "from __future__ import annotations", @@ -455,7 +455,13 @@ def verify_generated_classes() -> bool: import sys # Import the generated module - target_path = pathlib.Path(__file__).parent / "core" / "edge" / "sfu_events.py" + target_path = ( + pathlib.Path(__file__).parent + / "vision_agents" + / "plugins" + / "getstream" + / "sfu_events.py" + ) if not target_path.exists(): print("Error: sfu_events.py not found. Run generation first.") return False @@ -567,8 +573,14 @@ def verify_field_types() -> None: def main() -> None: import sys - # Generate sfu_events.py in the core/edge directory - target_path = pathlib.Path(__file__).parent / "core" / "edge" / "sfu_events.py" + # Generate sfu_events.py in the Python package directory + target_path = ( + pathlib.Path(__file__).parent + / "vision_agents" + / "plugins" + / "getstream" + / "sfu_events.py" + ) target_path.write_text(_build_module(), encoding="utf-8") print(f"Regenerated {target_path}") diff --git a/plugins/getstream/tests/test_getstream_plugin.py b/plugins/getstream/tests/test_getstream_plugin.py index 548277a4d..8868d59fd 100644 --- a/plugins/getstream/tests/test_getstream_plugin.py +++ b/plugins/getstream/tests/test_getstream_plugin.py @@ -1,7 +1,6 @@ -from getstream.video.rtc.pb.stream.video.sfu.models.models_pb2 import TrackType - -from vision_agents.core.events.manager import EventManager from vision_agents.core.edge.events import TrackAddedEvent, TrackRemovedEvent +from vision_agents.core.edge.types import TrackType +from vision_agents.core.events.manager import EventManager class TestTrackRepublishing: @@ -30,7 +29,7 @@ async def collect_track_events(event: TrackAddedEvent | TrackRemovedEvent): # Simulate track lifecycle: start -> stop -> start again track_id = "screenshare-track-1" - track_type = TrackType.TRACK_TYPE_SCREEN_SHARE + track_type = TrackType.SCREEN_SHARE # 1. Start screenshare event_manager.send( diff --git a/plugins/getstream/tests/test_sfu_events.py b/plugins/getstream/tests/test_sfu_events.py new file mode 100644 index 000000000..6367ecf62 --- /dev/null +++ b/plugins/getstream/tests/test_sfu_events.py @@ -0,0 +1,138 @@ +from getstream.video.rtc.pb.stream.video.sfu.event import events_pb2 +from getstream.video.rtc.pb.stream.video.sfu.models import models_pb2 +from vision_agents.core.events.manager import EventManager +from vision_agents.plugins.getstream.sfu_events import ( + AudioLevelEvent, + ParticipantJoinedEvent, + TrackPublishedEvent, + TrackUnpublishedEvent, +) + + +class TestSFUEvents: + """Tests for SFU events in the GetStream plugin.""" + + async def test_protobuf_events_with_base_event(self): + """Test that event manager handles protobuf events that inherit from BaseEvent.""" + + manager = EventManager() + + # Register generated protobuf event classes + manager.register(AudioLevelEvent) + manager.register(ParticipantJoinedEvent) + + assert AudioLevelEvent.type in manager._events + assert ParticipantJoinedEvent.type in manager._events + + # Test 1: Send wrapped protobuf event with BaseEvent fields + proto_audio = events_pb2.AudioLevel( + user_id="user123", level=0.85, is_speaking=True + ) + wrapped_event = AudioLevelEvent.from_proto(proto_audio, session_id="session123") + + received_audio_events = [] + + @manager.subscribe + async def handle_audio(event: AudioLevelEvent): + received_audio_events.append(event) + + manager.send(wrapped_event) + await manager.wait() + + assert len(received_audio_events) == 1 + assert received_audio_events[0].user_id == "user123" + assert received_audio_events[0].session_id == "session123" + assert received_audio_events[0].is_speaking is True + assert received_audio_events[0].level is not None + assert abs(received_audio_events[0].level - 0.85) < 0.01 + assert hasattr(received_audio_events[0], "event_id") + assert hasattr(received_audio_events[0], "timestamp") + + # Test 2: Send raw protobuf message (auto-wrapped) + proto_raw = events_pb2.AudioLevel( + user_id="user456", level=0.95, is_speaking=False + ) + + received_audio_events.clear() + manager.send(proto_raw) + await manager.wait() + + assert len(received_audio_events) == 1 + assert received_audio_events[0].user_id == "user456" + assert received_audio_events[0].level is not None + assert abs(received_audio_events[0].level - 0.95) < 0.01 + assert received_audio_events[0].is_speaking is False + assert hasattr(received_audio_events[0], "event_id") + + # Test 3: Create event without protobuf payload (all fields optional) + empty_event = AudioLevelEvent() + assert empty_event.payload is None + assert empty_event.user_id is None + assert empty_event.event_id is not None + + # Test 4: Multiple protobuf event types + received_participant_events = [] + + @manager.subscribe + async def handle_participant(event: ParticipantJoinedEvent): + received_participant_events.append(event) + + participant = models_pb2.Participant(user_id="user789", session_id="sess456") + proto_participant = events_pb2.ParticipantJoined( + call_cid="call123", participant=participant + ) + + manager.send(proto_participant) + await manager.wait() + + assert len(received_participant_events) == 1 + assert received_participant_events[0].call_cid == "call123" + assert received_participant_events[0].participant is not None + assert hasattr(received_participant_events[0], "event_id") + + async def test_track_published_event_with_participant_property(self): + """Test that TrackPublishedEvent correctly handles participant property override.""" + + manager = EventManager() + + # Register events that override participant field with property + manager.register(TrackPublishedEvent) + manager.register(TrackUnpublishedEvent) + + # Test TrackPublishedEvent + participant = models_pb2.Participant(user_id="user123", session_id="session456") + proto_published = events_pb2.TrackPublished( + user_id="user123", participant=participant + ) + + # This should NOT raise "AttributeError: property 'participant' of 'TrackPublishedEvent' object has no setter" + TrackPublishedEvent.from_proto(proto_published) + + received_events = [] + + @manager.subscribe + async def handle_published(event: TrackPublishedEvent): + received_events.append(event) + + # Send raw protobuf message (auto-wrapped by manager) + manager.send(proto_published) + await manager.wait() + + assert len(received_events) == 1 + assert received_events[0].user_id == "user123" + # Verify participant property returns correct value from protobuf payload + assert received_events[0].participant is not None + assert received_events[0].participant.user_id == "user123" + assert received_events[0].participant.session_id == "session456" + assert hasattr(received_events[0], "event_id") + + # Test TrackUnpublishedEvent + proto_unpublished = events_pb2.TrackUnpublished( + user_id="user456", participant=participant, cause=1 + ) + + unpublished_event = TrackUnpublishedEvent.from_proto(proto_unpublished) + assert unpublished_event.user_id == "user456" + assert unpublished_event.participant is not None + assert unpublished_event.participant.user_id == "user123" + assert unpublished_event.cause == 1 diff --git a/plugins/getstream/vision_agents/plugins/getstream/__init__.py b/plugins/getstream/vision_agents/plugins/getstream/__init__.py index 27185bbbe..28da8cb1d 100644 --- a/plugins/getstream/vision_agents/plugins/getstream/__init__.py +++ b/plugins/getstream/vision_agents/plugins/getstream/__init__.py @@ -1,8 +1,118 @@ # GetStream plugin for Stream Agents -from .stream_conversation import StreamConversation as Conversation +from getstream import Stream as Client +from getstream.models import ( + BlockedUserEvent, + CallAcceptedEvent, + CallClosedCaptionsFailedEvent, + CallClosedCaptionsStartedEvent, + CallClosedCaptionsStoppedEvent, + CallCreatedEvent, + CallDeletedEvent, + CallEndedEvent, + CallFrameRecordingFailedEvent, + CallFrameRecordingFrameReadyEvent, + CallFrameRecordingStartedEvent, + CallFrameRecordingStoppedEvent, + CallHLSBroadcastingFailedEvent, + CallHLSBroadcastingStartedEvent, + CallHLSBroadcastingStoppedEvent, + CallLiveStartedEvent, + CallMemberAddedEvent, + CallMemberRemovedEvent, + CallMemberUpdatedEvent, + CallMemberUpdatedPermissionEvent, + CallMissedEvent, + CallModerationBlurEvent, + CallModerationWarningEvent, + CallNotificationEvent, + CallReactionEvent, + CallRecordingFailedEvent, + CallRecordingReadyEvent, + CallRecordingStartedEvent, + CallRecordingStoppedEvent, + CallRejectedEvent, + CallRingEvent, + CallRtmpBroadcastFailedEvent, + CallRtmpBroadcastStartedEvent, + CallRtmpBroadcastStoppedEvent, + CallSessionEndedEvent, + CallSessionParticipantCountsUpdatedEvent, + CallSessionParticipantJoinedEvent, + CallSessionParticipantLeftEvent, + CallSessionStartedEvent, + CallStatsReportReadyEvent, + CallTranscriptionFailedEvent, + CallTranscriptionReadyEvent, + CallTranscriptionStartedEvent, + CallTranscriptionStoppedEvent, + CallUpdatedEvent, + CallUserFeedbackSubmittedEvent, + CallUserMutedEvent, + ClosedCaptionEvent, + KickedUserEvent, + PermissionRequestEvent, + UnblockedUserEvent, + UpdatedCallPermissionsEvent, +) +from .stream_conversation import StreamConversation as Conversation from .stream_edge_transport import StreamEdge as Edge -from getstream import Stream as Client - -__all__ = ["Conversation", "Edge", "Client"] +__all__ = [ + "Conversation", + "Edge", + "Client", + # Getstream event re-exports + "BlockedUserEvent", + "CallAcceptedEvent", + "CallClosedCaptionsFailedEvent", + "CallClosedCaptionsStartedEvent", + "CallClosedCaptionsStoppedEvent", + "CallCreatedEvent", + "CallDeletedEvent", + "CallEndedEvent", + "CallFrameRecordingFailedEvent", + "CallFrameRecordingFrameReadyEvent", + "CallFrameRecordingStartedEvent", + "CallFrameRecordingStoppedEvent", + "CallHLSBroadcastingFailedEvent", + "CallHLSBroadcastingStartedEvent", + "CallHLSBroadcastingStoppedEvent", + "CallLiveStartedEvent", + "CallMemberAddedEvent", + "CallMemberRemovedEvent", + "CallMemberUpdatedEvent", + "CallMemberUpdatedPermissionEvent", + "CallMissedEvent", + "CallModerationBlurEvent", + "CallModerationWarningEvent", + "CallNotificationEvent", + "CallReactionEvent", + "CallRecordingFailedEvent", + "CallRecordingReadyEvent", + "CallRecordingStartedEvent", + "CallRecordingStoppedEvent", + "CallRejectedEvent", + "CallRingEvent", + "CallRtmpBroadcastFailedEvent", + "CallRtmpBroadcastStartedEvent", + "CallRtmpBroadcastStoppedEvent", + "CallSessionEndedEvent", + "CallSessionParticipantCountsUpdatedEvent", + "CallSessionParticipantJoinedEvent", + "CallSessionParticipantLeftEvent", + "CallSessionStartedEvent", + "CallStatsReportReadyEvent", + "CallTranscriptionFailedEvent", + "CallTranscriptionReadyEvent", + "CallTranscriptionStartedEvent", + "CallTranscriptionStoppedEvent", + "CallUpdatedEvent", + "CallUserFeedbackSubmittedEvent", + "CallUserMutedEvent", + "ClosedCaptionEvent", + "KickedUserEvent", + "PermissionRequestEvent", + "UnblockedUserEvent", + "UpdatedCallPermissionsEvent", +] diff --git a/agents-core/vision_agents/core/edge/sfu_events.py b/plugins/getstream/vision_agents/plugins/getstream/sfu_events.py similarity index 99% rename from agents-core/vision_agents/core/edge/sfu_events.py rename to plugins/getstream/vision_agents/plugins/getstream/sfu_events.py index 7de659fad..6051cc69f 100644 --- a/agents-core/vision_agents/core/edge/sfu_events.py +++ b/plugins/getstream/vision_agents/plugins/getstream/sfu_events.py @@ -7,8 +7,8 @@ from typing import Any, Dict, List, Optional from dataclasses_json import DataClassJsonMixin -from google.protobuf.json_format import MessageToDict from getstream.video.rtc.pb.stream.video.sfu.event import events_pb2 +from google.protobuf.json_format import MessageToDict from vision_agents.core.events.base import BaseEvent # Note: For enum fields typed as 'int', use the corresponding enum from: @@ -1492,7 +1492,7 @@ def call_cid(self) -> Optional[str]: return None return getattr(self.payload, "call_cid", None) - @property # type: ignore[misc] + @property # type: ignore[override,misc] def participant(self) -> Optional[Participant]: # type: ignore[override] """Access participant field from the protobuf payload.""" if self.payload is None: @@ -1541,7 +1541,7 @@ def call_cid(self) -> Optional[str]: return None return getattr(self.payload, "call_cid", None) - @property # type: ignore[misc] + @property # type: ignore[misc,override] def participant(self) -> Optional[Participant]: # type: ignore[override] """Access participant field from the protobuf payload.""" if self.payload is None: @@ -1621,7 +1621,7 @@ def call_cid(self) -> Optional[str]: return None return getattr(self.payload, "call_cid", None) - @property # type: ignore[misc] + @property # type: ignore[misc,override] def participant(self) -> Optional[Participant]: # type: ignore[override] """Access participant field from the protobuf payload.""" if self.payload is None: @@ -2096,7 +2096,7 @@ def user_id(self) -> Optional[str]: # type: ignore[override] return None return getattr(self.payload, "user_id", None) - @property # type: ignore[misc] + @property # type: ignore[misc,override] def participant(self) -> Optional[Participant]: # type: ignore[override] """Access participant field from the protobuf payload.""" if self.payload is None: @@ -2152,7 +2152,7 @@ def cause(self) -> Optional[int]: return None return getattr(self.payload, "cause", None) - @property # type: ignore[misc] + @property # type: ignore[misc,override] def participant(self) -> Optional[Participant]: # type: ignore[override] """Access participant field from the protobuf payload.""" if self.payload is None: diff --git a/plugins/getstream/vision_agents/plugins/getstream/stream_edge_transport.py b/plugins/getstream/vision_agents/plugins/getstream/stream_edge_transport.py index 940b84c5e..4e6f60ead 100644 --- a/plugins/getstream/vision_agents/plugins/getstream/stream_edge_transport.py +++ b/plugins/getstream/vision_agents/plugins/getstream/stream_edge_transport.py @@ -4,36 +4,83 @@ import os import time import webbrowser -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, cast from urllib.parse import urlencode import aiortc +import getstream.models from getstream import AsyncStream -from getstream.chat.async_client import ChatClient -from getstream.models import ChannelInput, ChannelMember, ChannelMemberRequest +from getstream.models import ( + ChannelInput, + ChannelMember, + ChannelMemberRequest, + UserRequest, +) from getstream.video import rtc -from getstream.video.async_call import Call -from getstream.video.rtc import ConnectionManager, audio_track +from getstream.video.async_call import Call as StreamCall +from getstream.video.rtc import AudioStreamTrack, ConnectionManager from getstream.video.rtc.participants import ParticipantsState from getstream.video.rtc.pb.stream.video.sfu.models.models_pb2 import ( - Participant, - TrackType, + Participant as StreamParticipant, +) +from getstream.video.rtc.pb.stream.video.sfu.models.models_pb2 import ( + TrackType as StreamTrackType, ) from getstream.video.rtc.track_util import PcmData from getstream.video.rtc.tracks import SubscriptionConfig, TrackSubscriptionConfig from vision_agents.core.agents.agents import tracer -from vision_agents.core.edge import EdgeTransport, events, sfu_events -from vision_agents.core.edge.types import Connection, OutputAudioTrack, User -from vision_agents.core.events.manager import EventManager +from vision_agents.core.edge import Call, EdgeTransport, events +from vision_agents.core.edge.types import Connection, Participant, TrackType, User from vision_agents.core.utils import get_vision_agents_version from vision_agents.plugins.getstream.stream_conversation import StreamConversation +from . import sfu_events + if TYPE_CHECKING: from vision_agents.core.agents.agents import Agent logger = logging.getLogger(__name__) +# Conversion maps and functions for getstream -> core types +_TRACK_TYPE_MAP = { + StreamTrackType.TRACK_TYPE_UNSPECIFIED: TrackType.UNSPECIFIED, + StreamTrackType.TRACK_TYPE_VIDEO: TrackType.VIDEO, + StreamTrackType.TRACK_TYPE_AUDIO: TrackType.AUDIO, + StreamTrackType.TRACK_TYPE_SCREEN_SHARE: TrackType.SCREEN_SHARE, + StreamTrackType.TRACK_TYPE_SCREEN_SHARE_AUDIO: TrackType.SCREEN_SHARE_AUDIO, +} + + +def _to_core_track_type(stream_track_type: StreamTrackType.ValueType) -> TrackType: + """Convert getstream TrackType to core TrackType.""" + type_ = _TRACK_TYPE_MAP.get(stream_track_type) + if type_ is None: + raise ValueError(f"Unknown track type: {stream_track_type}") + return type_ + + +def _to_core_participant( + participant: sfu_events.Participant | StreamParticipant | None, +) -> Participant | None: + """Convert plugin or protobuf participant to core Participant type. + + Args: + participant: Plugin's sfu_events.Participant wrapper, protobuf + StreamParticipant, or None + + Returns: + Core Participant with original and user_id, or None + """ + if participant is None: + return None + + if not participant.user_id: + return None + + return Participant(original=participant, user_id=participant.user_id) + + class StreamConnection(Connection): def __init__(self, connection: ConnectionManager): super().__init__() @@ -79,7 +126,7 @@ async def close(self, timeout: float = 2.0): except Exception as e: logger.error(f"Error during connection close: {e}") - def _on_participant_change(self, participants: list[Participant]) -> None: + def _on_participant_change(self, participants: list[StreamParticipant]) -> None: # Get all participants except the agent itself. other_participants = [ p for p in participants if p.user_id != self._connection.user_id @@ -95,7 +142,7 @@ def _on_participant_change(self, participants: list[Participant]) -> None: self._idle_since = time.time() -class StreamEdge(EdgeTransport): +class StreamEdge(EdgeTransport[StreamCall]): """ StreamEdge uses getstream.io's edge network. To support multiple vendors, this means we expose @@ -108,9 +155,9 @@ def __init__(self, **kwargs): super().__init__() version = get_vision_agents_version() self.client = AsyncStream(user_agent=f"stream-vision-agents-{version}") - self.events = EventManager() - self.events.register_events_from_module(events) + # self.events is inherited from EdgeTransport (with required events already registered) self.events.register_events_from_module(sfu_events) + self.events.register_events_from_module(getstream.models, "call.") self.conversation: Optional[StreamConversation] = None self.channel_type = "messaging" self.agent_user_id: str | None = None @@ -122,7 +169,7 @@ def __init__(self, **kwargs): self._pending_tracks: dict = {} self._real_connection: Optional[ConnectionManager] = None - self._call: Optional[Call] = None + self._call: Optional[StreamCall] = None # Register event handlers self.events.subscribe(self._on_track_published) @@ -136,16 +183,16 @@ def _connection(self) -> ConnectionManager: return self._real_connection def _get_webrtc_kind(self, track_type_int: int) -> str: - """Get the expected WebRTC kind (audio/video) for a SFU track type.""" + """Get the expected WebRTC kind (audio/video) for an SFU track type.""" # Map SFU track types to WebRTC kinds if track_type_int in ( - TrackType.TRACK_TYPE_AUDIO, - TrackType.TRACK_TYPE_SCREEN_SHARE_AUDIO, + StreamTrackType.TRACK_TYPE_AUDIO, + StreamTrackType.TRACK_TYPE_SCREEN_SHARE_AUDIO, ): return "audio" elif track_type_int in ( - TrackType.TRACK_TYPE_VIDEO, - TrackType.TRACK_TYPE_SCREEN_SHARE, + StreamTrackType.TRACK_TYPE_VIDEO, + StreamTrackType.TRACK_TYPE_SCREEN_SHARE, ): return "video" else: @@ -164,17 +211,21 @@ async def _on_track_published(self, event: sfu_events.TrackPublishedEvent): user_id = event.payload.user_id session_id = event.payload.session_id + # Convert Stream track type to the Vision agents track type track_type_int = event.payload.type # TrackType enum int from SFU - expected_kind = self._get_webrtc_kind(track_type_int) - track_key = (user_id, session_id, track_type_int) - is_agent_track = user_id == self.agent_user_id + track_type = _to_core_track_type(track_type_int) + webrtc_track_kind = self._get_webrtc_kind(track_type_int) # Skip processing the agent's own tracks - we don't subscribe to them + is_agent_track = user_id == self.agent_user_id if is_agent_track: - logger.debug(f"Skipping agent's own track: {track_type_int} from {user_id}") + logger.debug( + f'Skipping agent\'s own track: "{track_type.name}" from {user_id}' + ) return # First check if track already exists in map (e.g., from previous unpublish/republish) + track_key = (user_id, session_id, track_type_int) if track_key in self._track_map: self._track_map[track_key]["published"] = True track_id = self._track_map[track_key]["track_id"] @@ -184,8 +235,8 @@ async def _on_track_published(self, event: sfu_events.TrackPublishedEvent): events.TrackAddedEvent( plugin_name="getstream", track_id=track_id, - track_type=track_type_int, - user=event.participant, + track_type=track_type, + participant=_to_core_participant(event.participant), ) ) return @@ -205,7 +256,7 @@ async def _on_track_published(self, event: sfu_events.TrackPublishedEvent): if ( pending_user == user_id and pending_session == session_id - and pending_kind == expected_kind + and pending_kind == webrtc_track_kind ): track_id = tid del self._pending_tracks[tid] @@ -229,15 +280,14 @@ async def _on_track_published(self, event: sfu_events.TrackPublishedEvent): events.TrackAddedEvent( plugin_name="getstream", track_id=track_id, - track_type=track_type_int, - user=event.participant, - participant=event.participant, + track_type=track_type, + participant=_to_core_participant(event.participant), ) ) else: raise TimeoutError( - f"Timeout waiting for pending track: {track_type_int} ({expected_kind}) from user {user_id}, " + f"Timeout waiting for pending track: {track_type.name} from user {user_id}, " f"session {session_id}. Waited {timeout}s but WebRTC track_added with matching kind was never received." f"Pending tracks: {self._pending_tracks}\n" f"Key: {track_key}\n" @@ -271,11 +321,12 @@ async def _on_track_removed( ) or [] event_desc = "Participant left" - track_names = [TrackType.Name(t) for t in tracks_to_remove] + track_names = [StreamTrackType.Name(t) for t in tracks_to_remove] logger.info(f"{event_desc}: {user_id}, tracks: {track_names}") # Mark each track as unpublished and send TrackRemovedEvent for track_type_int in tracks_to_remove: + track_type = _to_core_track_type(track_type_int) track_key = (user_id, session_id, track_type_int) track_info = self._track_map.get(track_key) @@ -285,10 +336,8 @@ async def _on_track_removed( events.TrackRemovedEvent( plugin_name="getstream", track_id=track_id, - track_type=track_type_int, - user=participant, - # TODO: user=participant? - participant=participant, + track_type=track_type, + participant=_to_core_participant(participant), ) ) # Mark as unpublished instead of removing @@ -303,9 +352,8 @@ async def _on_call_ended(self, event: sfu_events.CallEndedEvent): ) ) - async def create_conversation(self, call: Call, user, instructions): - chat_client: ChatClient = call.client.stream.chat - channel = chat_client.channel(self.channel_type, call.id) + async def create_conversation(self, call: Call, user: User, instructions: str): + channel = self.client.chat.channel(self.channel_type, call.id) await channel.get_or_create( data=ChannelInput(created_by_id=user.id), ) @@ -320,21 +368,37 @@ async def create_user(self, user: User): async def create_users(self, users: list[User]): """Create multiple users in a single API call.""" - from getstream.models import UserRequest users_map = {u.id: UserRequest(name=u.name, id=u.id) for u in users} response = await self.client.update_users(users_map) return [response.data.users[u.id] for u in users] - async def join(self, agent: "Agent", call: Call) -> StreamConnection: - """ - The logic for joining a call is different for each edge network/realtime audio/video provider + async def create_call(self, call_id: str, **kwargs) -> StreamCall: + """Shortcut for creating a call/room etc.""" + call_type = kwargs.get("call_type", "default") + call = self.client.video.call(call_type, call_id) + await call.get_or_create(data={"created_by_id": self.agent_user_id}) + return call + + async def join( + self, agent: "Agent", call: StreamCall, **kwargs + ) -> StreamConnection: + """Join a GetStream call and establish a WebRTC connection. + + This method: + - Configures WebRTC subscription for audio/video tracks + - Joins the call with the agent's user ID + - Sets up track and audio event handlers + - Re-emits participant and track events for the agent to consume + - Establishes the connection and republishes existing tracks + + Args: + agent: The Agent instance joining the call. + call: StreamCall object representing the GetStream call to join. + **kwargs: Additional configuration options (unused). - This function - - initializes the chat channel - - has the agent.agent_user join the call - - connects incoming audio/video to the agent - - connecting agent's outgoing audio/video to the call + Returns: + StreamConnection: A connection wrapper implementing the core Connection interface. """ # Traditional mode - use WebRTC connection @@ -361,7 +425,7 @@ async def on_audio_received(pcm: PcmData): events.AudioReceivedEvent( plugin_name="getstream", pcm_data=pcm, - participant=pcm.participant, + participant=_to_core_participant(pcm.participant), ) ) @@ -385,23 +449,25 @@ async def on_audio_received(pcm: PcmData): return standardize_connection def create_audio_track( - self, framerate: int = 48000, stereo: bool = True - ) -> OutputAudioTrack: - return audio_track.AudioStreamTrack( + self, sample_rate: int = 48000, stereo: bool = True + ) -> AudioStreamTrack: + return AudioStreamTrack( audio_buffer_size_ms=300_000, - sample_rate=framerate, + sample_rate=sample_rate, channels=stereo and 2 or 1, ) # default to webrtc framerate - def create_video_track(self): - return aiortc.VideoStreamTrack() - - def add_track_subscriber( - self, track_id: str - ) -> Optional[aiortc.mediastreams.MediaStreamTrack]: - return self._connection.subscriber_pc.add_track_subscriber(track_id) + def add_track_subscriber(self, track_id: str) -> Optional[aiortc.VideoStreamTrack]: + subscriber = self._connection.subscriber_pc.add_track_subscriber(track_id) + if subscriber is not None: + subscriber = cast(aiortc.VideoStreamTrack, subscriber) + return subscriber - async def publish_tracks(self, audio_track, video_track): + async def publish_tracks( + self, + audio_track: Optional[aiortc.MediaStreamTrack], + video_track: Optional[aiortc.MediaStreamTrack], + ): """ Add the tracks to publish audio and video """ @@ -415,15 +481,14 @@ async def publish_tracks(self, audio_track, video_track): def _get_subscription_config(self): return TrackSubscriptionConfig( track_types=[ - TrackType.TRACK_TYPE_VIDEO, - TrackType.TRACK_TYPE_AUDIO, - TrackType.TRACK_TYPE_SCREEN_SHARE, - TrackType.TRACK_TYPE_SCREEN_SHARE_AUDIO, + StreamTrackType.TRACK_TYPE_VIDEO, + StreamTrackType.TRACK_TYPE_AUDIO, + StreamTrackType.TRACK_TYPE_SCREEN_SHARE, + StreamTrackType.TRACK_TYPE_SCREEN_SHARE_AUDIO, ] ) async def close(self): - # Note: Not calling super().close() as it's an abstract method with trivial body self._call = None async def send_custom_event(self, data: dict) -> None: @@ -452,7 +517,7 @@ async def open_demo_for_agent( return await self.open_demo(call) @tracer.start_as_current_span("stream_edge.open_demo") - async def open_demo(self, call: Call) -> str: + async def open_demo(self, call: StreamCall) -> str: client = call.client.stream # Create a human user for testing diff --git a/plugins/moondream/README.md b/plugins/moondream/README.md index 25a7b0088..2c84af9e0 100644 --- a/plugins/moondream/README.md +++ b/plugins/moondream/README.md @@ -133,7 +133,7 @@ from dotenv import load_dotenv from vision_agents.core import User, Agent, Runner from vision_agents.core.agents import AgentLauncher from vision_agents.plugins import deepgram, getstream, elevenlabs, moondream -from vision_agents.core.events import CallSessionParticipantJoinedEvent +from vision_agents.plugins.getstream import CallSessionParticipantJoinedEvent load_dotenv() diff --git a/plugins/moondream/example/moondream_vlm_example.py b/plugins/moondream/example/moondream_vlm_example.py index 0b5e19d68..03ac38f2f 100644 --- a/plugins/moondream/example/moondream_vlm_example.py +++ b/plugins/moondream/example/moondream_vlm_example.py @@ -5,8 +5,8 @@ from dotenv import load_dotenv from vision_agents.core import Agent, Runner, User from vision_agents.core.agents import AgentLauncher -from vision_agents.core.events import CallSessionParticipantJoinedEvent from vision_agents.plugins import deepgram, elevenlabs, getstream, moondream +from vision_agents.plugins.getstream import CallSessionParticipantJoinedEvent logger = logging.getLogger(__name__) diff --git a/plugins/openai/examples/qwen_vl_example/qwen_vl_example.py b/plugins/openai/examples/qwen_vl_example/qwen_vl_example.py index 4f39790f4..cc1c51daa 100644 --- a/plugins/openai/examples/qwen_vl_example/qwen_vl_example.py +++ b/plugins/openai/examples/qwen_vl_example/qwen_vl_example.py @@ -3,8 +3,8 @@ from dotenv import load_dotenv from vision_agents.core import Agent, Runner, User from vision_agents.core.agents import AgentLauncher -from vision_agents.core.events import CallSessionParticipantJoinedEvent from vision_agents.plugins import deepgram, elevenlabs, getstream, openai +from vision_agents.plugins.getstream import CallSessionParticipantJoinedEvent load_dotenv() diff --git a/plugins/openai/vision_agents/plugins/openai/rtc_manager.py b/plugins/openai/vision_agents/plugins/openai/rtc_manager.py index 104dab5a9..7c85a282c 100644 --- a/plugins/openai/vision_agents/plugins/openai/rtc_manager.py +++ b/plugins/openai/vision_agents/plugins/openai/rtc_manager.py @@ -1,30 +1,26 @@ import asyncio import json -from typing import Any, Optional, Callable, cast, Literal +import logging +from typing import Any, Callable, Literal, Optional, cast import av from aiortc import ( - RTCPeerConnection, - RTCSessionDescription, RTCDataChannel, + RTCPeerConnection, RTCRtpSender, + RTCSessionDescription, ) +from aiortc.mediastreams import MediaStreamTrack +from getstream.video.rtc.audio_track import AudioStreamTrack +from getstream.video.rtc.track_util import PcmData from openai import AsyncOpenAI -from openai.types.realtime import RealtimeSessionCreateRequestParam from openai.types.beta.realtime import ( - ConversationItemCreateEvent, ConversationItem, ConversationItemContent, + ConversationItemCreateEvent, ) - -from getstream.video.rtc.audio_track import AudioStreamTrack -import logging -from getstream.video.rtc.track_util import PcmData - -from aiortc.mediastreams import MediaStreamTrack - +from openai.types.realtime import RealtimeSessionCreateRequestParam from vision_agents.core.utils.audio_forwarder import AudioForwarder -from vision_agents.core.utils.audio_track import QueuedAudioTrack from vision_agents.core.utils.video_forwarder import VideoForwarder from vision_agents.core.utils.video_track import QueuedVideoTrack @@ -55,7 +51,7 @@ def __init__( self.data_channel: Optional[RTCDataChannel] = None # tracks for sharing audio & video - self._audio_to_openai_track: QueuedAudioTrack = QueuedAudioTrack( + self._audio_to_openai_track: AudioStreamTrack = AudioStreamTrack( sample_rate=48000 ) self._video_to_openai_track: QueuedVideoTrack = QueuedVideoTrack() diff --git a/plugins/wizper/example/wizper_example.py b/plugins/wizper/example/wizper_example.py index 9f22f61eb..f65940a3d 100644 --- a/plugins/wizper/example/wizper_example.py +++ b/plugins/wizper/example/wizper_example.py @@ -44,7 +44,7 @@ async def handle_transcript(event: STTTranscriptEvent): user_info = "unknown" if event.participant: user = event.participant - user_info = user.name if user.name else str(user) + user_info = user.user_id if user.user_id else str(user) logger.info(f"[{event.timestamp}] {user_info}: {event.text}") if event.confidence: diff --git a/tests/test_agent_tracks.py b/tests/test_agent_tracks.py index 433aa4838..53443b41c 100644 --- a/tests/test_agent_tracks.py +++ b/tests/test_agent_tracks.py @@ -13,9 +13,8 @@ from unittest.mock import Mock import aiortc -from getstream.video.rtc.pb.stream.video.sfu.models.models_pb2 import TrackType from vision_agents.core.agents.agents import Agent -from vision_agents.core.edge.types import Participant, User +from vision_agents.core.edge.types import Participant, TrackType, User from vision_agents.core.llm.llm import LLM, VideoLLM from vision_agents.core.processors.base_processor import ( VideoProcessor, @@ -122,7 +121,7 @@ def add_track_subscriber(self, track_id): self.add_track_subscriber_calls.append(track_id) return MockVideoTrack(track_id) - def create_audio_track(self, framerate=48000, stereo=True): + def create_audio_track(self, sample_rate=48000, stereo=True): """Mock creating audio track""" return Mock(id="audio_track_1") @@ -182,7 +181,7 @@ async def test_regular_video_track_is_forwarded(self): await agent._on_track_added( track_id="video_track_1", - track_type=TrackType.TRACK_TYPE_VIDEO, + track_type=TrackType.VIDEO, participant=participant, ) @@ -192,7 +191,7 @@ async def test_regular_video_track_is_forwarded(self): # Verify track was added assert "video_track_1" in agent._active_video_tracks track_info = agent._active_video_tracks["video_track_1"] - assert track_info.type == TrackType.TRACK_TYPE_VIDEO + assert track_info.type == TrackType.VIDEO assert track_info.priority == 0 # Regular video has priority 0 # Verify processor received the track @@ -218,7 +217,7 @@ async def test_screenshare_takes_priority_over_video(self): # Add regular video track first await agent._on_track_added( track_id="video_track_1", - track_type=TrackType.TRACK_TYPE_VIDEO, + track_type=TrackType.VIDEO, participant=participant, ) @@ -235,7 +234,7 @@ async def test_screenshare_takes_priority_over_video(self): # Now add screenshare track await agent._on_track_added( track_id="screenshare_track_1", - track_type=TrackType.TRACK_TYPE_SCREEN_SHARE, + track_type=TrackType.SCREEN_SHARE, participant=participant, ) @@ -274,13 +273,13 @@ async def test_track_removed_updates_active_tracks(self): # Add two video tracks await agent._on_track_added( track_id="video_track_1", - track_type=TrackType.TRACK_TYPE_VIDEO, + track_type=TrackType.VIDEO, participant=participant, ) await agent._on_track_added( track_id="screenshare_track_1", - track_type=TrackType.TRACK_TYPE_SCREEN_SHARE, + track_type=TrackType.SCREEN_SHARE, participant=participant, ) @@ -295,7 +294,7 @@ async def test_track_removed_updates_active_tracks(self): # Remove screenshare track await agent._on_track_removed( track_id="screenshare_track_1", - track_type=TrackType.TRACK_TYPE_SCREEN_SHARE, + track_type=TrackType.SCREEN_SHARE, participant=participant, ) @@ -327,7 +326,7 @@ async def test_multiple_processors_all_receive_tracks(self): await agent._on_track_added( track_id="video_track_1", - track_type=TrackType.TRACK_TYPE_VIDEO, + track_type=TrackType.VIDEO, participant=participant, ) @@ -352,7 +351,7 @@ async def test_llm_receives_highest_priority_track(self): # Add regular video track await agent._on_track_added( track_id="video_track_1", - track_type=TrackType.TRACK_TYPE_VIDEO, + track_type=TrackType.VIDEO, participant=participant, ) @@ -389,7 +388,7 @@ async def test_processors_optional(self): await agent._on_track_added( track_id="video_track_1", - track_type=TrackType.TRACK_TYPE_VIDEO, + track_type=TrackType.VIDEO, participant=participant, ) @@ -412,7 +411,7 @@ async def test_non_video_llm_does_not_receive_tracks(self): await agent._on_track_added( track_id="video_track_1", - track_type=TrackType.TRACK_TYPE_VIDEO, + track_type=TrackType.VIDEO, participant=participant, ) diff --git a/tests/test_agents/test_agents.py b/tests/test_agents/test_agents.py index 37f15eb78..97546c665 100644 --- a/tests/test_agents/test_agents.py +++ b/tests/test_agents/test_agents.py @@ -1,17 +1,14 @@ import asyncio from typing import Any, Optional -from unittest.mock import AsyncMock from uuid import uuid4 import pytest -from getstream.video.rtc import Call +from getstream.video.rtc import AudioStreamTrack from vision_agents.core import Agent, User -from vision_agents.core.edge import EdgeTransport -from vision_agents.core.edge.types import OutputAudioTrack +from vision_agents.core.edge import Call, EdgeTransport from vision_agents.core.events import EventManager from vision_agents.core.llm.llm import LLM, LLMResponseEvent from vision_agents.core.tts import TTS -from vision_agents.core.utils import audio_track from vision_agents.core.warmup import Warmable @@ -51,8 +48,13 @@ def __init__( async def create_user(self, user: User): return - def create_audio_track(self, *args, **kwargs) -> OutputAudioTrack: - return audio_track.AudioStreamTrack( + async def create_call( + self, call_id: str, agent_user_id: Optional[str] = None, **kwargs + ) -> Call: + return DummyCall(call_id=call_id) + + def create_audio_track(self, *args, **kwargs) -> AudioStreamTrack: + return AudioStreamTrack( audio_buffer_size_ms=300_000, sample_rate=48000, channels=2, @@ -84,9 +86,18 @@ async def send_custom_event(self, data: dict) -> None: self.last_custom_event = data +class DummyCall(Call): + def __init__(self, call_id: str): + self._id = call_id + + @property + def id(self) -> str: + return self._id + + @pytest.fixture def call(): - return Call(call_id=str(uuid4()), call_type="default", client=AsyncMock()) + return DummyCall(call_id=str(uuid4())) class SomeException(Exception): diff --git a/tests/test_events.py b/tests/test_events.py index 85a453214..3b3392ced 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -1,7 +1,8 @@ -import pytest -import types import dataclasses +import logging +import types +import pytest from vision_agents.core.events.manager import EventManager, ExceptionEvent @@ -23,399 +24,247 @@ class AnotherEvent: type: str = "custom.anotherevent" -@pytest.mark.asyncio -async def test_register_invalid_event_raises_value_error(): - manager = EventManager() - with pytest.raises(ValueError): - manager.register(InvalidEvent) +class TestEventManager: + async def test_register_invalid_event_raises_value_error(self): + manager = EventManager() + with pytest.raises(ValueError): + manager.register(InvalidEvent) + + async def test_register_valid_event_success(self): + manager = EventManager() + manager.register(ValidEvent) + # after registration the event type should be in the internal dict + assert "custom.validevent" in manager._events + manager.register(AnotherEvent) + + async def test_register_multiple_valid_events_success(self): + manager = EventManager() + # Multiple events can be registered at once + manager.register(ValidEvent, AnotherEvent) + assert "custom.validevent" in manager._events + assert "custom.anotherevent" in manager._events + + async def test_register_events_from_module_raises_name_error(self): + manager = EventManager() + + # Create a dummy module with two event classes + dummy_module = types.SimpleNamespace( + MyEvent=ValidEvent, + Another=AnotherEvent, + ) + dummy_module.__name__ = "dummy_module" + manager.register_events_from_module(dummy_module, prefix="custom.") + @manager.subscribe + async def my_handler(event: ValidEvent): + my_handler.value = event.field -@pytest.mark.asyncio -async def test_register_valid_event_success(): - manager = EventManager() - manager.register(ValidEvent) - # after registration the event type should be in the internal dict - assert "custom.validevent" in manager._events + manager.send(ValidEvent(field=2)) + await manager.wait() + assert my_handler.value == 2 + async def test_subscribe_with_multiple_events_different(self): + manager = EventManager() + manager.register(ValidEvent) + manager.register(AnotherEvent) -@pytest.mark.asyncio -async def test_register_events_from_module_raises_name_error(): - manager = EventManager() + with pytest.raises(RuntimeError): - # Create a dummy module with two event classes - dummy_module = types.SimpleNamespace( - MyEvent=ValidEvent, - Another=AnotherEvent, - ) - dummy_module.__name__ = "dummy_module" - manager.register_events_from_module(dummy_module, prefix="custom.") + @manager.subscribe + async def multi_event_handler(event1: ValidEvent, event2: AnotherEvent): + pass - @manager.subscribe - async def my_handler(event: ValidEvent): - my_handler.value = event.field + async def test_subscribe_with_multiple_events_as_one_processes(self): + manager = EventManager() + manager.register(ValidEvent) + manager.register(AnotherEvent) + value = 0 - manager.send(ValidEvent(field=2)) - await manager.wait() - assert my_handler.value == 2 + @manager.subscribe + async def multi_event_handler(event: ValidEvent | AnotherEvent): + nonlocal value + value += 1 + manager.send(ValidEvent(field=1)) + manager.send(AnotherEvent(value="2")) + await manager.wait() -@pytest.mark.asyncio -async def test_subscribe_with_multiple_events_different(): - manager = EventManager() - manager.register(ValidEvent) - manager.register(AnotherEvent) + assert value == 2 - with pytest.raises(RuntimeError): + async def test_subscribe_unregistered_event_raises_key_error(self): + manager = EventManager(ignore_unknown_events=False) - @manager.subscribe - async def multi_event_handler(event1: ValidEvent, event2: AnotherEvent): - pass + with pytest.raises(KeyError): + @manager.subscribe + async def unknown_handler(event: ValidEvent): + pass -@pytest.mark.asyncio -async def test_subscribe_with_multiple_events_as_one_processes(): - manager = EventManager() - manager.register(ValidEvent) - manager.register(AnotherEvent) - value = 0 + async def test_handler_exception_triggers_recursive_exception_event(self): + manager = EventManager() + manager.register(ValidEvent, ignore_not_compatible=False) + manager.register(ExceptionEvent) - @manager.subscribe - async def multi_event_handler(event: ValidEvent | AnotherEvent): - nonlocal value - value += 1 + # Counter to ensure recursive handler is invoked + recursive_counter = {"count": 0} - manager.send(ValidEvent(field=1)) - manager.send(AnotherEvent(value="2")) - await manager.wait() + @manager.subscribe + async def failing_handler(event: ValidEvent): + raise RuntimeError("Intentional failure") - assert value == 2 + @manager.subscribe + async def exception_handler(event: ExceptionEvent): + # Increment the counter each time the exception handler runs + recursive_counter["count"] += 1 + # Re-raise the exception only once to trigger a second recursion + if recursive_counter["count"] == 1: + raise ValueError("Re-raising in exception handler") + + manager.send(ValidEvent(field=10)) + await manager.wait() + + # After processing, the recursive counter should be 2 (original failure + one re-raise) + assert recursive_counter["count"] == 2 + + async def test_send_unknown_event_type_raises_runtime_error(self): + manager = EventManager(ignore_unknown_events=False) + + # Define a dynamic event class that is not registered + @dataclasses.dataclass + class UnregisteredEvent: + data: str + type: str = "custom.unregistered" + + # The event will be queued but there are no handlers for its type + with pytest.raises(RuntimeError): + manager.send(UnregisteredEvent(data="oops")) + + async def test_merge_managers_events_processed_in_one(self): + """Test that when two managers are merged, events from both are processed in the merged manager.""" + # Create two separate managers + manager1 = EventManager() + manager2 = EventManager() + + # Register different events in each manager + manager1.register(ValidEvent) + manager2.register(AnotherEvent) + + # Set up handlers in each manager + all_events_processed: list[tuple[str, ValidEvent | AnotherEvent]] = [] + + @manager1.subscribe + async def manager1_handler(event: ValidEvent): + all_events_processed.append(("manager1", event)) + + @manager2.subscribe + async def manager2_handler(event: AnotherEvent): + all_events_processed.append(("manager2", event)) + + # Send events to both managers before merging + manager1.send(ValidEvent(field=1)) + manager2.send(AnotherEvent(value="test")) + + # Wait for events to be processed in their respective managers + await manager1.wait() + await manager2.wait() + # Verify events were processed in their original managers + assert len(all_events_processed) == 2 + assert all_events_processed[0][0] == "manager1" + assert isinstance(all_events_processed[0][1], ValidEvent) + assert all_events_processed[0][1].field == 1 + assert all_events_processed[1][0] == "manager2" + assert isinstance(all_events_processed[1][1], AnotherEvent) + assert all_events_processed[1][1].value == "test" -@pytest.mark.asyncio -async def test_subscribe_unregistered_event_raises_key_error(): - manager = EventManager(ignore_unknown_events=False) + # Clear the processed events list + all_events_processed.clear() - with pytest.raises(KeyError): + # Merge manager2 into manager1 + manager1.merge(manager2) - @manager.subscribe - async def unknown_handler(event: ValidEvent): - pass + # Verify that manager2's processing task is stopped + assert manager2._processing_task is None + # Send new events to both managers after merging + manager1.send(ValidEvent(field=2)) + manager2.send(AnotherEvent(value="merged")) -@pytest.mark.asyncio -async def test_handler_exception_triggers_recursive_exception_event(): - manager = EventManager() - manager.register(ValidEvent, ignore_not_compatible=False) - manager.register(ExceptionEvent) - - # Counter to ensure recursive handler is invoked - recursive_counter = {"count": 0} - - @manager.subscribe - async def failing_handler(event: ValidEvent): - raise RuntimeError("Intentional failure") - - @manager.subscribe - async def exception_handler(event: ExceptionEvent): - # Increment the counter each time the exception handler runs - recursive_counter["count"] += 1 - # Re-raise the exception only once to trigger a second recursion - if recursive_counter["count"] == 1: - raise ValueError("Re-raising in exception handler") - - manager.send(ValidEvent(field=10)) - await manager.wait() - - # After processing, the recursive counter should be 2 (original failure + one re-raise) - assert recursive_counter["count"] == 2 - - -@pytest.mark.asyncio -async def test_send_unknown_event_type_raises_key_error(): - manager = EventManager(ignore_unknown_events=False) - - # Define a dynamic event class that is not registered - @dataclasses.dataclass - class UnregisteredEvent: - data: str - type: str = "custom.unregistered" - - # The event will be queued but there are no handlers for its type - with pytest.raises(RuntimeError): - manager.send(UnregisteredEvent(data="oops")) - - -@pytest.mark.asyncio -async def test_merge_managers_events_processed_in_one(): - """Test that when two managers are merged, events from both are processed in the merged manager.""" - # Create two separate managers - manager1 = EventManager() - manager2 = EventManager() - - # Register different events in each manager - manager1.register(ValidEvent) - manager2.register(AnotherEvent) - - # Set up handlers in each manager - all_events_processed: list[tuple[str, ValidEvent | AnotherEvent]] = [] - - @manager1.subscribe - async def manager1_handler(event: ValidEvent): - all_events_processed.append(("manager1", event)) - - @manager2.subscribe - async def manager2_handler(event: AnotherEvent): - all_events_processed.append(("manager2", event)) - - # Send events to both managers before merging - manager1.send(ValidEvent(field=1)) - manager2.send(AnotherEvent(value="test")) - - # Wait for events to be processed in their respective managers - await manager1.wait() - await manager2.wait() - - # Verify events were processed in their original managers - assert len(all_events_processed) == 2 - assert all_events_processed[0][0] == "manager1" - assert isinstance(all_events_processed[0][1], ValidEvent) - assert all_events_processed[0][1].field == 1 - assert all_events_processed[1][0] == "manager2" - assert isinstance(all_events_processed[1][1], AnotherEvent) - assert all_events_processed[1][1].value == "test" - - # Clear the processed events list - all_events_processed.clear() - - # Merge manager2 into manager1 - manager1.merge(manager2) - - # Verify that manager2's processing task is stopped - assert manager2._processing_task is None - - # Send new events to both managers after merging - manager1.send(ValidEvent(field=2)) - manager2.send(AnotherEvent(value="merged")) - - # Wait for events to be processed (only manager1's task should be running) - await manager1.wait() - - # After merging, both events should be processed by manager1's task - # (manager2's processing task should be stopped) - assert len(all_events_processed) == 2 - # Both events should be processed by manager1's task - assert all_events_processed[0][0] == "manager1" # ValidEvent - assert isinstance(all_events_processed[0][1], ValidEvent) - assert all_events_processed[0][1].field == 2 - assert ( - all_events_processed[1][0] == "manager2" - ) # AnotherEvent (handler from manager2) - assert isinstance(all_events_processed[1][1], AnotherEvent) - assert all_events_processed[1][1].value == "merged" - - # Verify that manager2 can still send events but they go to manager1's queue - # and are processed by manager1's task - all_events_processed.clear() - manager2.send(AnotherEvent(value="from_manager2")) - await manager1.wait() - - # The event from manager2 should be processed by manager1's task - assert len(all_events_processed) == 1 - assert all_events_processed[0][0] == "manager2" # Handler from manager2 - assert isinstance(all_events_processed[0][1], AnotherEvent) - assert all_events_processed[0][1].value == "from_manager2" - - -@pytest.mark.asyncio -async def test_merge_managers_preserves_silent_events(caplog): - """Test that when two managers are merged, silent events from both are preserved.""" - import logging - - manager1 = EventManager() - manager2 = EventManager() - - manager1.register(ValidEvent) - manager2.register(AnotherEvent) - - # Mark ValidEvent as silent in manager1 - manager1.silent(ValidEvent) - # Mark AnotherEvent as silent in manager2 - manager2.silent(AnotherEvent) - - handler_called = [] - - @manager1.subscribe - async def valid_handler(event: ValidEvent): - handler_called.append("valid") - - @manager2.subscribe - async def another_handler(event: AnotherEvent): - handler_called.append("another") - - # Merge manager2 into manager1 - manager1.merge(manager2) - - # Verify that both silent events are preserved - assert "custom.validevent" in manager1._silent_events - assert "custom.anotherevent" in manager1._silent_events - - # Verify that manager2 also references the merged silent events - assert manager2._silent_events is manager1._silent_events - - # Capture logs at INFO level - with caplog.at_level(logging.INFO): - # Send both events - manager1.send(ValidEvent(field=42)) - manager1.send(AnotherEvent(value="test")) + # Wait for events to be processed (only manager1's task should be running) await manager1.wait() - # Both handlers should have been called - assert handler_called == ["valid", "another"] - - # Check log messages - log_messages = [record.message for record in caplog.records] - - # Should NOT see "Called handler" for either event (both are silent) - assert not any("Called handler valid_handler" in msg for msg in log_messages) - assert not any("Called handler another_handler" in msg for msg in log_messages) - - -@pytest.mark.asyncio -@pytest.mark.integration -async def test_protobuf_events_with_base_event(): - """Test that event manager handles protobuf events that inherit from BaseEvent.""" - from vision_agents.core.events.manager import EventManager - from vision_agents.core.edge.sfu_events import ( - AudioLevelEvent, - ParticipantJoinedEvent, - ) - from getstream.video.rtc.pb.stream.video.sfu.event import events_pb2 - from getstream.video.rtc.pb.stream.video.sfu.models import models_pb2 - - manager = EventManager() - - # Register generated protobuf event classes - manager.register(AudioLevelEvent) - manager.register(ParticipantJoinedEvent) - - assert AudioLevelEvent.type in manager._events - assert ParticipantJoinedEvent.type in manager._events - - # Test 1: Send wrapped protobuf event with BaseEvent fields - proto_audio = events_pb2.AudioLevel(user_id="user123", level=0.85, is_speaking=True) - wrapped_event = AudioLevelEvent.from_proto(proto_audio, session_id="session123") - - received_audio_events = [] - - @manager.subscribe - async def handle_audio(event: AudioLevelEvent): - received_audio_events.append(event) - - manager.send(wrapped_event) - await manager.wait() - - assert len(received_audio_events) == 1 - assert received_audio_events[0].user_id == "user123" - assert received_audio_events[0].session_id == "session123" - assert received_audio_events[0].is_speaking is True - assert received_audio_events[0].level is not None - assert abs(received_audio_events[0].level - 0.85) < 0.01 - assert hasattr(received_audio_events[0], "event_id") - assert hasattr(received_audio_events[0], "timestamp") - - # Test 2: Send raw protobuf message (auto-wrapped) - proto_raw = events_pb2.AudioLevel(user_id="user456", level=0.95, is_speaking=False) - - received_audio_events.clear() - manager.send(proto_raw) - await manager.wait() - - assert len(received_audio_events) == 1 - assert received_audio_events[0].user_id == "user456" - assert received_audio_events[0].level is not None - assert abs(received_audio_events[0].level - 0.95) < 0.01 - assert received_audio_events[0].is_speaking is False - assert hasattr(received_audio_events[0], "event_id") - - # Test 3: Create event without protobuf payload (all fields optional) - empty_event = AudioLevelEvent() - assert empty_event.payload is None - assert empty_event.user_id is None - assert empty_event.event_id is not None - - # Test 4: Multiple protobuf event types - received_participant_events = [] - - @manager.subscribe - async def handle_participant(event: ParticipantJoinedEvent): - received_participant_events.append(event) - - participant = models_pb2.Participant(user_id="user789", session_id="sess456") - proto_participant = events_pb2.ParticipantJoined( - call_cid="call123", participant=participant - ) - - manager.send(proto_participant) - await manager.wait() - - assert len(received_participant_events) == 1 - assert received_participant_events[0].call_cid == "call123" - assert received_participant_events[0].participant is not None - assert hasattr(received_participant_events[0], "event_id") - - -@pytest.mark.asyncio -@pytest.mark.integration -async def test_track_published_event_with_participant_property(): - """Test that TrackPublishedEvent correctly handles participant property override.""" - from vision_agents.core.events.manager import EventManager - from vision_agents.core.edge.sfu_events import ( - TrackPublishedEvent, - TrackUnpublishedEvent, - ) - from getstream.video.rtc.pb.stream.video.sfu.event import events_pb2 - from getstream.video.rtc.pb.stream.video.sfu.models import models_pb2 - - manager = EventManager() - - # Register events that override participant field with property - manager.register(TrackPublishedEvent) - manager.register(TrackUnpublishedEvent) - - # Test TrackPublishedEvent - participant = models_pb2.Participant(user_id="user123", session_id="session456") - proto_published = events_pb2.TrackPublished( - user_id="user123", participant=participant - ) - - # This should NOT raise "AttributeError: property 'participant' of 'TrackPublishedEvent' object has no setter" - TrackPublishedEvent.from_proto(proto_published) - - received_events = [] - - @manager.subscribe - async def handle_published(event: TrackPublishedEvent): - received_events.append(event) - - # Send raw protobuf message (auto-wrapped by manager) - manager.send(proto_published) - await manager.wait() - - assert len(received_events) == 1 - assert received_events[0].user_id == "user123" - # Verify participant property returns correct value from protobuf payload - assert received_events[0].participant is not None - assert received_events[0].participant.user_id == "user123" - assert received_events[0].participant.session_id == "session456" - assert hasattr(received_events[0], "event_id") - - # Test TrackUnpublishedEvent - proto_unpublished = events_pb2.TrackUnpublished( - user_id="user456", participant=participant, cause=1 - ) - - unpublished_event = TrackUnpublishedEvent.from_proto(proto_unpublished) - assert unpublished_event.user_id == "user456" - assert unpublished_event.participant is not None - assert unpublished_event.participant.user_id == "user123" - assert unpublished_event.cause == 1 + # After merging, both events should be processed by manager1's task + # (manager2's processing task should be stopped) + assert len(all_events_processed) == 2 + # Both events should be processed by manager1's task + assert all_events_processed[0][0] == "manager1" # ValidEvent + assert isinstance(all_events_processed[0][1], ValidEvent) + assert all_events_processed[0][1].field == 2 + assert ( + all_events_processed[1][0] == "manager2" + ) # AnotherEvent (handler from manager2) + assert isinstance(all_events_processed[1][1], AnotherEvent) + assert all_events_processed[1][1].value == "merged" + + # Verify that manager2 can still send events but they go to manager1's queue + # and are processed by manager1's task + all_events_processed.clear() + manager2.send(AnotherEvent(value="from_manager2")) + await manager1.wait() + + # The event from manager2 should be processed by manager1's task + assert len(all_events_processed) == 1 + assert all_events_processed[0][0] == "manager2" # Handler from manager2 + assert isinstance(all_events_processed[0][1], AnotherEvent) + assert all_events_processed[0][1].value == "from_manager2" + + async def test_merge_managers_preserves_silent_events(self, caplog): + """Test that when two managers are merged, silent events from both are preserved.""" + + manager1 = EventManager() + manager2 = EventManager() + + manager1.register(ValidEvent) + manager2.register(AnotherEvent) + + # Mark ValidEvent as silent in manager1 + manager1.silent(ValidEvent) + # Mark AnotherEvent as silent in manager2 + manager2.silent(AnotherEvent) + + handler_called = [] + + @manager1.subscribe + async def valid_handler(event: ValidEvent): + handler_called.append("valid") + + @manager2.subscribe + async def another_handler(event: AnotherEvent): + handler_called.append("another") + + # Merge manager2 into manager1 + manager1.merge(manager2) + + # Verify that both silent events are preserved + assert "custom.validevent" in manager1._silent_events + assert "custom.anotherevent" in manager1._silent_events + + # Verify that manager2 also references the merged silent events + assert manager2._silent_events is manager1._silent_events + + # Capture logs at INFO level + with caplog.at_level(logging.INFO): + # Send both events + manager1.send(ValidEvent(field=42)) + manager1.send(AnotherEvent(value="test")) + await manager1.wait() + + # Both handlers should have been called + assert handler_called == ["valid", "another"] + + # Check log messages + log_messages = [record.message for record in caplog.records] + + # Should NOT see "Called handler" for either event (both are silent) + assert not any("Called handler valid_handler" in msg for msg in log_messages) + assert not any("Called handler another_handler" in msg for msg in log_messages) diff --git a/uv.lock b/uv.lock index f3b8f8dc4..d936042d9 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -1655,7 +1655,7 @@ wheels = [ [[package]] name = "getstream" -version = "2.5.21" +version = "2.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dataclasses-json" }, @@ -1670,9 +1670,9 @@ dependencies = [ { name = "python-dateutil" }, { name = "twirp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/be/4998f63f4872ab5e4b8c30602af05d154669e9f095b9cf3a66aee533272a/getstream-2.5.21.tar.gz", hash = "sha256:cefa559e11c76179e7a2169b13550d04a54e9239fd6e963412426da3a04df076", size = 449483, upload-time = "2026-01-12T21:07:14.921Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/8f/4a9eba635964894bb373dc7ad90370720be37c2da16ff1c3578fea057d61/getstream-2.7.1.tar.gz", hash = "sha256:cf78bb0c52ed732e447ee59446ce1e3b999e146886ad73cbf799e2dcbe8d01ed", size = 445595, upload-time = "2026-01-30T17:40:17.486Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/9f/b5a5d4beabfb2ce4ce138663d75b47dede622fc5c5156467065ce2f04f5f/getstream-2.5.21-py3-none-any.whl", hash = "sha256:0181bda206ed725d6fcd372dd763eea3b702b645a842c2fc26c79539cf295a95", size = 260055, upload-time = "2026-01-12T21:07:13.573Z" }, + { url = "https://files.pythonhosted.org/packages/40/ad/a71eeb8c6dda09eb93f0e1f41d331a9a1d9f867b296996a3b8b0b7bfda8f/getstream-2.7.1-py3-none-any.whl", hash = "sha256:7465eb344b0f1f422088f259b593b460dc3bc88150c2ade1f1497bb4a3ee1457", size = 275293, upload-time = "2026-01-30T17:40:18.563Z" }, ] [package.optional-dependencies] @@ -2650,6 +2650,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/a2/4f639c1168d7aada749a896afb4892a831e2041bebdcf636aebfe9e86556/librosa-0.10.1-py3-none-any.whl", hash = "sha256:7ab91d9f5fcb75ea14848a05d3b1f825cf8d0c42ca160d19ae6874f2de2d8223", size = 253710, upload-time = "2023-08-16T13:52:19.141Z" }, ] +[[package]] +name = "librt" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" }, + { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" }, + { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" }, + { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" }, + { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" }, + { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" }, + { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" }, + { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" }, + { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" }, + { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" }, + { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" }, + { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" }, + { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" }, + { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" }, + { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" }, + { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, + { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, + { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, +] + [[package]] name = "llvmlite" version = "0.45.1" @@ -3139,34 +3191,35 @@ wheels = [ [[package]] name = "mypy" -version = "1.18.2" +version = "1.19.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, - { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, - { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, - { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, - { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, - { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, - { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, - { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, - { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, - { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, - { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, - { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, - { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, - { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, - { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, ] [package.optional-dependencies]