From 15e11a874f25623dceb309321f96c16bd35d96d4 Mon Sep 17 00:00:00 2001 From: Daniil Gusev Date: Sun, 4 Jan 2026 23:18:08 +0100 Subject: [PATCH 01/13] Agent: Cleanup obsolete code --- .../vision_agents/core/agents/agents.py | 65 ++++--------------- 1 file changed, 11 insertions(+), 54 deletions(-) diff --git a/agents-core/vision_agents/core/agents/agents.py b/agents-core/vision_agents/core/agents/agents.py index 232e751a4..652c7fc9f 100644 --- a/agents-core/vision_agents/core/agents/agents.py +++ b/agents-core/vision_agents/core/agents/agents.py @@ -132,9 +132,15 @@ def __init__( tracer: Tracer = trace.get_tracer("agents"), profiler: Optional[Profiler] = None, ): + self._agent_user_initialized = False + self.agent_user = agent_user + if not self.agent_user.id: + self.agent_user.id = f"agent-{uuid4()}" + self._pending_turn: Optional[LLMTurn] = None self.participants: Optional[ParticipantsState] = None - self.call = None + self.call: Optional[Call] = None + self._active_processed_track_id: Optional[str] = None self._active_source_track_id: Optional[str] = None if options is None: @@ -148,8 +154,6 @@ def __init__( self.instructions = Instructions(input_text=instructions) self.edge = edge - self.agent_user = agent_user - self._agent_user_initialized = False # only needed in case we spin threads self.tracer = tracer @@ -203,18 +207,14 @@ def __init__( processor.attach_agent(self) self.events.subscribe(self._on_agent_say) - # Initialize state variables - self._is_running: bool = False - self._current_frame = None - self._interval_task = None - self._callback_executed = False - self._track_tasks: Dict[str, asyncio.Task] = {} + # An event to detect if the call was ended. + # `None` means the call is ended, or it hasn't started yet. + self._call_ended_event: Optional[asyncio.Event] = None # Track metadata: track_id -> TrackInfo self._active_video_tracks: Dict[str, TrackInfo] = {} self._video_forwarders: List[VideoForwarder] = [] - self._current_video_track_id: Optional[str] = None - self._connection: Optional[Connection] = None + self._connection: Optional[StreamConnection] = None # Optional local video track override for debugging. # This track will play instead of any incoming video track. @@ -226,8 +226,6 @@ def __init__( # the outgoing video track self._video_track: Optional[VideoStreamTrack] = None - self._realtime_connection = None - self._pc_track_handler_attached: bool = False self._audio_consumer_task: Optional[asyncio.Task] = None # validation time @@ -590,21 +588,6 @@ async def finish(self): ) return - running_event = asyncio.Event() - with self.span("agent.finish"): - # If connection is None or already closed, return immediately - if not self._connection: - logging.info( - "🔚 Agent connection already closed, finishing immediately" - ) - return - - @self.edge.events.subscribe - async def on_ended(event: CallEndedEvent): - running_event.set() - self._is_running = False - # TODO: add members count check (particiapnts left + count = 1 timeout 2 minutes) - try: await running_event.wait() except asyncio.CancelledError: @@ -653,9 +636,6 @@ def _end_tracing(self): otel_context.detach(self._context_token) self._context_token = None - def __aexit__(self, exc_type, exc_val, exc_tb): - self._end_tracing() - async def close(self): """Clean up all connections and resources. @@ -701,15 +681,6 @@ async def _stop(self): self.logger.error(f"Error stopping video forwarder: {e}") self._video_forwarders.clear() - # Close Realtime connection - if self._realtime_connection: - await self._realtime_connection.__aexit__(None, None, None) - self._realtime_connection = None - - # shutdown task processing - for _, track in self._track_tasks.items(): - track.cancel() - # Close RTC connection if self._connection: await self._connection.close() @@ -725,11 +696,6 @@ async def _stop(self): self._video_track.stop() self._video_track = None - # Cancel interval task - if self._interval_task: - self._interval_task.cancel() - self._interval_task = None - # ------------------------------------------------------------------ # Logging context helpers # ------------------------------------------------------------------ @@ -754,8 +720,6 @@ async def create_user(self) -> None: return None with self.span("edge.create_user"): - if not self.agent_user.id: - self.agent_user.id = f"agent-{uuid4()}" await self.edge.create_user(self.agent_user) self._agent_user_initialized = True @@ -1313,13 +1277,6 @@ def _sanitize_text(self, text: str) -> str: """Remove markdown and special characters that don't speak well.""" return text.replace("*", "").replace("#", "") - def _truncate_for_logging(self, obj, max_length=200): - """Truncate object string representation for logging to prevent spam.""" - obj_str = str(obj) - if len(obj_str) > max_length: - obj_str = obj_str[:max_length] + "... (truncated)" - return obj_str - async def _get_video_track_override(self) -> VideoFileTrack: """ Create a video track override in async way if the path is set. From c9d8d2e03b216eda1e1cd7e5dabf8a38810af5ab Mon Sep 17 00:00:00 2001 From: Daniil Gusev Date: Mon, 5 Jan 2026 13:30:13 +0100 Subject: [PATCH 02/13] Agent: move `wait_for_participant` to `StreamConnection` --- .../vision_agents/core/agents/agents.py | 53 ++++-- .../tests/test_stream_edge_transport.py | 90 ++++++++++ .../getstream/stream_edge_transport.py | 52 +++++- tests/test_agent.py | 170 ------------------ 4 files changed, 170 insertions(+), 195 deletions(-) create mode 100644 plugins/getstream/tests/test_stream_edge_transport.py delete mode 100644 tests/test_agent.py diff --git a/agents-core/vision_agents/core/agents/agents.py b/agents-core/vision_agents/core/agents/agents.py index 652c7fc9f..5dd5217cb 100644 --- a/agents-core/vision_agents/core/agents/agents.py +++ b/agents-core/vision_agents/core/agents/agents.py @@ -12,7 +12,6 @@ import getstream.models from aiortc import VideoStreamTrack from getstream.video.rtc import Call -from getstream.video.rtc.participants import ParticipantsState from getstream.video.rtc.pb.stream.video.sfu.models.models_pb2 import TrackType from opentelemetry import context as otel_context from opentelemetry import trace @@ -27,7 +26,7 @@ TrackAddedEvent, TrackRemovedEvent, ) -from ..edge.types import Connection, OutputAudioTrack, Participant, PcmData, User +from ..edge.types import OutputAudioTrack, Participant, PcmData, User from ..events.manager import EventManager from ..instructions import Instructions from ..llm import events as llm_events @@ -70,7 +69,10 @@ from .transcript_buffer import TranscriptBuffer if TYPE_CHECKING: - from vision_agents.plugins.getstream.stream_edge_transport import StreamEdge + from vision_agents.plugins.getstream.stream_edge_transport import ( + StreamConnection, + StreamEdge, + ) logger = logging.getLogger(__name__) @@ -138,7 +140,6 @@ def __init__( self.agent_user.id = f"agent-{uuid4()}" self._pending_turn: Optional[LLMTurn] = None - self.participants: Optional[ParticipantsState] = None self.call: Optional[Call] = None self._active_processed_track_id: Optional[str] = None @@ -309,6 +310,13 @@ 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): + if self._call_ended_event is not None: + self._call_ended_event.set() + + await self.close() + @self.events.subscribe async def on_stt_transcript_event_create_response( event: STTTranscriptEvent | STTPartialTranscriptEvent, @@ -552,30 +560,37 @@ async def join( self.llm.set_conversation(self.conversation) if wait_for_participant: - self.logger.info("Agent is ready, waiting for participant to join") await self.wait_for_participant() return AgentSessionContextManager(self, self._connection) - async def wait_for_participant(self): - """wait for a participant other than the AI agent to join""" - - if self.participants is None: + async def wait_for_participant(self, timeout: Optional[float] = None) -> None: + """ + Wait for a participant other than the AI agent to join + """ + if self._connection is None: return - participant_joined = asyncio.Event() + self.logger.info("Waiting for other participants to join") + try: + await self._connection.wait_for_participant(timeout=timeout) + except asyncio.TimeoutError: + self.logger.info( + f"No participants joined after {timeout}s timeout, proceeding." + ) - def on_participants(participants): - for p in participants: - if p.user_id != self.agent_user.id: - participant_joined.set() + def idle_for(self) -> float: + """ + Return the idle time for this connection if there is no other participants except the agent itself. + `0.0` means that connection is active. - subscription = self.participants.map(on_participants) + Returns: + idle time for this connection or 0.0 + """ + if self._connection is None: + return 0.0 - try: - await participant_joined.wait() - finally: - subscription.unsubscribe() + return self._connection.idle_for() async def finish(self): """Wait for the call to end gracefully. diff --git a/plugins/getstream/tests/test_stream_edge_transport.py b/plugins/getstream/tests/test_stream_edge_transport.py new file mode 100644 index 000000000..e6844d2c3 --- /dev/null +++ b/plugins/getstream/tests/test_stream_edge_transport.py @@ -0,0 +1,90 @@ +import asyncio +import time +from unittest.mock import Mock +from uuid import uuid4 + +import pytest +from getstream.video.rtc import ConnectionManager +from getstream.video.rtc.pb.stream.video.sfu.models.models_pb2 import Participant +from vision_agents.plugins.getstream.stream_edge_transport import StreamConnection + + +@pytest.fixture +def connection_manager(): + return ConnectionManager(user_id=str(uuid4()), call=Mock()) + + +class TestStreamConnection: + def test_idle_for(self, connection_manager): + # No participants, connection is idle + conn = StreamConnection(connection=connection_manager) + time.sleep(0.01) + assert conn.idle_for() > 0 + + # One participant (itself), still idle + connection_manager.participants_state._add_participant( + Participant(user_id=str(connection_manager.user_id)) + ) + time.sleep(0.01) + assert conn.idle_for() > 0 + + # A participant joined, not idle anymore + another_participant = Participant(user_id="another-user-id") + connection_manager.participants_state._add_participant(another_participant) + time.sleep(0.01) + assert not conn.idle_for() + + # A participant left, idle again + connection_manager.participants_state._remove_participant(another_participant) + time.sleep(0.01) + assert conn.idle_for() > 0 + + async def test_wait_for_participant_already_present(self, connection_manager): + """Test that wait_for_participant returns immediately if participant already in call""" + + conn = StreamConnection(connection_manager) + # Add a non-agent participant to the call + participant = Participant(user_id="user-1", session_id="session-1") + connection_manager.participants_state._add_participant(participant) + + # This should return immediately without waiting + await asyncio.wait_for(conn.wait_for_participant(), timeout=1.0) + + async def test_wait_for_participant_agent_doesnt_count(self, connection_manager): + """ + Test that the agent itself in the call doesn't satisfy wait_for_participant + """ + conn = StreamConnection(connection_manager) + # Add only the agent to the call + agent_participant = Participant( + user_id=connection_manager.user_id, session_id="session-1" + ) + connection_manager.participants_state._add_participant(agent_participant) + + # This should timeout since only agent is present + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(conn.wait_for_participant(timeout=2.0), timeout=0.5) + + async def test_wait_for_participant_event_triggered(self, connection_manager): + """Test that wait_for_participant completes when a participant joins""" + # No participants present initially (participants list is empty by default) + conn = StreamConnection(connection_manager) + + # Create a task to wait for participant + wait_task = asyncio.create_task(conn.wait_for_participant()) + + # Give it a moment to set up the event handler + await asyncio.sleep(0.1) + + # Task should be waiting + assert not wait_task.done() + + # Add a participant to simulate someone joining + participant = Participant(user_id="user-1", session_id="session-1") + connection_manager.participants_state._add_participant(participant) + + # Give it a moment to process + await asyncio.sleep(0.05) + + # Wait task should complete now + await asyncio.wait_for(wait_task, timeout=1.0) 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 c86b9fe1a..2e46334d3 100644 --- a/plugins/getstream/vision_agents/plugins/getstream/stream_edge_transport.py +++ b/plugins/getstream/vision_agents/plugins/getstream/stream_edge_transport.py @@ -1,9 +1,10 @@ +import asyncio import datetime import logging -import asyncio import os +import time import webbrowser -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from urllib.parse import urlencode import aiortc @@ -15,17 +16,17 @@ from getstream.video.rtc import ConnectionManager, audio_track from getstream.video.rtc.participants import ParticipantsState from getstream.video.rtc.pb.stream.video.sfu.models.models_pb2 import ( + Participant, TrackType, ) 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, sfu_events -from vision_agents.plugins.getstream.stream_conversation import StreamConversation -from vision_agents.core.edge.types import Connection, User, OutputAudioTrack +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 events from vision_agents.core.utils import get_vision_agents_version +from vision_agents.plugins.getstream.stream_conversation import StreamConversation if TYPE_CHECKING: from vision_agents.core.agents.agents import Agent @@ -38,11 +39,35 @@ def __init__(self, connection: ConnectionManager): super().__init__() # store the native connection object self._connection = connection + self._idle_since = 0 + self._participant_joined = asyncio.Event() + # Subscribe to participants changes for this connection + self._subscription = self._connection.participants_state.map( + self._on_participant_change + ) @property def participants(self) -> ParticipantsState: return self._connection.participants_state + def idle_for(self) -> float: + """ + Return the idle time for this connection if there is no other participants except the agent itself. + `0.0` means that connection is active. + + Returns: + idle time for this connection or 0. + """ + if self._idle_since: + return time.time() - self._idle_since + return 0.0 + + async def wait_for_participant(self, timeout: Optional[float] = None) -> None: + """ + Wait for at least one participant other than the agent to join. + """ + await asyncio.wait_for(self._participant_joined.wait(), timeout=timeout) + async def close(self, timeout: float = 2.0): try: await asyncio.wait_for(self._connection.leave(), timeout=timeout) @@ -56,6 +81,21 @@ 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: + # Get all participants except the agent itself. + other_participants = [ + p for p in participants if p.user_id != self._connection.user_id + ] + if other_participants: + # Some participants detected. + # Reset the idleness timeout back to zero. + self._idle_since = 0 + # Resolve the participant joined event + self._participant_joined.set() + elif not self._idle_since: + # No participants left, register the time the connection became idle if it's not set. + self._idle_since = time.time() + class StreamEdge(EdgeTransport): """ diff --git a/tests/test_agent.py b/tests/test_agent.py deleted file mode 100644 index 7809c6438..000000000 --- a/tests/test_agent.py +++ /dev/null @@ -1,170 +0,0 @@ -""" -Test suite for Agent core functionality. - -Tests cover: -- wait_for_participant method -""" - -import asyncio -from unittest.mock import Mock -import pytest - -from getstream.video.rtc.participants import ParticipantsState -from vision_agents.core.agents.agents import Agent -from vision_agents.core.edge.types import User -from vision_agents.core.edge.sfu_events import Participant -from vision_agents.core.llm.llm import LLM -from vision_agents.core.stt.stt import STT - - -class MockLLM(LLM): - """Mock LLM for testing""" - - async def simple_response(self, text: str, processors=None, participant=None): - """Mock simple_response""" - return Mock(text="mock response", original={}) - - def _attach_agent(self, agent): - """Mock attach agent""" - pass - - -class MockSTT(STT): - """Mock STT for testing""" - - async def process_audio(self, pcm, participant): - """Mock process_audio""" - pass - - def set_output_format(self, sample_rate, channels): - """Mock set_output_format""" - pass - - -class MockEdge: - """Mock edge transport for testing""" - - def __init__(self): - from vision_agents.core.events.manager import EventManager - - self.events = EventManager() - self.client = Mock() - - async def create_user(self, user): - """Mock create user""" - pass - - def create_audio_track(self, framerate=48000, stereo=True): - """Mock creating audio track""" - return Mock(id="audio_track_1") - - -class TestAgentWaitForParticipant: - """Test suite for Agent wait_for_participant logic""" - - def create_mock_agent(self, llm=None): - """Helper to create a mock agent with minimal setup""" - if llm is None: - llm = MockLLM() - - edge = MockEdge() - agent_user = User(id="test-agent", name="Test Agent") - - # Create agent with minimal config (need STT for validation) - agent = Agent( - edge=edge, - llm=llm, - agent_user=agent_user, - instructions="Test instructions", - stt=MockSTT(), - ) - - # Set up call and participants state - agent.call = Mock(id="test-call") - - # Create a mock ParticipantsState with the needed behavior - mock_participants = Mock(spec=ParticipantsState) - mock_participants._participants = [] - mock_participants._callbacks = [] - - def mock_map(callback): - """Mock the map method to store callback and call it with current participants""" - # Store callback for later updates - mock_participants._callbacks.append(callback) - # Call immediately with current state - callback(mock_participants._participants) - - subscription = Mock() - subscription.unsubscribe = Mock( - side_effect=lambda: mock_participants._callbacks.remove(callback) - ) - return subscription - - def trigger_update(): - """Helper to trigger all callbacks with current participants""" - for cb in mock_participants._callbacks: - cb(mock_participants._participants) - - mock_participants.map = mock_map - mock_participants.trigger_update = trigger_update - agent.participants = mock_participants - - return agent - - @pytest.mark.asyncio - async def test_wait_for_participant_already_present(self): - """Test that wait_for_participant returns immediately if participant already in call""" - agent = self.create_mock_agent() - - # Add a non-agent participant to the call - participant = Participant(user_id="user-1", session_id="session-1") - agent.participants._participants.append(participant) - - # This should return immediately without waiting - await asyncio.wait_for(agent.wait_for_participant(), timeout=1.0) - - # Test passes if we didn't timeout - - @pytest.mark.asyncio - async def test_wait_for_participant_agent_doesnt_count(self): - """Test that the agent itself in the call doesn't satisfy wait_for_participant""" - agent = self.create_mock_agent() - - # Add only the agent to the call - agent_participant = Participant( - user_id=agent.agent_user.id, session_id="agent-session" - ) - agent.participants._participants.append(agent_participant) - - # This should timeout since only agent is present - with pytest.raises(asyncio.TimeoutError): - await asyncio.wait_for(agent.wait_for_participant(), timeout=0.5) - - @pytest.mark.asyncio - async def test_wait_for_participant_event_triggered(self): - """Test that wait_for_participant completes when a participant joins""" - agent = self.create_mock_agent() - - # No participants present initially (participants list is empty by default) - - # Create a task to wait for participant - wait_task = asyncio.create_task(agent.wait_for_participant()) - - # Give it a moment to set up the event handler - await asyncio.sleep(0.1) - - # Task should be waiting - assert not wait_task.done() - - # Add a participant to simulate someone joining - new_participant = Participant(user_id="user-1", session_id="session-1") - agent.participants._participants.append(new_participant) - - # Trigger the participants update to notify subscribers - agent.participants.trigger_update() - - # Give it a moment to process - await asyncio.sleep(0.05) - - # Wait task should complete now - await asyncio.wait_for(wait_task, timeout=1.0) From 82e4e96356da00c7c6228cf6b1a1d3745a396c16 Mon Sep 17 00:00:00 2001 From: Daniil Gusev Date: Mon, 5 Jan 2026 13:32:28 +0100 Subject: [PATCH 03/13] Agent: cleanup on_track_added/removed handlers --- agents-core/vision_agents/core/agents/agents.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/agents-core/vision_agents/core/agents/agents.py b/agents-core/vision_agents/core/agents/agents.py index 5dd5217cb..c19b7789b 100644 --- a/agents-core/vision_agents/core/agents/agents.py +++ b/agents-core/vision_agents/core/agents/agents.py @@ -925,13 +925,20 @@ async def _track_to_video_processors(self, track: TrackInfo): async def _on_track_removed( self, track_id: str, track_type: int, 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, + ): + return track_type_name = ( "SCREEN_SHARE" if track_type == TrackType.TRACK_TYPE_SCREEN_SHARE else "VIDEO" ) - user_id = participant.user_id if participant else "unknown" - self.logger.info(f"📺 Track removed: {track_type_name} from {user_id}") + self.logger.info( + f"📺 Track removed: {track_type_name} from {participant.user_id}" + ) track = self._active_video_tracks.pop(track_id, None) if track is not None: @@ -987,8 +994,9 @@ async def _on_track_added( if track_type == TrackType.TRACK_TYPE_SCREEN_SHARE else "VIDEO" ) - user_id = participant.user_id if participant else "unknown" - self.logger.info(f"📺 Track added: {track_type_name} from {user_id}") + self.logger.info( + f"📺 Track added: {track_type_name} from {participant.user_id}" + ) if self._video_track_override_path is not None: # If local video track is set, we override all other video tracks with it. From 95db7fa34bff55f69d40ed4f4d5d86596e1132fa Mon Sep 17 00:00:00 2001 From: Daniil Gusev Date: Mon, 5 Jan 2026 15:41:04 +0100 Subject: [PATCH 04/13] Agent: update `finish()` to depend on a stored asyncio.Event() --- .../vision_agents/core/agents/agents.py | 109 +++++++++--------- 1 file changed, 54 insertions(+), 55 deletions(-) diff --git a/agents-core/vision_agents/core/agents/agents.py b/agents-core/vision_agents/core/agents/agents.py index c19b7789b..c69c9e42f 100644 --- a/agents-core/vision_agents/core/agents/agents.py +++ b/agents-core/vision_agents/core/agents/agents.py @@ -238,6 +238,9 @@ def __init__( self.events.send(events.AgentInitEvent()) + self._close_lock = asyncio.Lock() + self._closed = False + async def _finish_llm_turn(self): if self._pending_turn is None or self._pending_turn.response is None: raise ValueError( @@ -494,32 +497,22 @@ def subscribe(self, function): async def join( self, call: Call, wait_for_participant=True ) -> "AgentSessionContextManager": - # TODO: validation. join can only be called once - self.logger.info("joining call") - # run start on all subclasses - await self._apply("start") - self._start_tracing() - - if self._root_span: - self._root_span.set_attribute("call_id", call.id) - if self.agent_user.id: - self._root_span.set_attribute("agent_id", self.agent_user.id) - - if self._is_running: - raise RuntimeError("Agent is already running") - - await self.create_user() - + if self._call_ended_event is not None: + raise RuntimeError("Agent already joined the call") + self._call_ended_event = asyncio.Event() self.call = call self.conversation = None + self._start_tracing(call) # Ensure all subsequent logs include the call context. self._set_call_logging_context(call.id) - # Setup chat and connect it to transcript events (we'll wait at the end) - create_conversation_coro = self.edge.create_conversation( - call, self.agent_user, self.instructions.full_reference - ) + # run start on all subclasses + await self._apply("start") + + await self.create_user() + if self.agent_user.id: + self._root_span.set_attribute("agent_id", self.agent_user.id) try: # Connect to MCP servers if manager is available @@ -534,14 +527,12 @@ async def join( with self.span("edge.join"): connection = await self.edge.join(self, call) - self.participants = connection.participants except Exception: self.clear_call_logging_context() raise self._connection = connection - self._is_running = True self._audio_consumer_task = asyncio.create_task(self._consume_incoming_audio()) self.logger.info(f"🤖 Agent joined call: {call.id}") @@ -554,8 +545,11 @@ async def join( with self.span("edge.publish_tracks"): await self.edge.publish_tracks(audio_track, video_track) - # wait for conversation creation coro at the very end of the join flow - self.conversation = await create_conversation_coro + # Setup chat and connect it to transcript events + self.conversation = await self.edge.create_conversation( + call, self.agent_user, self.instructions.full_reference + ) + # Provide conversation to the LLM so it can access the chat history. self.llm.set_conversation(self.conversation) @@ -593,32 +587,30 @@ def idle_for(self) -> float: return self._connection.idle_for() async def finish(self): - """Wait for the call to end gracefully. - Subscribes to the edge transport's `call_ended` event and awaits it. If - no connection is active, returns immediately. """ - if not self._connection: - self.logger.info( - "🔚 Agent connection is already closed, finishing immediately" - ) + Wait for the call to end gracefully. + If no connection is active, returns immediately. + """ + if self._call_ended_event is None: + # Exit immediately because the agent either left the call, or the call hasn't even started. return try: - await running_event.wait() + await self._call_ended_event.wait() except asyncio.CancelledError: - running_event.clear() - - self.events.send(events.AgentFinishEvent()) - - await self.close() + # Close the agent even if the coroutine is canceled + self.events.send(events.AgentFinishEvent()) + await self.close() + raise @contextlib.contextmanager - def span(self, name): - with tracer.start_as_current_span(name, context=self._root_ctx) as span: + def span(self, name: str): + with self.tracer.start_as_current_span(name, context=self._root_ctx) as span: yield span - def _start_tracing(self): - self._root_span = tracer.start_span("join").__enter__() + def _start_tracing(self, call: Call) -> None: + self._root_span = self.tracer.start_span("join").__enter__() + self._root_span.set_attribute("call_id", call.id) self._root_ctx = set_span_in_context(self._root_span) # Activate the root context globally so all subsequent spans are nested under it self._context_token = otel_context.attach(self._root_ctx) @@ -652,21 +644,28 @@ def _end_tracing(self): self._context_token = None async def close(self): - """Clean up all connections and resources. + """ + Clean up all connections and resources. Closes MCP connections, realtime output, active media tracks, processor tasks, the call connection, STT/TTS services, and stops turn detection. - Safe to call multiple times. - - This is an async method because several components expose async shutdown - hooks (e.g., WebRTC connections, plugin services). + It is safe to call multiple times. """ - self._end_tracing() - self._is_running = False - self.clear_call_logging_context() - # Run the async cleanup code in a separate shielded coroutine. - # asyncio.shield changes the context, failing self._end_tracing() - await asyncio.shield(self._stop()) + async with self._close_lock: + if self._closed: + # The agent was closed while waiting for the lock, exit early + return + self.logger.info("🤖 Closing the agent") + # Set call_ended event again in case the agent is closed externally + self._call_ended_event.set() + + # Run the async cleanup code in a separate shielded coroutine. + # asyncio.shield changes the context, failing self._end_tracing() + await asyncio.shield(self._stop()) + self._call_ended_event = None + self.clear_call_logging_context() + self._closed = True + self._end_tracing() async def _stop(self): # Stop audio consumer task @@ -856,10 +855,10 @@ async def _consume_incoming_audio(self) -> None: interval_seconds = 0.02 # 20ms target interval try: - while self._is_running: + while not self._call_ended_event.is_set(): loop_start = time.perf_counter() try: - # Get audio data from queue with timeout to allow checking _is_running + # Get audio data from queue with timeout to keep the loop running pcm = await asyncio.wait_for( self._incoming_audio_queue.get_duration(duration_ms=20), timeout=1.0, @@ -891,7 +890,7 @@ async def _consume_incoming_audio(self) -> None: ) except (asyncio.TimeoutError, asyncio.QueueEmpty): - # No audio data available, continue loop to check _is_running + # No audio data available, continue the loop pass # Sleep for remaining time to maintain consistent interval From 7d6e6f8d813cd273de8858112b6e3df1664bd6f1 Mon Sep 17 00:00:00 2001 From: Daniil Gusev Date: Tue, 6 Jan 2026 12:58:08 +0100 Subject: [PATCH 05/13] Deepgram & 11labs STT: make close() more robust --- agents-core/vision_agents/core/utils/utils.py | 20 ++++++++++++++ .../plugins/deepgram/deepgram_stt.py | 27 ++++++++++--------- .../vision_agents/plugins/elevenlabs/stt.py | 24 ++++++++--------- 3 files changed, 46 insertions(+), 25 deletions(-) diff --git a/agents-core/vision_agents/core/utils/utils.py b/agents-core/vision_agents/core/utils/utils.py index 3d088cb5d..59004e7b3 100644 --- a/agents-core/vision_agents/core/utils/utils.py +++ b/agents-core/vision_agents/core/utils/utils.py @@ -1,4 +1,5 @@ import asyncio +import contextlib import importlib.metadata import inspect import logging @@ -99,3 +100,22 @@ async def await_or_run( if inspect.isawaitable(result): return await result return result + + +async def cancel_and_wait(fut: asyncio.Future) -> None: + """ + Cancel an async task or future and wait for it to complete. + + Args: + fut: a Future or Task to cancel. + + Returns: + None + """ + + if fut.done(): + return None + fut.cancel() + with contextlib.suppress(asyncio.CancelledError): + await asyncio.shield(fut) + return None diff --git a/plugins/deepgram/vision_agents/plugins/deepgram/deepgram_stt.py b/plugins/deepgram/vision_agents/plugins/deepgram/deepgram_stt.py index ad7bb7664..1b20478c3 100644 --- a/plugins/deepgram/vision_agents/plugins/deepgram/deepgram_stt.py +++ b/plugins/deepgram/vision_agents/plugins/deepgram/deepgram_stt.py @@ -1,17 +1,17 @@ import asyncio import logging import os -from typing import Optional, Any +from typing import Any, Optional from deepgram import AsyncDeepgramClient from deepgram.core import EventType from deepgram.extensions.types.sockets import ListenV2ControlMessage from deepgram.listen.v2.socket_client import AsyncV2SocketClient from getstream.video.rtc.track_util import PcmData - from vision_agents.core import stt -from vision_agents.core.stt import TranscriptResponse from vision_agents.core.edge.types import Participant +from vision_agents.core.stt import TranscriptResponse +from vision_agents.core.utils.utils import cancel_and_wait logger = logging.getLogger(__name__) @@ -273,16 +273,19 @@ async def close(self): # Mark as closed first await super().close() - # Cancel listen task - if self._listen_task and not self._listen_task.done(): - self._listen_task.cancel() - await asyncio.gather(self._listen_task, return_exceptions=True) + # Cancel the listen task and ensure it's finished + if self._listen_task: + await cancel_and_wait(self._listen_task) # Close connection if self.connection and self._connection_context: close_msg = ListenV2ControlMessage(type="CloseStream") - await self.connection.send_control(close_msg) - await self._connection_context.__aexit__(None, None, None) - self.connection = None - self._connection_context = None - self._connection_ready.clear() + try: + await self.connection.send_control(close_msg) + await self._connection_context.__aexit__(None, None, None) + except Exception as exc: + logger.warning(f"Error closing Deepgram websocket connection: {exc}") + finally: + self.connection = None + self._connection_context = None + self._connection_ready.clear() diff --git a/plugins/elevenlabs/vision_agents/plugins/elevenlabs/stt.py b/plugins/elevenlabs/vision_agents/plugins/elevenlabs/stt.py index 85e9b17d2..92a82349b 100644 --- a/plugins/elevenlabs/vision_agents/plugins/elevenlabs/stt.py +++ b/plugins/elevenlabs/vision_agents/plugins/elevenlabs/stt.py @@ -2,22 +2,22 @@ import base64 import logging import os -from typing import Optional, Any +from typing import Any, Optional -from elevenlabs.client import AsyncElevenLabs from elevenlabs import ( - RealtimeAudioOptions, AudioFormat, CommitStrategy, - RealtimeEvents, + RealtimeAudioOptions, RealtimeConnection, + RealtimeEvents, ) +from elevenlabs.client import AsyncElevenLabs from getstream.video.rtc.track_util import PcmData - from vision_agents.core import stt -from vision_agents.core.stt import TranscriptResponse from vision_agents.core.edge.types import Participant +from vision_agents.core.stt import TranscriptResponse from vision_agents.core.utils.audio_queue import AudioQueue +from vision_agents.core.utils.utils import cancel_and_wait logger = logging.getLogger(__name__) @@ -387,21 +387,19 @@ async def close(self): await super().close() # Cancel send task - if self._send_task and not self._send_task.done(): - self._send_task.cancel() - await asyncio.gather(self._send_task, return_exceptions=True) + if self._send_task: + await cancel_and_wait(self._send_task) # Cancel listen task - if self._listen_task and not self._listen_task.done(): - self._listen_task.cancel() - await asyncio.gather(self._listen_task, return_exceptions=True) + if self._listen_task: + await cancel_and_wait(self._listen_task) # Close connection if self.connection: try: await self.connection.close() except Exception as e: - logger.warning(f"Error closing connection: {e}") + logger.warning(f"Error closing Elevenlabs connection: {e}") finally: self.connection = None self._connection_ready.clear() From eebd636688b674517a9b90e0901e1b8c67539b02 Mon Sep 17 00:00:00 2001 From: Daniil Gusev Date: Tue, 6 Jan 2026 18:01:57 +0100 Subject: [PATCH 06/13] Agent: `Agent.join` as a proper asynccontextmanager and robust `close()` - Removed outdaded `AgentSessionContextManager` - `Agent.join` now invoked via `async with` instead of `with await` - Updated all examples - Fixed opentelemetry context issues - `Agent.close()` now completes even on cancellation. --- DEVELOPMENT.md | 2 +- .../core/agents/agent_session.py | 91 -------- .../vision_agents/core/agents/agents.py | 220 +++++++++++------- examples/01_simple_agent_example/README.md | 2 +- .../simple_agent_example.py | 2 +- examples/01_simple_agent_example/test.py | 0 .../golf_coach_example.py | 2 +- .../football_commentator_example.py | 2 +- .../aws_realtime_function_calling_example.py | 2 +- .../aws/example/aws_realtime_nova_example.py | 2 +- plugins/cartesia/example/main.py | 2 +- plugins/cartesia/example/narrator-example.py | 2 +- plugins/decart/example/decart_example.py | 7 +- .../deepgram/example/deepgram_tts_example.py | 2 +- .../elevenlabs/example/elevenlabs_example.py | 2 +- .../example/fast_whisper_example.py | 2 +- plugins/fish/example/fish_example.py | 2 +- .../tests/test_stream_edge_transport.py | 8 +- .../getstream/stream_edge_transport.py | 12 +- plugins/heygen/README.md | 4 +- plugins/heygen/example/avatar_example.py | 2 +- .../heygen/example/avatar_realtime_example.py | 2 +- plugins/huggingface/example/main.py | 2 +- .../inworld/example/inworld_tts_example.py | 2 +- plugins/kokoro/example/kokoro_example.py | 2 +- plugins/moondream/README.md | 2 +- .../example/moondream_vlm_example.py | 2 +- .../openai/examples/qwen_vl_example/README.md | 2 +- .../qwen_vl_example/qwen_vl_example.py | 2 +- .../openrouter/example/openrouter_example.py | 2 +- plugins/qwen/example/README.md | 2 +- plugins/qwen/example/qwen_realtime_example.py | 2 +- plugins/roboflow/example/roboflow_example.py | 2 +- plugins/sample_plugin/example/my_example.py | 2 +- .../smart_turn/example/smart_turn_example.py | 2 +- plugins/vogent/example/vogent_example.py | 2 +- plugins/wizper/example/wizper_example.py | 2 +- 37 files changed, 178 insertions(+), 224 deletions(-) delete mode 100644 agents-core/vision_agents/core/agents/agent_session.py delete mode 100644 examples/01_simple_agent_example/test.py diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index bc230fe51..eabfe4155 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -469,7 +469,7 @@ async def start_agent() -> None: ) call = agent.edge.client.video.call("default", str(uuid4())) - with await agent.join(call): + async with agent.join(call): await agent.simple_response("Hello!") await agent.finish() ``` diff --git a/agents-core/vision_agents/core/agents/agent_session.py b/agents-core/vision_agents/core/agents/agent_session.py deleted file mode 100644 index 0d16c4073..000000000 --- a/agents-core/vision_agents/core/agents/agent_session.py +++ /dev/null @@ -1,91 +0,0 @@ -import asyncio -import contextvars -import typing - -if typing.TYPE_CHECKING: - from .agents import Agent - - -class AgentSessionContextManager: - """Context manager that owns an `Agent` session lifecycle. - - This wrapper keeps the underlying RTC connection (if any) open for the - duration of the context, and guarantees a best-effort asynchronous cleanup of - both the RTC connection and the `Agent` resources on exit. - - It accepts an optional connection context manager (e.g., a WebRTC join - context) that may implement an async `__aexit__`. On exit, we shield the - asynchronous teardown so it completes even as the loop shuts down. Any - exception that caused the context to exit is propagated. - - Typical usage: - agent = Agent(...) - with await agent.join(call): - await agent.finish() - - Args: - agent: The `Agent` whose resources and event wiring should be managed. - connection_cm: Optional provider-specific connection context manager - returned by the edge transport (kept open during the context). - """ - - def __init__(self, agent: "Agent", connection_cm=None): - self.agent = agent - self._connection_cm = connection_cm - - def __enter__(self): - """Enter the session context. - - Returns: - AgentSessionContextManager: The context manager itself. - """ - return self - - def __exit__(self, exc_type, exc_value, traceback): - """Exit the session context and trigger cleanup. - - Ensures the provider connection (if provided) and the `Agent` are closed. - Cleanup coroutines are shielded so they are not cancelled by loop - shutdown. Exceptions are not suppressed. - - Args: - exc_type: Exception type causing exit, if any. - exc_value: Exception instance, if any. - traceback: Traceback object, if any. - - Returns: - False, so any exception is propagated to the caller. - """ - loop = asyncio.get_running_loop() - - # ------------------------------------------------------------------ - # Close the RTC connection context if one was started. - # ------------------------------------------------------------------ - if self._connection_cm is not None: - aexit = getattr(self._connection_cm, "__aexit__", None) - if aexit is not None: - if asyncio.iscoroutinefunction(aexit): - # Shield the aexit coroutine so it runs to completion even if the loop is closing. - asyncio.shield(loop.create_task(aexit(None, None, None))) - else: - # Fallback for a sync __aexit__ (unlikely, but safe). - aexit(None, None, None) - - # ------------------------------------------------------------------ - # Close the agent's own resources. - # ------------------------------------------------------------------ - if getattr(self.agent, "_call_context_token", None) is not None: - self.agent.clear_call_logging_context() - coro = self.agent.close() - if asyncio.iscoroutine(coro): - ctx = contextvars.copy_context() - ctx.run(loop.create_task, coro) - - # ------------------------------------------------------------------ - # Handle any exception that caused the context manager to exit. - # ------------------------------------------------------------------ - if exc_type: - print(f"An exception occurred: {exc_value}") - - # Returning False propagates the exception (if any); True would suppress it. - return False diff --git a/agents-core/vision_agents/core/agents/agents.py b/agents-core/vision_agents/core/agents/agents.py index c69c9e42f..307c916a5 100644 --- a/agents-core/vision_agents/core/agents/agents.py +++ b/agents-core/vision_agents/core/agents/agents.py @@ -1,12 +1,21 @@ import asyncio -import contextlib import datetime import logging import time import uuid from collections import defaultdict +from contextlib import asynccontextmanager, contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypeGuard +from typing import ( + TYPE_CHECKING, + Any, + AsyncIterator, + Dict, + Iterator, + List, + Optional, + TypeGuard, +) from uuid import uuid4 import getstream.models @@ -59,11 +68,10 @@ clear_call_context, set_call_context, ) -from ..utils.utils import await_or_run +from ..utils.utils import await_or_run, cancel_and_wait from ..utils.video_forwarder import VideoForwarder from ..utils.video_track import VideoFileTrack from . import events -from .agent_session import AgentSessionContextManager from .agent_types import AgentOptions, LLMTurn, TrackInfo, default_agent_options from .conversation import Conversation from .transcript_buffer import TranscriptBuffer @@ -156,7 +164,7 @@ def __init__( self.instructions = Instructions(input_text=instructions) self.edge = edge - # only needed in case we spin threads + # OpenTelemetry data self.tracer = tracer self._root_span: Optional[Span] = None self._root_ctx: Optional[Context] = None @@ -208,9 +216,6 @@ def __init__( processor.attach_agent(self) self.events.subscribe(self._on_agent_say) - # An event to detect if the call was ended. - # `None` means the call is ended, or it hasn't started yet. - self._call_ended_event: Optional[asyncio.Event] = None # Track metadata: track_id -> TrackInfo self._active_video_tracks: Dict[str, TrackInfo] = {} @@ -238,6 +243,13 @@ def __init__( self.events.send(events.AgentInitEvent()) + # An event to detect if the call was ended. + # `None` means the call is ended, or it hasn't started yet. + # It is set only after agent joins the call + self._call_ended_event: Optional[asyncio.Event] = None + self._joined_at: float = 0.0 + + self._join_lock = asyncio.Lock() self._close_lock = asyncio.Lock() self._closed = False @@ -494,27 +506,42 @@ def subscribe(self, function): """ return self.events.subscribe(function) + @asynccontextmanager async def join( - self, call: Call, wait_for_participant=True - ) -> "AgentSessionContextManager": + self, call: Call, participant_wait_timeout: Optional[float] = 10.0 + ) -> AsyncIterator[None]: + """ + Join the given call. + + The agent can join the call only once. + Once the call is ended, the agent closes itself. + + Args: + call: the call to join. + participant_wait_timeout: timeout in seconds to wait for other participants to join before proceeding. + If `0`, do not wait at all. If `None`, wait forever. + Default - `10.0`. + + Returns: + + """ if self._call_ended_event is not None: raise RuntimeError("Agent already joined the call") - self._call_ended_event = asyncio.Event() - self.call = call - self.conversation = None - self._start_tracing(call) - # Ensure all subsequent logs include the call context. - self._set_call_logging_context(call.id) + try: + await self._join_lock.acquire() + self._start_tracing(call) + self.call = call + self.conversation = None - # run start on all subclasses - await self._apply("start") + # Ensure all subsequent logs include the call context. + self._set_call_logging_context(call.id) - await self.create_user() - if self.agent_user.id: - self._root_span.set_attribute("agent_id", self.agent_user.id) + # run start on all subclasses + await self._apply("start") + + await self.create_user() - try: # Connect to MCP servers if manager is available if self.mcp_manager: with self.span("mcp_manager.connect_all"): @@ -526,46 +553,54 @@ async def join( await self.llm.connect() with self.span("edge.join"): - connection = await self.edge.join(self, call) + self._connection = await self.edge.join(self, call) + self.logger.info(f"🤖 Agent joined call: {call.id}") - except Exception: - self.clear_call_logging_context() - raise - - self._connection = connection - self._audio_consumer_task = asyncio.create_task(self._consume_incoming_audio()) - - self.logger.info(f"🤖 Agent joined call: {call.id}") + # Set up audio and video tracks together to avoid SDP issues + audio_track = self._audio_track if self.publish_audio else None + video_track = self._video_track if self.publish_video else None - # Set up audio and video tracks together to avoid SDP issues - audio_track = self._audio_track if self.publish_audio else None - video_track = self._video_track if self.publish_video else None + if audio_track or video_track: + with self.span("edge.publish_tracks"): + await self.edge.publish_tracks(audio_track, video_track) - if audio_track or video_track: - with self.span("edge.publish_tracks"): - await self.edge.publish_tracks(audio_track, video_track) - - # Setup chat and connect it to transcript events - self.conversation = await self.edge.create_conversation( - call, self.agent_user, self.instructions.full_reference - ) + # Setup chat and connect it to transcript events + self.conversation = await self.edge.create_conversation( + call, self.agent_user, self.instructions.full_reference + ) - # Provide conversation to the LLM so it can access the chat history. - self.llm.set_conversation(self.conversation) + # Provide conversation to the LLM so it can access the chat history. + self.llm.set_conversation(self.conversation) - if wait_for_participant: - await self.wait_for_participant() + if participant_wait_timeout != 0: + await self.wait_for_participant(timeout=participant_wait_timeout) - return AgentSessionContextManager(self, self._connection) + # Start consuming audio from the call + self._audio_consumer_task = asyncio.create_task( + self._consume_incoming_audio() + ) + self._call_ended_event = asyncio.Event() + self._joined_at = time.time() + yield + finally: + await self.close() + self._end_tracing() + self._join_lock.release() async def wait_for_participant(self, timeout: Optional[float] = None) -> None: """ - Wait for a participant other than the AI agent to join + Wait for a participant other than the AI agent to join. + + Args: + timeout: How long to wait for the participant to join in seconds. + If `None`, wait forever. + Default - `30.0`. """ if self._connection is None: return self.logger.info("Waiting for other participants to join") + try: await self._connection.wait_for_participant(timeout=timeout) except asyncio.TimeoutError: @@ -581,10 +616,20 @@ def idle_for(self) -> float: Returns: idle time for this connection or 0.0 """ - if self._connection is None: + if self._connection is None or not self._joined_at: + # The call hasn't started yet. return 0.0 - return self._connection.idle_for() + # The connection is opened, but it's not idle, exit early. + idle_since = self._connection.idle_since() + if not idle_since: + return 0.0 + + # The RTC connection is established and it's idle. + # Adjust the idle_since timestamp if the Agent was waiting for participants before actually + # joining the call. + idle_since_adjusted = max(idle_since, self._joined_at) + return time.time() - idle_since_adjusted async def finish(self): """ @@ -603,14 +648,16 @@ async def finish(self): await self.close() raise - @contextlib.contextmanager - def span(self, name: str): + @contextmanager + def span(self, name: str) -> Iterator[Span]: with self.tracer.start_as_current_span(name, context=self._root_ctx) as span: yield span def _start_tracing(self, call: Call) -> None: self._root_span = self.tracer.start_span("join").__enter__() self._root_span.set_attribute("call_id", call.id) + if self.agent_user.id: + self._root_span.set_attribute("agent_id", self.agent_user.id) self._root_ctx = set_span_in_context(self._root_span) # Activate the root context globally so all subsequent spans are nested under it self._context_token = otel_context.attach(self._root_ctx) @@ -651,30 +698,33 @@ async def close(self): tasks, the call connection, STT/TTS services, and stops turn detection. It is safe to call multiple times. """ + if self._close_lock.locked() or self._closed: + return + async with self._close_lock: - if self._closed: - # The agent was closed while waiting for the lock, exit early - return - self.logger.info("🤖 Closing the agent") - # Set call_ended event again in case the agent is closed externally + # This is how to make sure the `_stop()` coroutine is definitely finished even if the outer + # task is cancelled. + # Run _stop() in a shielded task + task = asyncio.create_task(self._close()) + try: + await asyncio.shield(task) + except asyncio.CancelledError: + # The close() itself is cancelled, but the shielded task is still running because that's + # how shield() works. + # Wait until the shielded task finishes + await task + # Propagate cancellation upwards + raise + + async def _close(self): + # Set call_ended event again in case the agent is closed externally + self.logger.info("🤖 Stopping the agent") + if self._call_ended_event is not None: self._call_ended_event.set() - # Run the async cleanup code in a separate shielded coroutine. - # asyncio.shield changes the context, failing self._end_tracing() - await asyncio.shield(self._stop()) - self._call_ended_event = None - self.clear_call_logging_context() - self._closed = True - self._end_tracing() - - async def _stop(self): # Stop audio consumer task if self._audio_consumer_task: - self._audio_consumer_task.cancel() - try: - await self._audio_consumer_task - except asyncio.CancelledError: - pass + await cancel_and_wait(self._audio_consumer_task) self._audio_consumer_task = None # run stop on all subclasses @@ -687,13 +737,12 @@ async def _stop(self): await self.mcp_manager.disconnect_all() # Stop all video forwarders - if hasattr(self, "_video_forwarders"): - for forwarder in self._video_forwarders: - try: - await forwarder.stop() - except Exception as e: - self.logger.error(f"Error stopping video forwarder: {e}") - self._video_forwarders.clear() + for forwarder in self._video_forwarders: + try: + await forwarder.stop() + except Exception as e: + self.logger.error(f"Error stopping video forwarder: {e}") + self._video_forwarders.clear() # Close RTC connection if self._connection: @@ -710,6 +759,12 @@ async def _stop(self): self._video_track.stop() self._video_track = None + self._call_ended_event = None + self._joined_at = 0.0 + self.clear_call_logging_context() + self._closed = True + self.logger.info("🤖 Agent stopped") + # ------------------------------------------------------------------ # Logging context helpers # ------------------------------------------------------------------ @@ -855,7 +910,7 @@ async def _consume_incoming_audio(self) -> None: interval_seconds = 0.02 # 20ms target interval try: - while not self._call_ended_event.is_set(): + while self._call_ended_event and not self._call_ended_event.is_set(): loop_start = time.perf_counter() try: # Get audio data from queue with timeout to keep the loop running @@ -1324,13 +1379,6 @@ def _is_realtime_llm(llm: LLM | AudioLLM | VideoLLM | Realtime) -> TypeGuard[Rea return isinstance(llm, Realtime) -def _log_task_exception(task: asyncio.Task): - try: - task.result() - except Exception: - logger.exception("Error in background task") - - class _AgentLoggerAdapter(logging.LoggerAdapter): """ A logger adapter to include the agent_id to the logs diff --git a/examples/01_simple_agent_example/README.md b/examples/01_simple_agent_example/README.md index 850842b0e..f23b300e4 100644 --- a/examples/01_simple_agent_example/README.md +++ b/examples/01_simple_agent_example/README.md @@ -83,7 +83,7 @@ agent = Agent( ```python call = agent.edge.client.video.call("default", str(uuid4())) -with await agent.join(call): +async with agent.join(call): await agent.finish() ``` diff --git a/examples/01_simple_agent_example/simple_agent_example.py b/examples/01_simple_agent_example/simple_agent_example.py index e8c2e0279..8bcea5820 100644 --- a/examples/01_simple_agent_example/simple_agent_example.py +++ b/examples/01_simple_agent_example/simple_agent_example.py @@ -54,7 +54,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non call = await agent.create_call(call_type, call_id) # Have the agent join the call/room - with await agent.join(call): + async with agent.join(call): # Use agent.simple response or... await agent.simple_response("tell me something interesting in a short sentence") # Alternatively: if you need more control, user the native openAI create_response diff --git a/examples/01_simple_agent_example/test.py b/examples/01_simple_agent_example/test.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/02_golf_coach_example/golf_coach_example.py b/examples/02_golf_coach_example/golf_coach_example.py index 6c9612cc5..2ac6769ca 100644 --- a/examples/02_golf_coach_example/golf_coach_example.py +++ b/examples/02_golf_coach_example/golf_coach_example.py @@ -29,7 +29,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non call = await agent.create_call(call_type, call_id) # join the call and open a demo env - with await agent.join(call): + async with agent.join(call): # all LLMs support a simple_response method and a more advanced native method (so you can always use the latest LLM features) await agent.llm.simple_response( text="Say hi. After the user does their golf swing offer helpful feedback." diff --git a/examples/04_football_commentator_example/football_commentator_example.py b/examples/04_football_commentator_example/football_commentator_example.py index 67a3802a7..5aaa5a080 100644 --- a/examples/04_football_commentator_example/football_commentator_example.py +++ b/examples/04_football_commentator_example/football_commentator_example.py @@ -64,7 +64,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non call = await agent.create_call(call_type, call_id) # Have the agent join the call/room - with await agent.join(call): + async with agent.join(call): # run till the call ends await agent.finish() diff --git a/plugins/aws/example/aws_realtime_function_calling_example.py b/plugins/aws/example/aws_realtime_function_calling_example.py index 4261aa7e7..02cd14374 100644 --- a/plugins/aws/example/aws_realtime_function_calling_example.py +++ b/plugins/aws/example/aws_realtime_function_calling_example.py @@ -87,7 +87,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non logger.info("🤖 Starting AWS Bedrock Realtime Agent...") # Have the agent join the call/room - with await agent.join(call): + async with agent.join(call): logger.info("Joining call") logger.info("LLM ready") diff --git a/plugins/aws/example/aws_realtime_nova_example.py b/plugins/aws/example/aws_realtime_nova_example.py index 3c96c51c6..8fcf4f746 100644 --- a/plugins/aws/example/aws_realtime_nova_example.py +++ b/plugins/aws/example/aws_realtime_nova_example.py @@ -36,7 +36,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non call = await agent.create_call(call_type, call_id) # Have the agent join the call/room - with await agent.join(call): + async with agent.join(call): await asyncio.sleep(5) await agent.llm.simple_response( text="Tell me a short story about a dragon and a princess" diff --git a/plugins/cartesia/example/main.py b/plugins/cartesia/example/main.py index a7c0d52b6..24835cda3 100644 --- a/plugins/cartesia/example/main.py +++ b/plugins/cartesia/example/main.py @@ -51,7 +51,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non call = await agent.create_call(call_type, call_id) # Join call and wait - with await agent.join(call): + async with agent.join(call): await agent.simple_response("tell me something interesting in a short sentence") await agent.finish() diff --git a/plugins/cartesia/example/narrator-example.py b/plugins/cartesia/example/narrator-example.py index d0fb172c8..b92ddfd89 100644 --- a/plugins/cartesia/example/narrator-example.py +++ b/plugins/cartesia/example/narrator-example.py @@ -53,7 +53,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non call = await agent.create_call(call_type, call_id) # Join call and wait - with await agent.join(call): + async with agent.join(call): await asyncio.sleep(3) await agent.simple_response("narrate a story about a dragon") await agent.finish() diff --git a/plugins/decart/example/decart_example.py b/plugins/decart/example/decart_example.py index 4ece59c7b..dd781802e 100644 --- a/plugins/decart/example/decart_example.py +++ b/plugins/decart/example/decart_example.py @@ -1,10 +1,9 @@ import logging from dotenv import load_dotenv - -from vision_agents.core import User, Agent, cli +from vision_agents.core import Agent, User, cli from vision_agents.core.agents import AgentLauncher -from vision_agents.plugins import decart, getstream, openai, elevenlabs, deepgram +from vision_agents.plugins import decart, deepgram, elevenlabs, getstream, openai logger = logging.getLogger(__name__) @@ -47,7 +46,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non logger.info("🤖 Starting Agent...") # Have the agent join the call/room - with await agent.join(call): + async with agent.join(call): logger.info("Joining call") logger.info("LLM ready") diff --git a/plugins/deepgram/example/deepgram_tts_example.py b/plugins/deepgram/example/deepgram_tts_example.py index 80b8bef62..960788b3c 100644 --- a/plugins/deepgram/example/deepgram_tts_example.py +++ b/plugins/deepgram/example/deepgram_tts_example.py @@ -53,7 +53,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non logger.info("Starting Deepgram TTS Agent...") # Have the agent join the call/room - with await agent.join(call): + async with agent.join(call): logger.info("Joining call") logger.info("LLM ready") diff --git a/plugins/elevenlabs/example/elevenlabs_example.py b/plugins/elevenlabs/example/elevenlabs_example.py index 0fe419da0..ed0ca74d3 100644 --- a/plugins/elevenlabs/example/elevenlabs_example.py +++ b/plugins/elevenlabs/example/elevenlabs_example.py @@ -49,7 +49,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non logger.info("🤖 Starting ElevenLabs Agent...") # Have the agent join the call/room - with await agent.join(call): + async with agent.join(call): await agent.simple_response("tell me something interesting in a short sentence") await agent.finish() # Run till the call ends diff --git a/plugins/fast_whisper/example/fast_whisper_example.py b/plugins/fast_whisper/example/fast_whisper_example.py index ae647abc8..ff34982dd 100644 --- a/plugins/fast_whisper/example/fast_whisper_example.py +++ b/plugins/fast_whisper/example/fast_whisper_example.py @@ -46,7 +46,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non logger.info("🤖 Starting Fast Whisper Agent...") # Have the agent join the call/room - with await agent.join(call): + async with agent.join(call): logger.info("Joining call") logger.info("Fast Whisper STT ready") diff --git a/plugins/fish/example/fish_example.py b/plugins/fish/example/fish_example.py index 9672f00e5..a3639e2cb 100644 --- a/plugins/fish/example/fish_example.py +++ b/plugins/fish/example/fish_example.py @@ -55,7 +55,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non logger.info("🤖 Starting Fish Audio Agent...") # Have the agent join the call/room - with await agent.join(call): + async with agent.join(call): logger.info("Joining call") logger.info("LLM ready") diff --git a/plugins/getstream/tests/test_stream_edge_transport.py b/plugins/getstream/tests/test_stream_edge_transport.py index e6844d2c3..438fb9f98 100644 --- a/plugins/getstream/tests/test_stream_edge_transport.py +++ b/plugins/getstream/tests/test_stream_edge_transport.py @@ -19,25 +19,25 @@ def test_idle_for(self, connection_manager): # No participants, connection is idle conn = StreamConnection(connection=connection_manager) time.sleep(0.01) - assert conn.idle_for() > 0 + assert conn.idle_since() > 0 # One participant (itself), still idle connection_manager.participants_state._add_participant( Participant(user_id=str(connection_manager.user_id)) ) time.sleep(0.01) - assert conn.idle_for() > 0 + assert conn.idle_since() > 0 # A participant joined, not idle anymore another_participant = Participant(user_id="another-user-id") connection_manager.participants_state._add_participant(another_participant) time.sleep(0.01) - assert not conn.idle_for() + assert not conn.idle_since() # A participant left, idle again connection_manager.participants_state._remove_participant(another_participant) time.sleep(0.01) - assert conn.idle_for() > 0 + assert conn.idle_since() > 0 async def test_wait_for_participant_already_present(self, connection_manager): """Test that wait_for_participant returns immediately if participant already in call""" 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 2e46334d3..dad1d39ab 100644 --- a/plugins/getstream/vision_agents/plugins/getstream/stream_edge_transport.py +++ b/plugins/getstream/vision_agents/plugins/getstream/stream_edge_transport.py @@ -39,7 +39,7 @@ def __init__(self, connection: ConnectionManager): super().__init__() # store the native connection object self._connection = connection - self._idle_since = 0 + self._idle_since: float = 0.0 self._participant_joined = asyncio.Event() # Subscribe to participants changes for this connection self._subscription = self._connection.participants_state.map( @@ -50,17 +50,15 @@ def __init__(self, connection: ConnectionManager): def participants(self) -> ParticipantsState: return self._connection.participants_state - def idle_for(self) -> float: + def idle_since(self) -> float: """ - Return the idle time for this connection if there is no other participants except the agent itself. + Return the timestamp when all participants left this call except the agent itself. `0.0` means that connection is active. Returns: idle time for this connection or 0. """ - if self._idle_since: - return time.time() - self._idle_since - return 0.0 + return self._idle_since async def wait_for_participant(self, timeout: Optional[float] = None) -> None: """ @@ -89,7 +87,7 @@ def _on_participant_change(self, participants: list[Participant]) -> None: if other_participants: # Some participants detected. # Reset the idleness timeout back to zero. - self._idle_since = 0 + self._idle_since = 0.0 # Resolve the participant joined event self._participant_joined.set() elif not self._idle_since: diff --git a/plugins/heygen/README.md b/plugins/heygen/README.md index b46ca4bdf..f0fe40449 100644 --- a/plugins/heygen/README.md +++ b/plugins/heygen/README.md @@ -56,7 +56,7 @@ async def start_avatar_agent(): call = agent.edge.client.video.call("default", str(uuid4())) - with await agent.join(call): + async with agent.join(call): await agent.simple_response("Hello! I'm your AI assistant with an avatar.") await agent.finish() @@ -108,7 +108,7 @@ agent = Agent( call = agent.edge.client.video.call("default", str(uuid4())) -with await agent.join(call): +async with agent.join(call): await agent.finish() ``` diff --git a/plugins/heygen/example/avatar_example.py b/plugins/heygen/example/avatar_example.py index 49300a2f3..d056356c4 100644 --- a/plugins/heygen/example/avatar_example.py +++ b/plugins/heygen/example/avatar_example.py @@ -62,7 +62,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non logger.info("🤖 Starting HeyGen Avatar Agent...") # Have the agent join the call/room - with await agent.join(call): + async with agent.join(call): logger.info("Joining call") logger.info("Demo opened") diff --git a/plugins/heygen/example/avatar_realtime_example.py b/plugins/heygen/example/avatar_realtime_example.py index df66f5a3a..3714c0c52 100644 --- a/plugins/heygen/example/avatar_realtime_example.py +++ b/plugins/heygen/example/avatar_realtime_example.py @@ -52,7 +52,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non logger.info("🤖 Starting HeyGen Avatar Agent...") # Have the agent join the call/room - with await agent.join(call): + async with agent.join(call): logger.info("Joining call") logger.info("LLM ready") diff --git a/plugins/huggingface/example/main.py b/plugins/huggingface/example/main.py index 256faa87e..87c87e2e3 100644 --- a/plugins/huggingface/example/main.py +++ b/plugins/huggingface/example/main.py @@ -51,7 +51,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non logger.info("Starting HuggingFace Agent...") - with await agent.join(call): + async with agent.join(call): logger.info("Joining call") await asyncio.sleep(2) diff --git a/plugins/inworld/example/inworld_tts_example.py b/plugins/inworld/example/inworld_tts_example.py index 99ffd7d70..9c512349a 100644 --- a/plugins/inworld/example/inworld_tts_example.py +++ b/plugins/inworld/example/inworld_tts_example.py @@ -54,7 +54,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non logger.info("🤖 Starting Inworld AI Agent...") # Have the agent join the call/room - with await agent.join(call): + async with agent.join(call): logger.info("Joining call") logger.info("LLM ready") diff --git a/plugins/kokoro/example/kokoro_example.py b/plugins/kokoro/example/kokoro_example.py index 158838f29..495d2cc6d 100644 --- a/plugins/kokoro/example/kokoro_example.py +++ b/plugins/kokoro/example/kokoro_example.py @@ -48,7 +48,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non await agent.create_user() call = await agent.create_call(call_type, call_id) - with await agent.join(call): + async with agent.join(call): await agent.finish() diff --git a/plugins/moondream/README.md b/plugins/moondream/README.md index 35f5aab2a..a8ca442d8 100644 --- a/plugins/moondream/README.md +++ b/plugins/moondream/README.md @@ -152,7 +152,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non # Ask the agent to describe what it sees await agent.simple_response("Describe what you currently see") - with await agent.join(call): + async with agent.join(call): await agent.finish() if __name__ == "__main__": diff --git a/plugins/moondream/example/moondream_vlm_example.py b/plugins/moondream/example/moondream_vlm_example.py index 8af0996cc..1b689587f 100644 --- a/plugins/moondream/example/moondream_vlm_example.py +++ b/plugins/moondream/example/moondream_vlm_example.py @@ -41,7 +41,7 @@ async def on_participant_joined(event: CallSessionParticipantJoinedEvent): await agent.simple_response("Describe what you currently see") # Have the agent join the call/room - with await agent.join(call): + async with agent.join(call): # run till the call ends await agent.finish() diff --git a/plugins/openai/examples/qwen_vl_example/README.md b/plugins/openai/examples/qwen_vl_example/README.md index 0494430fb..4da996287 100644 --- a/plugins/openai/examples/qwen_vl_example/README.md +++ b/plugins/openai/examples/qwen_vl_example/README.md @@ -44,7 +44,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non await agent.create_user() call = await agent.create_call(call_type, call_id) - with await agent.join(call): + async with agent.join(call): # The agent will automatically process video frames and respond to user input await agent.finish() ``` 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 e1dbef40c..65d385db7 100644 --- a/plugins/openai/examples/qwen_vl_example/qwen_vl_example.py +++ b/plugins/openai/examples/qwen_vl_example/qwen_vl_example.py @@ -36,7 +36,7 @@ async def on_participant_joined(event: CallSessionParticipantJoinedEvent): await asyncio.sleep(2) await agent.simple_response("Describe what you currently see") - with await agent.join(call): + async with agent.join(call): await agent.edge.open_demo(call) # The agent will automatically process video frames and respond to user input await agent.finish() diff --git a/plugins/openrouter/example/openrouter_example.py b/plugins/openrouter/example/openrouter_example.py index 89682bb14..45fb718c3 100644 --- a/plugins/openrouter/example/openrouter_example.py +++ b/plugins/openrouter/example/openrouter_example.py @@ -113,7 +113,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non logger.info(f" - {func['name']}: {func.get('description', '')[:50]}...") # Have the agent join the call/room - with await agent.join(call): + async with agent.join(call): logger.info("Joining call") logger.info("LLM ready") diff --git a/plugins/qwen/example/README.md b/plugins/qwen/example/README.md index 675ad924b..d53aadedf 100644 --- a/plugins/qwen/example/README.md +++ b/plugins/qwen/example/README.md @@ -59,7 +59,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non await agent.create_user() call = await agent.create_call(call_type, call_id) - with await agent.join(call): + async with agent.join(call): await agent.edge.open_demo(call) await agent.finish() diff --git a/plugins/qwen/example/qwen_realtime_example.py b/plugins/qwen/example/qwen_realtime_example.py index d353e2d4b..292f4ec75 100644 --- a/plugins/qwen/example/qwen_realtime_example.py +++ b/plugins/qwen/example/qwen_realtime_example.py @@ -27,7 +27,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non await agent.create_user() call = await agent.create_call(call_type, call_id) - with await agent.join(call): + async with agent.join(call): await agent.edge.open_demo(call) await agent.finish() diff --git a/plugins/roboflow/example/roboflow_example.py b/plugins/roboflow/example/roboflow_example.py index bc3b32f27..355fb076e 100644 --- a/plugins/roboflow/example/roboflow_example.py +++ b/plugins/roboflow/example/roboflow_example.py @@ -52,7 +52,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non """Join the call and run the agent.""" call = await agent.create_call(call_type, call_id) - with await agent.join(call): + async with agent.join(call): await agent.finish() diff --git a/plugins/sample_plugin/example/my_example.py b/plugins/sample_plugin/example/my_example.py index 9651cfd30..f01585468 100644 --- a/plugins/sample_plugin/example/my_example.py +++ b/plugins/sample_plugin/example/my_example.py @@ -46,7 +46,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non logger.info("🤖 Starting Agent...") # Have the agent join the call/room - with await agent.join(call): + async with agent.join(call): logger.info("Joining call") logger.info("LLM ready") diff --git a/plugins/smart_turn/example/smart_turn_example.py b/plugins/smart_turn/example/smart_turn_example.py index 9776bae07..4d17341ca 100644 --- a/plugins/smart_turn/example/smart_turn_example.py +++ b/plugins/smart_turn/example/smart_turn_example.py @@ -28,7 +28,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non call = await agent.create_call(call_type, call_id) # Have the agent join the call/room - with await agent.join(call): + async with agent.join(call): await agent.simple_response("tell me something interesting in a short sentence") # run till the call ends diff --git a/plugins/vogent/example/vogent_example.py b/plugins/vogent/example/vogent_example.py index c4702c113..67124e3c4 100644 --- a/plugins/vogent/example/vogent_example.py +++ b/plugins/vogent/example/vogent_example.py @@ -28,7 +28,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non call = await agent.create_call(call_type, call_id) # Have the agent join the call/room - with await agent.join(call): + async with agent.join(call): await agent.simple_response("tell me something interesting in a short sentence") # run till the call ends diff --git a/plugins/wizper/example/wizper_example.py b/plugins/wizper/example/wizper_example.py index 6af6db17b..8bc8686ce 100644 --- a/plugins/wizper/example/wizper_example.py +++ b/plugins/wizper/example/wizper_example.py @@ -68,7 +68,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non await agent.create_user() call = await agent.create_call(call_type, call_id) - with await agent.join(call): + async with agent.join(call): logger.info("Listening for audio...") await agent.finish() From eba619e1790269cf2c8e4e706c9bbc99b938820f Mon Sep 17 00:00:00 2001 From: Daniil Gusev Date: Wed, 7 Jan 2026 13:40:39 +0100 Subject: [PATCH 07/13] Allow `on_warmed_up` method to be async too. --- agents-core/vision_agents/core/warmup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/agents-core/vision_agents/core/warmup.py b/agents-core/vision_agents/core/warmup.py index 0360afff4..a064c3b7e 100644 --- a/agents-core/vision_agents/core/warmup.py +++ b/agents-core/vision_agents/core/warmup.py @@ -2,6 +2,8 @@ import asyncio from typing import Any, Generic, Type, TypeVar +from vision_agents.core.utils.utils import await_or_run + __all__ = ( "Warmable", "WarmupCache", @@ -39,7 +41,7 @@ async def warmup(self, warmable: "Warmable"): # Store the result self._cache[warmable_cls] = resource # Set the resource back to the warmable instance. - warmable.on_warmed_up(resource) + await await_or_run(warmable.on_warmed_up, resource) class Warmable(abc.ABC, Generic[T]): From 20589131ba9e15938e8045a387c7c2d0742b0c0b Mon Sep 17 00:00:00 2001 From: Daniil Gusev Date: Wed, 7 Jan 2026 13:42:39 +0100 Subject: [PATCH 08/13] AgentLauncher: track the active agents and stop those that are idle --- .../vision_agents/core/agents/__init__.py | 2 + .../core/agents/agent_launcher.py | 91 +++++++++- .../vision_agents/core/agents/agents.py | 4 + .../vision_agents/core/cli/cli_runner.py | 6 +- tests/test_agents/__init__.py | 0 tests/test_agents/test_agent_launcher.py | 163 ++++++++++++++++++ 6 files changed, 257 insertions(+), 9 deletions(-) create mode 100644 tests/test_agents/__init__.py create mode 100644 tests/test_agents/test_agent_launcher.py diff --git a/agents-core/vision_agents/core/agents/__init__.py b/agents-core/vision_agents/core/agents/__init__.py index c600e711c..c29fef8d0 100644 --- a/agents-core/vision_agents/core/agents/__init__.py +++ b/agents-core/vision_agents/core/agents/__init__.py @@ -7,9 +7,11 @@ from .agents import Agent as Agent from .conversation import Conversation as Conversation from .agent_launcher import AgentLauncher as AgentLauncher +from .agent_types import AgentOptions as AgentOptions __all__ = [ "Agent", "Conversation", "AgentLauncher", + "AgentOptions", ] diff --git a/agents-core/vision_agents/core/agents/agent_launcher.py b/agents-core/vision_agents/core/agents/agent_launcher.py index 73baf1133..6076a5c22 100644 --- a/agents-core/vision_agents/core/agents/agent_launcher.py +++ b/agents-core/vision_agents/core/agents/agent_launcher.py @@ -1,9 +1,10 @@ import asyncio import logging -from typing import TYPE_CHECKING, Awaitable, Callable +import weakref +from typing import TYPE_CHECKING, Awaitable, Callable, Optional -from vision_agents.core.utils.utils import await_or_run -from vision_agents.core.warmup import WarmupCache, Warmable +from vision_agents.core.utils.utils import await_or_run, cancel_and_wait +from vision_agents.core.warmup import Warmable, WarmupCache if TYPE_CHECKING: from .agents import Agent @@ -23,6 +24,8 @@ def __init__( self, create_agent: Callable[..., "Agent" | Awaitable["Agent"]], join_call: Callable[..., None | Awaitable[None]] | None = None, + agent_idle_timeout: float = 10.0, + agent_idle_cleanup_interval: float = 5.0, ): """ Initialize the agent launcher. @@ -30,12 +33,45 @@ def __init__( Args: create_agent: A function that creates and returns an Agent instance join_call: Optional function that handles joining a call with the agent + agent_idle_timeout: Optional timeout in seconds for agent to stay alone on the call. Default - `30.0`. + `0` means idle agents won't leave the call until it's ended. + """ self.create_agent = create_agent self.join_call = join_call self._warmup_lock = asyncio.Lock() self._warmup_cache = WarmupCache() + if agent_idle_timeout < 0: + raise ValueError("agent_idle_timeout must be >= 0") + self._agent_idle_timeout = agent_idle_timeout + + if agent_idle_cleanup_interval <= 0: + raise ValueError("agent_idle_cleanup_interval must be > 0") + self._agent_idle_cleanup_interval = agent_idle_cleanup_interval + + self._active_agents: weakref.WeakSet[Agent] = weakref.WeakSet() + + self._running = False + self._cleanup_task: Optional[asyncio.Task] = None + self._warmed_up: bool = False + + async def start(self): + if self._running: + raise RuntimeError("AgentLauncher is already running") + logger.debug("Starting AgentLauncher") + self._running = True + await self.warmup() + self._cleanup_task = asyncio.create_task(self._cleanup_idle_agents()) + logger.debug("AgentLauncher started") + + async def stop(self): + logger.debug("Stopping AgentLauncher") + self._running = False + if self._cleanup_task: + await cancel_and_wait(self._cleanup_task) + logger.debug("AgentLauncher stopped") + async def warmup(self) -> None: """ Warm up all agent components. @@ -43,6 +79,9 @@ async def warmup(self) -> None: This method creates the agent and calls warmup() on LLM, TTS, STT, and turn detection components if they exist. """ + if self._warmed_up or self._warmup_lock.locked(): + return + async with self._warmup_lock: logger.info("Creating agent...") @@ -50,6 +89,7 @@ async def warmup(self) -> None: agent: "Agent" = await await_or_run(self.create_agent) logger.info("Warming up agent components...") await self._warmup_agent(agent) + self._warmed_up = True logger.info("Agent warmup completed") @@ -65,6 +105,7 @@ async def launch(self, **kwargs) -> "Agent": """ agent: "Agent" = await await_or_run(self.create_agent, **kwargs) await self._warmup_agent(agent) + self._active_agents.add(agent) return agent async def _warmup_agent(self, agent: "Agent") -> None: @@ -99,10 +140,46 @@ async def _warmup_agent(self, agent: "Agent") -> None: warmup_tasks.append(agent.turn_detection.warmup(self._warmup_cache)) # Warmup processors - if agent.processors: - for processor in agent.processors: - if isinstance(processor, Warmable): - warmup_tasks.append(processor.warmup(self._warmup_cache)) + for processor in agent.processors: + if isinstance(processor, Warmable): + warmup_tasks.append(processor.warmup(self._warmup_cache)) if warmup_tasks: await asyncio.gather(*warmup_tasks) + + async def _cleanup_idle_agents(self) -> None: + if not self._agent_idle_timeout: + return + + while self._running: + # Collect idle agents first to close them all at once + idle_agents = [] + for agent in self._active_agents: + agent_idle_for = agent.idle_for() + if agent_idle_for >= self._agent_idle_timeout: + logger.info( + f'Agent with user_id "{agent.agent_user.id}" is idle for {round(agent_idle_for, 2)}s, ' + f"closing it after {self._agent_idle_timeout}s timeout" + ) + idle_agents.append(agent) + + if idle_agents: + coros = [asyncio.shield(a.close()) for a in idle_agents] + result = await asyncio.shield( + asyncio.gather(*coros, return_exceptions=True) + ) + for agent, r in zip(idle_agents, result): + if isinstance(r, Exception): + logger.error( + f"Failed to close idle agent with user_id {agent.agent_user.id}", + exc_info=r, + ) + + await asyncio.sleep(self._agent_idle_cleanup_interval) + + async def __aenter__(self): + await self.start() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.stop() diff --git a/agents-core/vision_agents/core/agents/agents.py b/agents-core/vision_agents/core/agents/agents.py index 307c916a5..dd55d6ae9 100644 --- a/agents-core/vision_agents/core/agents/agents.py +++ b/agents-core/vision_agents/core/agents/agents.py @@ -690,6 +690,10 @@ def _end_tracing(self): otel_context.detach(self._context_token) self._context_token = None + @property + def closed(self) -> bool: + return self._closed + async def close(self): """ Clean up all connections and resources. diff --git a/agents-core/vision_agents/core/cli/cli_runner.py b/agents-core/vision_agents/core/cli/cli_runner.py index f142e4a8d..0b21095ab 100644 --- a/agents-core/vision_agents/core/cli/cli_runner.py +++ b/agents-core/vision_agents/core/cli/cli_runner.py @@ -101,8 +101,8 @@ async def _run(): logger.info("🚀 Launching agent...") try: - # Warmup the agent's dependencies - await launcher.warmup() + # Start the agent launcher. + await launcher.start() # Create the agent agent = await launcher.launch() @@ -135,6 +135,8 @@ async def _run(): except Exception as e: logger.error(f"❌ Error running agent: {e}", exc_info=True) raise + finally: + await launcher.stop() asyncio_logger_level = asyncio_logger.level diff --git a/tests/test_agents/__init__.py b/tests/test_agents/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_agents/test_agent_launcher.py b/tests/test_agents/test_agent_launcher.py new file mode 100644 index 000000000..94f5c065d --- /dev/null +++ b/tests/test_agents/test_agent_launcher.py @@ -0,0 +1,163 @@ +import asyncio +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from vision_agents.core import Agent, AgentLauncher, User +from vision_agents.core.events import EventManager +from vision_agents.core.llm import LLM +from vision_agents.core.llm.llm import LLMResponseEvent +from vision_agents.core.tts import TTS +from vision_agents.core.warmup import Warmable + + +class DummyTTS(TTS): + async def stream_audio(self, *_, **__): + return b"" + + async def stop_audio(self) -> None: ... + + +class DummyLLM(LLM, Warmable[bool]): + def __init__(self): + super(DummyLLM, self).__init__() + self.warmed_up = False + + async def simple_response(self, *_, **__) -> LLMResponseEvent[Any]: + return LLMResponseEvent(text="Simple response", original=None) + + async def on_warmup(self) -> bool: + return True + + async def on_warmed_up(self, *_) -> None: + self.warmed_up = True + + +@pytest.fixture() +async def stream_edge_mock() -> MagicMock: + mock = MagicMock() + mock.events.return_value = EventManager() + return mock + + +class TestAgentLauncher: + async def test_warmup(self, stream_edge_mock): + llm = DummyLLM() + tts = DummyTTS() + + async def create_agent(**kwargs) -> Agent: + return Agent( + llm=llm, + tts=tts, + edge=stream_edge_mock, + agent_user=User(name="test"), + ) + + launcher = AgentLauncher(create_agent=create_agent) + await launcher.warmup() + assert llm.warmed_up + + async def test_launch(self, stream_edge_mock): + llm = DummyLLM() + tts = DummyTTS() + + async def create_agent(**kwargs) -> Agent: + return Agent( + llm=llm, + tts=tts, + edge=stream_edge_mock, + agent_user=User(name="test"), + ) + + launcher = AgentLauncher(create_agent=create_agent) + agent = await launcher.launch() + assert agent + + async def test_idle_agents_stopped(self, stream_edge_mock): + llm = DummyLLM() + tts = DummyTTS() + + async def create_agent(**kwargs) -> Agent: + return Agent( + llm=llm, + tts=tts, + edge=stream_edge_mock, + agent_user=User(name="test"), + ) + + launcher = AgentLauncher( + create_agent=create_agent, + agent_idle_timeout=1.0, + agent_idle_cleanup_interval=0.5, + ) + with patch.object(Agent, "idle_for", return_value=10): + # Start the launcher internals + async with launcher: + # Launch a couple of idle agents + agent1 = await launcher.launch() + agent2 = await launcher.launch() + # Sleep 2s to let the launcher clean up the agents + await asyncio.sleep(2) + + # The agents must be closed + assert agent1.closed + assert agent2.closed + + async def test_idle_agents_alive_with_idle_timeout_zero(self, stream_edge_mock): + llm = DummyLLM() + tts = DummyTTS() + + async def create_agent(**kwargs) -> Agent: + return Agent( + llm=llm, + tts=tts, + edge=stream_edge_mock, + agent_user=User(name="test"), + ) + + launcher = AgentLauncher( + create_agent=create_agent, + agent_idle_timeout=0, + ) + with patch.object(Agent, "idle_for", return_value=10): + # Start the launcher internals + async with launcher: + # Launch a couple of idle agents + agent1 = await launcher.launch() + agent2 = await launcher.launch() + # Sleep 2s to let the launcher clean up the agents + await asyncio.sleep(2) + + # The agents must not be closed because agent_idle_timeout=0 + assert not agent1.closed + assert not agent2.closed + + async def test_active_agents_alive(self, stream_edge_mock): + llm = DummyLLM() + tts = DummyTTS() + + async def create_agent(**kwargs) -> Agent: + return Agent( + llm=llm, + tts=tts, + edge=stream_edge_mock, + agent_user=User(name="test"), + ) + + launcher = AgentLauncher( + create_agent=create_agent, + agent_idle_timeout=1.0, + agent_idle_cleanup_interval=0.5, + ) + with patch.object(Agent, "idle_for", return_value=0): + # Start the launcher internals + async with launcher: + # Launch a couple of active agents (idle_for=0) + agent1 = await launcher.launch() + agent2 = await launcher.launch() + # Sleep 2s to let the launcher clean up the agents + await asyncio.sleep(2) + + # The agents must be closed + assert not agent1.closed + assert not agent2.closed From 9544f006bf4fa3b3d7f733cfb477cb97c3978f09 Mon Sep 17 00:00:00 2001 From: Daniil Gusev Date: Wed, 7 Jan 2026 15:15:15 +0100 Subject: [PATCH 09/13] Coderabbit review fixes --- agents-core/vision_agents/core/agents/agent_launcher.py | 2 +- tests/test_agents/test_agent_launcher.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/agents-core/vision_agents/core/agents/agent_launcher.py b/agents-core/vision_agents/core/agents/agent_launcher.py index 6076a5c22..6697f1696 100644 --- a/agents-core/vision_agents/core/agents/agent_launcher.py +++ b/agents-core/vision_agents/core/agents/agent_launcher.py @@ -24,7 +24,7 @@ def __init__( self, create_agent: Callable[..., "Agent" | Awaitable["Agent"]], join_call: Callable[..., None | Awaitable[None]] | None = None, - agent_idle_timeout: float = 10.0, + agent_idle_timeout: float = 30.0, agent_idle_cleanup_interval: float = 5.0, ): """ diff --git a/tests/test_agents/test_agent_launcher.py b/tests/test_agents/test_agent_launcher.py index 94f5c065d..d9877433a 100644 --- a/tests/test_agents/test_agent_launcher.py +++ b/tests/test_agents/test_agent_launcher.py @@ -36,7 +36,7 @@ async def on_warmed_up(self, *_) -> None: @pytest.fixture() async def stream_edge_mock() -> MagicMock: mock = MagicMock() - mock.events.return_value = EventManager() + mock.events = EventManager() return mock @@ -158,6 +158,6 @@ async def create_agent(**kwargs) -> Agent: # Sleep 2s to let the launcher clean up the agents await asyncio.sleep(2) - # The agents must be closed + # The agents must not be closed assert not agent1.closed assert not agent2.closed From 3f1b756f1e507006deac9514d8d430af9abe74a8 Mon Sep 17 00:00:00 2001 From: Daniil Gusev Date: Wed, 7 Jan 2026 15:15:33 +0100 Subject: [PATCH 10/13] Fix missing plugins in pyproject.toml --- pyproject.toml | 4 + uv.lock | 371 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 370 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 56e6c7b64..19a8b293e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,8 @@ vision-agents-plugins-vogent = { workspace = true } vision-agents-plugins-fast-whisper = { workspace = true } vision-agents-plugins-roboflow = { workspace = true } vision-agents-plugins-decart = { workspace = true } +vision-agents-plugins-twilio = { workspace = true } +vision-agents-plugins-turbopuffer = { workspace = true } [tool.uv] # Workspace-level override to resolve numpy version conflicts @@ -64,6 +66,8 @@ members = [ "plugins/fast_whisper", "plugins/roboflow", "plugins/decart", + "plugins/twilio", + "plugins/turbopuffer", ] exclude = [ "**/__pycache__", diff --git a/uv.lock b/uv.lock index 68a6762d2..4dbf56790 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'", @@ -33,6 +33,8 @@ members = [ "vision-agents-plugins-qwen", "vision-agents-plugins-roboflow", "vision-agents-plugins-smart-turn", + "vision-agents-plugins-turbopuffer", + "vision-agents-plugins-twilio", "vision-agents-plugins-ultralytics", "vision-agents-plugins-vogent", "vision-agents-plugins-wizper", @@ -192,6 +194,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/4d/d22668674122c08f4d56972297c51a624e64b3ed1efaa40187607a7cb66e/aiohttp-3.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:ff0a7b0a82a7ab905cbda74006318d1b12e37c797eb1b0d4eb3e316cf47f658f", size = 498093, upload-time = "2025-10-28T20:58:52.782Z" }, ] +[[package]] +name = "aiohttp-retry" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/61/ebda4d8e3d8cfa1fd3db0fb428db2dd7461d5742cea35178277ad180b033/aiohttp_retry-2.9.1.tar.gz", hash = "sha256:8eb75e904ed4ee5c2ec242fefe85bf04240f685391c4879d8f541d6028ff01f1", size = 13608, upload-time = "2024-11-06T10:44:54.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/99/84ba7273339d0f3dfa57901b846489d2e5c2cd731470167757f1935fffbd/aiohttp_retry-2.9.1-py3-none-any.whl", hash = "sha256:66d2759d1921838256a05a3f80ad7e724936f083e35be5abb5e16eed6be6dc54", size = 9981, upload-time = "2024-11-06T10:44:52.917Z" }, +] + [[package]] name = "aioice" version = "0.10.1" @@ -268,6 +282,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -1346,6 +1369,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/dc/0012b95c05448264329ba71ad568e440f12b7f5acdf2ddc09fa1aec42e0d/fal_client-0.8.1-py3-none-any.whl", hash = "sha256:ab37063f2b35ca6fad06f75fe45b05b72c774ad7590e0f93d47a8f6ad5e1f9db", size = 10912, upload-time = "2025-10-15T19:33:07.797Z" }, ] +[[package]] +name = "fastapi" +version = "0.128.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" }, +] + [[package]] name = "faster-whisper" version = "1.2.1" @@ -1588,13 +1626,14 @@ wheels = [ [[package]] name = "getstream" -version = "2.5.19" +version = "2.5.20" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dataclasses-json" }, { name = "httpx" }, { name = "ijson" }, { name = "marshmallow" }, + { name = "protobuf" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyee" }, @@ -1602,9 +1641,9 @@ dependencies = [ { name = "python-dateutil" }, { name = "twirp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/5a/d8db2ea5f41e237320aeba1281866dba2076f0a457aebab823c8bae6f0f6/getstream-2.5.19.tar.gz", hash = "sha256:c517cb52037ec606e734068556641fe764ddc1338c3e4eb37fce8e28f93b418c", size = 443976, upload-time = "2026-01-06T02:08:50.872Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/99/5d463c00cb905ee7fa4a5e59c4af6f4d4967bf8795e79a32280520fefca2/getstream-2.5.20.tar.gz", hash = "sha256:fd777c75bc8e277933a39221fbf4f870063b0fdad19b70b0b93765483b12d2ff", size = 434715, upload-time = "2026-01-06T18:59:47.331Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/e8/0ec692109e6d8d5ce25ed726f2937bc807d27e1db0fb965f5512d51e4880/getstream-2.5.19-py3-none-any.whl", hash = "sha256:9442988025425e40e98936c8b8980c64b8c231ba56430082429bc07ad8613836", size = 253552, upload-time = "2026-01-06T02:08:49.215Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e2/1a8d05fe3de33151cf2b9fa5178d0db7aa1176b3278c89de0b2a1b1ce385/getstream-2.5.20-py3-none-any.whl", hash = "sha256:c3eee5a6d89659d9cdf1107255bb52b56c8198533403ddd79837c7fd16ebd42b", size = 260060, upload-time = "2026-01-06T18:59:45.925Z" }, ] [package.optional-dependencies] @@ -2293,6 +2332,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/e8/685f47e0d754320684db4425a0967f7d3fa70126bffd76110b7009a0090f/joblib-1.5.2-py3-none-any.whl", hash = "sha256:4e1f0bdbb987e6d843c70cf43714cb276623def372df3c22fe5266b2670bc241", size = 308396, upload-time = "2025-08-27T12:15:45.188Z" }, ] +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, +] + [[package]] name = "jsonschema" version = "4.25.1" @@ -2462,6 +2522,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/cc/75f41633c75224ba820a4533163bc8b070b6bf25416014074c63284c2d4e/kokoro-0.9.4-py3-none-any.whl", hash = "sha256:a129dc6364a286bd6a92c396e9862459d3d3e45f2c15596ed5a94dcee5789efd", size = 32592, upload-time = "2025-04-05T22:01:23.018Z" }, ] +[[package]] +name = "langchain-core" +version = "1.2.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/ce/ba5ed5ea6df22965b2893c2ed28ebb456204962723d408904c4acfa5e942/langchain_core-1.2.6.tar.gz", hash = "sha256:b4e7841dd7f8690375aa07c54739178dc2c635147d475e0c2955bf82a1afa498", size = 833343, upload-time = "2026-01-02T21:35:44.749Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/40/0655892c245d8fbe6bca6d673ab5927e5c3ab7be143de40b52289a0663bc/langchain_core-1.2.6-py3-none-any.whl", hash = "sha256:aa6ed954b4b1f4504937fe75fdf674317027e9a91ba7a97558b0de3dc8004e34", size = 489096, upload-time = "2026-01-02T21:35:43.391Z" }, +] + +[[package]] +name = "langchain-google-genai" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filetype" }, + { name = "google-genai" }, + { name = "langchain-core" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/05/3ffc616cb7f74d5c5f95ded8d92c4a259cbd39740d8dbbad767a3d61cd1f/langchain_google_genai-4.1.1.tar.gz", hash = "sha256:8278827d34dac5c5c01aa0fdf602654781d03fe82266f88aeb6863e4ff723b8d", size = 275538, upload-time = "2025-12-17T03:42:24.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/37/05c1dd1d188feead3a071cf8069e8c0d0860cb4136d5c67b1f7467ec9aeb/langchain_google_genai-4.1.1-py3-none-any.whl", hash = "sha256:748a11fa47c0f4892e2b1d2a541372ab015012c64ac580b94c5b9a59aa829368", size = 65508, upload-time = "2025-12-17T03:42:23.988Z" }, +] + +[[package]] +name = "langchain-text-splitters" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/42/c178dcdc157b473330eb7cc30883ea69b8ec60078c7b85e2d521054c4831/langchain_text_splitters-1.1.0.tar.gz", hash = "sha256:75e58acb7585dc9508f3cd9d9809cb14751283226c2d6e21fb3a9ae57582ca22", size = 272230, upload-time = "2025-12-14T01:15:38.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/1a/a84ed1c046deecf271356b0179c1b9fba95bfdaa6f934e1849dee26fad7b/langchain_text_splitters-1.1.0-py3-none-any.whl", hash = "sha256:f00341fe883358786104a5f881375ac830a4dd40253ecd42b4c10536c6e4693f", size = 34182, upload-time = "2025-12-14T01:15:37.382Z" }, +] + +[[package]] +name = "langsmith" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "uuid-utils" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/a3/d36a9935fd1215e21d022a5773b243b6eec12ba11fde3eb8ba1f8384b01e/langsmith-0.6.1.tar.gz", hash = "sha256:bf35f9ffa592d602d5b11d23890d51342f321ac7f5e0cb6a22ab48fbdb88853a", size = 884701, upload-time = "2026-01-06T20:15:38.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/01/9a3f0ff60afcb30383ea9775e9f9a233c0127bad7c786d878f78b487bebb/langsmith-0.6.1-py3-none-any.whl", hash = "sha256:cad1f0a5cb8baf01490d2d90b7515d2cecc31648237bf070d2e6c0e7d58a2079", size = 282977, upload-time = "2026-01-06T20:15:36.579Z" }, +] + [[package]] name = "lazy-loader" version = "0.4" @@ -4221,6 +4346,124 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, ] +[[package]] +name = "pybase64" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/b8/4ed5c7ad5ec15b08d35cc79ace6145d5c1ae426e46435f4987379439dfea/pybase64-1.4.3.tar.gz", hash = "sha256:c2ed274c9e0ba9c8f9c4083cfe265e66dd679126cd9c2027965d807352f3f053", size = 137272, upload-time = "2025-12-06T13:27:04.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/a7/efcaa564f091a2af7f18a83c1c4875b1437db56ba39540451dc85d56f653/pybase64-1.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:18d85e5ab8b986bb32d8446aca6258ed80d1bafe3603c437690b352c648f5967", size = 38167, upload-time = "2025-12-06T13:23:16.821Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/c7ad35adff2d272bf2930132db2b3eea8c44bb1b1f64eb9b2b8e57cde7b4/pybase64-1.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3f5791a3491d116d0deaf4d83268f48792998519698f8751efb191eac84320e9", size = 31673, upload-time = "2025-12-06T13:23:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/43/1b/9a8cab0042b464e9a876d5c65fe5127445a2436da36fda64899b119b1a1b/pybase64-1.4.3-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f0b3f200c3e06316f6bebabd458b4e4bcd4c2ca26af7c0c766614d91968dee27", size = 68210, upload-time = "2025-12-06T13:23:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/62/f7/965b79ff391ad208b50e412b5d3205ccce372a2d27b7218ae86d5295b105/pybase64-1.4.3-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb632edfd132b3eaf90c39c89aa314beec4e946e210099b57d40311f704e11d4", size = 71599, upload-time = "2025-12-06T13:23:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/03/4b/a3b5175130b3810bbb8ccfa1edaadbd3afddb9992d877c8a1e2f274b476e/pybase64-1.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:356ef1d74648ce997f5a777cf8f1aefecc1c0b4fe6201e0ef3ec8a08170e1b54", size = 59922, upload-time = "2025-12-06T13:23:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/da/5d/c38d1572027fc601b62d7a407721688b04b4d065d60ca489912d6893e6cf/pybase64-1.4.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:c48361f90db32bacaa5518419d4eb9066ba558013aaf0c7781620279ecddaeb9", size = 56712, upload-time = "2025-12-06T13:23:22.77Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d4/4e04472fef485caa8f561d904d4d69210a8f8fc1608ea15ebd9012b92655/pybase64-1.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:702bcaa16ae02139d881aeaef5b1c8ffb4a3fae062fe601d1e3835e10310a517", size = 59300, upload-time = "2025-12-06T13:23:24.543Z" }, + { url = "https://files.pythonhosted.org/packages/86/e7/16e29721b86734b881d09b7e23dfd7c8408ad01a4f4c7525f3b1088e25ec/pybase64-1.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:53d0ffe1847b16b647c6413d34d1de08942b7724273dd57e67dcbdb10c574045", size = 60278, upload-time = "2025-12-06T13:23:25.608Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/18515f211d7c046be32070709a8efeeef8a0203de4fd7521e6b56404731b/pybase64-1.4.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9a1792e8b830a92736dae58f0c386062eb038dfe8004fb03ba33b6083d89cd43", size = 54817, upload-time = "2025-12-06T13:23:26.633Z" }, + { url = "https://files.pythonhosted.org/packages/e7/be/14e29d8e1a481dbff151324c96dd7b5d2688194bb65dc8a00ca0e1ad1e86/pybase64-1.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d468b1b1ac5ad84875a46eaa458663c3721e8be5f155ade356406848d3701f6", size = 58611, upload-time = "2025-12-06T13:23:27.684Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8a/a2588dfe24e1bbd742a554553778ab0d65fdf3d1c9a06d10b77047d142aa/pybase64-1.4.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e97b7bdbd62e71898cd542a6a9e320d9da754ff3ebd02cb802d69087ee94d468", size = 52404, upload-time = "2025-12-06T13:23:28.714Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/afcda7445bebe0cbc38cafdd7813234cdd4fc5573ff067f1abf317bb0cec/pybase64-1.4.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b33aeaa780caaa08ffda87fc584d5eab61e3d3bbb5d86ead02161dc0c20d04bc", size = 68817, upload-time = "2025-12-06T13:23:30.079Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3a/87c3201e555ed71f73e961a787241a2438c2bbb2ca8809c29ddf938a3157/pybase64-1.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c0efcf78f11cf866bed49caa7b97552bc4855a892f9cc2372abcd3ed0056f0d", size = 57854, upload-time = "2025-12-06T13:23:31.17Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7d/931c2539b31a7b375e7d595b88401eeb5bd6c5ce1059c9123f9b608aaa14/pybase64-1.4.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:66e3791f2ed725a46593f8bd2761ff37d01e2cdad065b1dceb89066f476e50c6", size = 54333, upload-time = "2025-12-06T13:23:32.422Z" }, + { url = "https://files.pythonhosted.org/packages/de/5e/537601e02cc01f27e9d75f440f1a6095b8df44fc28b1eef2cd739aea8cec/pybase64-1.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:72bb0b6bddadab26e1b069bb78e83092711a111a80a0d6b9edcb08199ad7299b", size = 56492, upload-time = "2025-12-06T13:23:33.515Z" }, + { url = "https://files.pythonhosted.org/packages/96/97/2a2e57acf8f5c9258d22aba52e71f8050e167b29ed2ee1113677c1b600c1/pybase64-1.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5b3365dbcbcdb0a294f0f50af0c0a16b27a232eddeeb0bceeefd844ef30d2a23", size = 70974, upload-time = "2025-12-06T13:23:36.27Z" }, + { url = "https://files.pythonhosted.org/packages/75/2e/a9e28941c6dab6f06e6d3f6783d3373044be9b0f9a9d3492c3d8d2260ac0/pybase64-1.4.3-cp312-cp312-win32.whl", hash = "sha256:7bca1ed3a5df53305c629ca94276966272eda33c0d71f862d2d3d043f1e1b91a", size = 33686, upload-time = "2025-12-06T13:23:37.848Z" }, + { url = "https://files.pythonhosted.org/packages/83/e3/507ab649d8c3512c258819c51d25c45d6e29d9ca33992593059e7b646a33/pybase64-1.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:9f2da8f56d9b891b18b4daf463a0640eae45a80af548ce435be86aa6eff3603b", size = 35833, upload-time = "2025-12-06T13:23:38.877Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8a/6eba66cd549a2fc74bb4425fd61b839ba0ab3022d3c401b8a8dc2cc00c7a/pybase64-1.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:0631d8a2d035de03aa9bded029b9513e1fee8ed80b7ddef6b8e9389ffc445da0", size = 31185, upload-time = "2025-12-06T13:23:39.908Z" }, + { url = "https://files.pythonhosted.org/packages/3a/50/b7170cb2c631944388fe2519507fe3835a4054a6a12a43f43781dae82be1/pybase64-1.4.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:ea4b785b0607d11950b66ce7c328f452614aefc9c6d3c9c28bae795dc7f072e1", size = 33901, upload-time = "2025-12-06T13:23:40.951Z" }, + { url = "https://files.pythonhosted.org/packages/48/8b/69f50578e49c25e0a26e3ee72c39884ff56363344b79fc3967f5af420ed6/pybase64-1.4.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:6a10b6330188c3026a8b9c10e6b9b3f2e445779cf16a4c453d51a072241c65a2", size = 40807, upload-time = "2025-12-06T13:23:42.006Z" }, + { url = "https://files.pythonhosted.org/packages/5c/8d/20b68f11adfc4c22230e034b65c71392e3e338b413bf713c8945bd2ccfb3/pybase64-1.4.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:27fdff227a0c0e182e0ba37a99109645188978b920dfb20d8b9c17eeee370d0d", size = 30932, upload-time = "2025-12-06T13:23:43.348Z" }, + { url = "https://files.pythonhosted.org/packages/f7/79/b1b550ac6bff51a4880bf6e089008b2e1ca16f2c98db5e039a08ac3ad157/pybase64-1.4.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2a8204f1fdfec5aa4184249b51296c0de95445869920c88123978304aad42df1", size = 31394, upload-time = "2025-12-06T13:23:44.317Z" }, + { url = "https://files.pythonhosted.org/packages/82/70/b5d7c5932bf64ee1ec5da859fbac981930b6a55d432a603986c7f509c838/pybase64-1.4.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:874fc2a3777de6baf6aa921a7aa73b3be98295794bea31bd80568a963be30767", size = 38078, upload-time = "2025-12-06T13:23:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/e66fe373bce717c6858427670736d54297938dad61c5907517ab4106bd90/pybase64-1.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2dc64a94a9d936b8e3449c66afabbaa521d3cc1a563d6bbaaa6ffa4535222e4b", size = 38158, upload-time = "2025-12-06T13:23:46.872Z" }, + { url = "https://files.pythonhosted.org/packages/80/a9/b806ed1dcc7aed2ea3dd4952286319e6f3a8b48615c8118f453948e01999/pybase64-1.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e48f86de1c145116ccf369a6e11720ce696c2ec02d285f440dfb57ceaa0a6cb4", size = 31672, upload-time = "2025-12-06T13:23:47.88Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c9/24b3b905cf75e23a9a4deaf203b35ffcb9f473ac0e6d8257f91a05dfce62/pybase64-1.4.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:1d45c8fe8fe82b65c36b227bb4a2cf623d9ada16bed602ce2d3e18c35285b72a", size = 68244, upload-time = "2025-12-06T13:23:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cd/d15b0c3e25e5859fab0416dc5b96d34d6bd2603c1c96a07bb2202b68ab92/pybase64-1.4.3-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ad70c26ba091d8f5167e9d4e1e86a0483a5414805cdb598a813db635bd3be8b8", size = 71620, upload-time = "2025-12-06T13:23:50.081Z" }, + { url = "https://files.pythonhosted.org/packages/0d/31/4ca953cc3dcde2b3711d6bfd70a6f4ad2ca95a483c9698076ba605f1520f/pybase64-1.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e98310b7c43145221e7194ac9fa7fffc84763c87bfc5e2f59f9f92363475bdc1", size = 59930, upload-time = "2025-12-06T13:23:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/60/55/e7f7bdcd0fd66e61dda08db158ffda5c89a306bbdaaf5a062fbe4e48f4a1/pybase64-1.4.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:398685a76034e91485a28aeebcb49e64cd663212fd697b2497ac6dfc1df5e671", size = 56425, upload-time = "2025-12-06T13:23:52.732Z" }, + { url = "https://files.pythonhosted.org/packages/cb/65/b592c7f921e51ca1aca3af5b0d201a98666d0a36b930ebb67e7c2ed27395/pybase64-1.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7e46400a6461187ccb52ed75b0045d937529e801a53a9cd770b350509f9e4d50", size = 59327, upload-time = "2025-12-06T13:23:53.856Z" }, + { url = "https://files.pythonhosted.org/packages/23/95/1613d2fb82dbb1548595ad4179f04e9a8451bfa18635efce18b631eabe3f/pybase64-1.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:1b62b9f2f291d94f5e0b76ab499790b7dcc78a009d4ceea0b0428770267484b6", size = 60294, upload-time = "2025-12-06T13:23:54.937Z" }, + { url = "https://files.pythonhosted.org/packages/9d/73/40431f37f7d1b3eab4673e7946ff1e8f5d6bd425ec257e834dae8a6fc7b0/pybase64-1.4.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:f30ceb5fa4327809dede614be586efcbc55404406d71e1f902a6fdcf322b93b2", size = 54858, upload-time = "2025-12-06T13:23:56.031Z" }, + { url = "https://files.pythonhosted.org/packages/a7/84/f6368bcaf9f743732e002a9858646fd7a54f428490d427dd6847c5cfe89e/pybase64-1.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0d5f18ed53dfa1d4cf8b39ee542fdda8e66d365940e11f1710989b3cf4a2ed66", size = 58629, upload-time = "2025-12-06T13:23:57.12Z" }, + { url = "https://files.pythonhosted.org/packages/43/75/359532f9adb49c6b546cafc65c46ed75e2ccc220d514ba81c686fbd83965/pybase64-1.4.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:119d31aa4b58b85a8ebd12b63c07681a138c08dfc2fe5383459d42238665d3eb", size = 52448, upload-time = "2025-12-06T13:23:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/92/6c/ade2ba244c3f33ed920a7ed572ad772eb0b5f14480b72d629d0c9e739a40/pybase64-1.4.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3cf0218b0e2f7988cf7d738a73b6a1d14f3be6ce249d7c0f606e768366df2cce", size = 68841, upload-time = "2025-12-06T13:23:59.886Z" }, + { url = "https://files.pythonhosted.org/packages/a0/51/b345139cd236be382f2d4d4453c21ee6299e14d2f759b668e23080f8663f/pybase64-1.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:12f4ee5e988bc5c0c1106b0d8fc37fb0508f12dab76bac1b098cb500d148da9d", size = 57910, upload-time = "2025-12-06T13:24:00.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b8/9f84bdc4f1c4f0052489396403c04be2f9266a66b70c776001eaf0d78c1f/pybase64-1.4.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:937826bc7b6b95b594a45180e81dd4d99bd4dd4814a443170e399163f7ff3fb6", size = 54335, upload-time = "2025-12-06T13:24:02.046Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c7/be63b617d284de46578a366da77ede39c8f8e815ed0d82c7c2acca560fab/pybase64-1.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:88995d1460971ef80b13e3e007afbe4b27c62db0508bc7250a2ab0a0b4b91362", size = 56486, upload-time = "2025-12-06T13:24:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/5e/96/f252c8f9abd6ded3ef1ccd3cdbb8393a33798007f761b23df8de1a2480e6/pybase64-1.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:72326fe163385ed3e1e806dd579d47fde5d8a59e51297a60fc4e6cbc1b4fc4ed", size = 70978, upload-time = "2025-12-06T13:24:04.221Z" }, + { url = "https://files.pythonhosted.org/packages/af/51/0f5714af7aeef96e30f968e4371d75ad60558aaed3579d7c6c8f1c43c18a/pybase64-1.4.3-cp313-cp313-win32.whl", hash = "sha256:b1623730c7892cf5ed0d6355e375416be6ef8d53ab9b284f50890443175c0ac3", size = 33684, upload-time = "2025-12-06T13:24:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ad/0cea830a654eb08563fb8214150ef57546ece1cc421c09035f0e6b0b5ea9/pybase64-1.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:8369887590f1646a5182ca2fb29252509da7ae31d4923dbb55d3e09da8cc4749", size = 35832, upload-time = "2025-12-06T13:24:06.35Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/eec2a8214989c751bc7b4cad1860eb2c6abf466e76b77508c0f488c96a37/pybase64-1.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:860b86bca71e5f0237e2ab8b2d9c4c56681f3513b1bf3e2117290c1963488390", size = 31175, upload-time = "2025-12-06T13:24:07.419Z" }, + { url = "https://files.pythonhosted.org/packages/db/c9/e23463c1a2913686803ef76b1a5ae7e6fac868249a66e48253d17ad7232c/pybase64-1.4.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:eb51db4a9c93215135dccd1895dca078e8785c357fabd983c9f9a769f08989a9", size = 38497, upload-time = "2025-12-06T13:24:08.873Z" }, + { url = "https://files.pythonhosted.org/packages/71/83/343f446b4b7a7579bf6937d2d013d82f1a63057cf05558e391ab6039d7db/pybase64-1.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a03ef3f529d85fd46b89971dfb00c634d53598d20ad8908fb7482955c710329d", size = 32076, upload-time = "2025-12-06T13:24:09.975Z" }, + { url = "https://files.pythonhosted.org/packages/46/fc/cb64964c3b29b432f54d1bce5e7691d693e33bbf780555151969ffd95178/pybase64-1.4.3-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:2e745f2ce760c6cf04d8a72198ef892015ddb89f6ceba489e383518ecbdb13ab", size = 72317, upload-time = "2025-12-06T13:24:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b7/fab2240da6f4e1ad46f71fa56ec577613cf5df9dce2d5b4cfaa4edd0e365/pybase64-1.4.3-cp313-cp313t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fac217cd9de8581a854b0ac734c50fd1fa4b8d912396c1fc2fce7c230efe3a7", size = 75534, upload-time = "2025-12-06T13:24:12.433Z" }, + { url = "https://files.pythonhosted.org/packages/91/3b/3e2f2b6e68e3d83ddb9fa799f3548fb7449765daec9bbd005a9fbe296d7f/pybase64-1.4.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:da1ee8fa04b283873de2d6e8fa5653e827f55b86bdf1a929c5367aaeb8d26f8a", size = 65399, upload-time = "2025-12-06T13:24:13.928Z" }, + { url = "https://files.pythonhosted.org/packages/6b/08/476ac5914c3b32e0274a2524fc74f01cbf4f4af4513d054e41574eb018f6/pybase64-1.4.3-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:b0bf8e884ee822ca7b1448eeb97fa131628fe0ff42f60cae9962789bd562727f", size = 60487, upload-time = "2025-12-06T13:24:15.177Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b8/618a92915330cc9cba7880299b546a1d9dab1a21fd6c0292ee44a4fe608c/pybase64-1.4.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1bf749300382a6fd1f4f255b183146ef58f8e9cb2f44a077b3a9200dfb473a77", size = 63959, upload-time = "2025-12-06T13:24:16.854Z" }, + { url = "https://files.pythonhosted.org/packages/a5/52/af9d8d051652c3051862c442ec3861259c5cdb3fc69774bc701470bd2a59/pybase64-1.4.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:153a0e42329b92337664cfc356f2065248e6c9a1bd651bbcd6dcaf15145d3f06", size = 64874, upload-time = "2025-12-06T13:24:18.328Z" }, + { url = "https://files.pythonhosted.org/packages/e4/51/5381a7adf1f381bd184d33203692d3c57cf8ae9f250f380c3fecbdbe554b/pybase64-1.4.3-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:86ee56ac7f2184ca10217ed1c655c1a060273e233e692e9086da29d1ae1768db", size = 58572, upload-time = "2025-12-06T13:24:19.417Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f0/578ee4ffce5818017de4fdf544e066c225bc435e73eb4793cde28a689d0b/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0e71a4db76726bf830b47477e7d830a75c01b2e9b01842e787a0836b0ba741e3", size = 63636, upload-time = "2025-12-06T13:24:20.497Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ad/8ae94814bf20159ea06310b742433e53d5820aa564c9fdf65bf2d79f8799/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2ba7799ec88540acd9861b10551d24656ca3c2888ecf4dba2ee0a71544a8923f", size = 56193, upload-time = "2025-12-06T13:24:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/d1/31/6438cfcc3d3f0fa84d229fa125c243d5094e72628e525dfefadf3bcc6761/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2860299e4c74315f5951f0cf3e72ba0f201c3356c8a68f95a3ab4e620baf44e9", size = 72655, upload-time = "2025-12-06T13:24:22.673Z" }, + { url = "https://files.pythonhosted.org/packages/a3/0d/2bbc9e9c3fc12ba8a6e261482f03a544aca524f92eae0b4908c0a10ba481/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:bb06015db9151f0c66c10aae8e3603adab6b6cd7d1f7335a858161d92fc29618", size = 62471, upload-time = "2025-12-06T13:24:23.8Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0b/34d491e7f49c1dbdb322ea8da6adecda7c7cd70b6644557c6e4ca5c6f7c7/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:242512a070817272865d37c8909059f43003b81da31f616bb0c391ceadffe067", size = 58119, upload-time = "2025-12-06T13:24:24.994Z" }, + { url = "https://files.pythonhosted.org/packages/ce/17/c21d0cde2a6c766923ae388fc1f78291e1564b0d38c814b5ea8a0e5e081c/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5d8277554a12d3e3eed6180ebda62786bf9fc8d7bb1ee00244258f4a87ca8d20", size = 60791, upload-time = "2025-12-06T13:24:26.046Z" }, + { url = "https://files.pythonhosted.org/packages/92/b2/eaa67038916a48de12b16f4c384bcc1b84b7ec731b23613cb05f27673294/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f40b7ddd698fc1e13a4b64fbe405e4e0e1279e8197e37050e24154655f5f7c4e", size = 74701, upload-time = "2025-12-06T13:24:27.466Z" }, + { url = "https://files.pythonhosted.org/packages/42/10/abb7757c330bb869ebb95dab0c57edf5961ffbd6c095c8209cbbf75d117d/pybase64-1.4.3-cp313-cp313t-win32.whl", hash = "sha256:46d75c9387f354c5172582a9eaae153b53a53afeb9c19fcf764ea7038be3bd8b", size = 33965, upload-time = "2025-12-06T13:24:28.548Z" }, + { url = "https://files.pythonhosted.org/packages/63/a0/2d4e5a59188e9e6aed0903d580541aaea72dcbbab7bf50fb8b83b490b6c3/pybase64-1.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:d7344625591d281bec54e85cbfdab9e970f6219cac1570f2aa140b8c942ccb81", size = 36207, upload-time = "2025-12-06T13:24:29.646Z" }, + { url = "https://files.pythonhosted.org/packages/1f/05/95b902e8f567b4d4b41df768ccc438af618f8d111e54deaf57d2df46bd76/pybase64-1.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:28a3c60c55138e0028313f2eccd321fec3c4a0be75e57a8d3eb883730b1b0880", size = 31505, upload-time = "2025-12-06T13:24:30.687Z" }, + { url = "https://files.pythonhosted.org/packages/e4/80/4bd3dff423e5a91f667ca41982dc0b79495b90ec0c0f5d59aca513e50f8c/pybase64-1.4.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:015bb586a1ea1467f69d57427abe587469392215f59db14f1f5c39b52fdafaf5", size = 33835, upload-time = "2025-12-06T13:24:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/45/60/a94d94cc1e3057f602e0b483c9ebdaef40911d84a232647a2fe593ab77bb/pybase64-1.4.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d101e3a516f837c3dcc0e5a0b7db09582ebf99ed670865223123fb2e5839c6c0", size = 40673, upload-time = "2025-12-06T13:24:32.82Z" }, + { url = "https://files.pythonhosted.org/packages/e3/71/cf62b261d431857e8e054537a5c3c24caafa331de30daede7b2c6c558501/pybase64-1.4.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8f183ac925a48046abe047360fe3a1b28327afb35309892132fe1915d62fb282", size = 30939, upload-time = "2025-12-06T13:24:34.001Z" }, + { url = "https://files.pythonhosted.org/packages/24/3e/d12f92a3c1f7c6ab5d53c155bff9f1084ba997a37a39a4f781ccba9455f3/pybase64-1.4.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30bf3558e24dcce4da5248dcf6d73792adfcf4f504246967e9db155be4c439ad", size = 31401, upload-time = "2025-12-06T13:24:35.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3d/9c27440031fea0d05146f8b70a460feb95d8b4e3d9ca8f45c972efb4c3d3/pybase64-1.4.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a674b419de318d2ce54387dd62646731efa32b4b590907800f0bd40675c1771d", size = 38075, upload-time = "2025-12-06T13:24:36.53Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d4/6c0e0cf0efd53c254173fbcd84a3d8fcbf5e0f66622473da425becec32a5/pybase64-1.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:720104fd7303d07bac302be0ff8f7f9f126f2f45c1edb4f48fdb0ff267e69fe1", size = 38257, upload-time = "2025-12-06T13:24:38.049Z" }, + { url = "https://files.pythonhosted.org/packages/50/eb/27cb0b610d5cd70f5ad0d66c14ad21c04b8db930f7139818e8fbdc14df4d/pybase64-1.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:83f1067f73fa5afbc3efc0565cecc6ed53260eccddef2ebe43a8ce2b99ea0e0a", size = 31685, upload-time = "2025-12-06T13:24:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/db/26/b136a4b65e5c94ff06217f7726478df3f31ab1c777c2c02cf698e748183f/pybase64-1.4.3-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b51204d349a4b208287a8aa5b5422be3baa88abf6cc8ff97ccbda34919bbc857", size = 68460, upload-time = "2025-12-06T13:24:41.735Z" }, + { url = "https://files.pythonhosted.org/packages/68/6d/84ce50e7ee1ae79984d689e05a9937b2460d4efa1e5b202b46762fb9036c/pybase64-1.4.3-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30f2fd53efecbdde4bdca73a872a68dcb0d1bf8a4560c70a3e7746df973e1ef3", size = 71688, upload-time = "2025-12-06T13:24:42.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/57/6743e420416c3ff1b004041c85eb0ebd9c50e9cf05624664bfa1dc8b5625/pybase64-1.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0932b0c5cfa617091fd74f17d24549ce5de3628791998c94ba57be808078eeaf", size = 60040, upload-time = "2025-12-06T13:24:44.37Z" }, + { url = "https://files.pythonhosted.org/packages/3b/68/733324e28068a89119af2921ce548e1c607cc5c17d354690fc51c302e326/pybase64-1.4.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:acb61f5ab72bec808eb0d4ce8b87ec9f38d7d750cb89b1371c35eb8052a29f11", size = 56478, upload-time = "2025-12-06T13:24:45.815Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9e/f3f4aa8cfe3357a3cdb0535b78eb032b671519d3ecc08c58c4c6b72b5a91/pybase64-1.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:2bc2d5bc15168f5c04c53bdfe5a1e543b2155f456ed1e16d7edce9ce73842021", size = 59463, upload-time = "2025-12-06T13:24:46.938Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d1/53286038e1f0df1cf58abcf4a4a91b0f74ab44539c2547b6c31001ddd054/pybase64-1.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:8a7bc3cd23880bdca59758bcdd6f4ef0674f2393782763910a7466fab35ccb98", size = 60360, upload-time = "2025-12-06T13:24:48.039Z" }, + { url = "https://files.pythonhosted.org/packages/00/9a/5cc6ce95db2383d27ff4d790b8f8b46704d360d701ab77c4f655bcfaa6a7/pybase64-1.4.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ad15acf618880d99792d71e3905b0e2508e6e331b76a1b34212fa0f11e01ad28", size = 54999, upload-time = "2025-12-06T13:24:49.547Z" }, + { url = "https://files.pythonhosted.org/packages/64/e7/c3c1d09c3d7ae79e3aa1358c6d912d6b85f29281e47aa94fc0122a415a2f/pybase64-1.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448158d417139cb4851200e5fee62677ae51f56a865d50cda9e0d61bda91b116", size = 58736, upload-time = "2025-12-06T13:24:50.641Z" }, + { url = "https://files.pythonhosted.org/packages/db/d5/0baa08e3d8119b15b588c39f0d39fd10472f0372e3c54ca44649cbefa256/pybase64-1.4.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9058c49b5a2f3e691b9db21d37eb349e62540f9f5fc4beabf8cbe3c732bead86", size = 52298, upload-time = "2025-12-06T13:24:51.791Z" }, + { url = "https://files.pythonhosted.org/packages/00/87/fc6f11474a1de7e27cd2acbb8d0d7508bda3efa73dfe91c63f968728b2a3/pybase64-1.4.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ce561724f6522907a66303aca27dce252d363fcd85884972d348f4403ba3011a", size = 69049, upload-time = "2025-12-06T13:24:53.253Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/7fb5566f669ac18b40aa5fc1c438e24df52b843c1bdc5da47d46d4c1c630/pybase64-1.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:63316560a94ac449fe86cb8b9e0a13714c659417e92e26a5cbf085cd0a0c838d", size = 57952, upload-time = "2025-12-06T13:24:54.342Z" }, + { url = "https://files.pythonhosted.org/packages/de/cc/ceb949232dbbd3ec4ee0190d1df4361296beceee9840390a63df8bc31784/pybase64-1.4.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7ecd796f2ac0be7b73e7e4e232b8c16422014de3295d43e71d2b19fd4a4f5368", size = 54484, upload-time = "2025-12-06T13:24:55.774Z" }, + { url = "https://files.pythonhosted.org/packages/a7/69/659f3c8e6a5d7b753b9c42a4bd9c42892a0f10044e9c7351a4148d413a33/pybase64-1.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d01e102a12fb2e1ed3dc11611c2818448626637857ec3994a9cf4809dfd23477", size = 56542, upload-time = "2025-12-06T13:24:57Z" }, + { url = "https://files.pythonhosted.org/packages/85/2c/29c9e6c9c82b72025f9676f9e82eb1fd2339ad038cbcbf8b9e2ac02798fc/pybase64-1.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ebff797a93c2345f22183f454fd8607a34d75eca5a3a4a969c1c75b304cee39d", size = 71045, upload-time = "2025-12-06T13:24:58.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/84/5a3dce8d7a0040a5c0c14f0fe1311cd8db872913fa04438071b26b0dac04/pybase64-1.4.3-cp314-cp314-win32.whl", hash = "sha256:28b2a1bb0828c0595dc1ea3336305cd97ff85b01c00d81cfce4f92a95fb88f56", size = 34200, upload-time = "2025-12-06T13:24:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/57/bc/ce7427c12384adee115b347b287f8f3cf65860b824d74fe2c43e37e81c1f/pybase64-1.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:33338d3888700ff68c3dedfcd49f99bfc3b887570206130926791e26b316b029", size = 36323, upload-time = "2025-12-06T13:25:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1b/2b8ffbe9a96eef7e3f6a5a7be75995eebfb6faaedc85b6da6b233e50c778/pybase64-1.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:62725669feb5acb186458da2f9353e88ae28ef66bb9c4c8d1568b12a790dfa94", size = 31584, upload-time = "2025-12-06T13:25:02.801Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/6824c2e6fb45b8fa4e7d92e3c6805432d5edc7b855e3e8e1eedaaf6efb7c/pybase64-1.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:153fe29be038948d9372c3e77ae7d1cab44e4ba7d9aaf6f064dbeea36e45b092", size = 38601, upload-time = "2025-12-06T13:25:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e5/10d2b3a4ad3a4850be2704a2f70cd9c0cf55725c8885679872d3bc846c67/pybase64-1.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f7fe3decaa7c4a9e162327ec7bd81ce183d2b16f23c6d53b606649c6e0203e9e", size = 32078, upload-time = "2025-12-06T13:25:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/43/04/8b15c34d3c2282f1c1b0850f1113a249401b618a382646a895170bc9b5e7/pybase64-1.4.3-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:a5ae04ea114c86eb1da1f6e18d75f19e3b5ae39cb1d8d3cd87c29751a6a22780", size = 72474, upload-time = "2025-12-06T13:25:06.434Z" }, + { url = "https://files.pythonhosted.org/packages/42/00/f34b4d11278f8fdc68bc38f694a91492aa318f7c6f1bd7396197ac0f8b12/pybase64-1.4.3-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1755b3dce3a2a5c7d17ff6d4115e8bee4a1d5aeae74469db02e47c8f477147da", size = 75706, upload-time = "2025-12-06T13:25:07.636Z" }, + { url = "https://files.pythonhosted.org/packages/bb/5d/71747d4ad7fe16df4c4c852bdbdeb1f2cf35677b48d7c34d3011a7a6ad3a/pybase64-1.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb852f900e27ffc4ec1896817535a0fa19610ef8875a096b59f21d0aa42ff172", size = 65589, upload-time = "2025-12-06T13:25:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/49/b1/d1e82bd58805bb5a3a662864800bab83a83a36ba56e7e3b1706c708002a5/pybase64-1.4.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:9cf21ea8c70c61eddab3421fbfce061fac4f2fb21f7031383005a1efdb13d0b9", size = 60670, upload-time = "2025-12-06T13:25:10.04Z" }, + { url = "https://files.pythonhosted.org/packages/15/67/16c609b7a13d1d9fc87eca12ba2dce5e67f949eeaab61a41bddff843cbb0/pybase64-1.4.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:afff11b331fdc27692fc75e85ae083340a35105cea1a3c4552139e2f0e0d174f", size = 64194, upload-time = "2025-12-06T13:25:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/37bc724e42960f0106c2d33dc957dcec8f760c91a908cc6c0df7718bc1a8/pybase64-1.4.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9a5143df542c1ce5c1f423874b948c4d689b3f05ec571f8792286197a39ba02", size = 64984, upload-time = "2025-12-06T13:25:12.645Z" }, + { url = "https://files.pythonhosted.org/packages/6e/66/b2b962a6a480dd5dae3029becf03ea1a650d326e39bf1c44ea3db78bb010/pybase64-1.4.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:d62e9861019ad63624b4a7914dff155af1cc5d6d79df3be14edcaedb5fdad6f9", size = 58750, upload-time = "2025-12-06T13:25:13.848Z" }, + { url = "https://files.pythonhosted.org/packages/2b/15/9b6d711035e29b18b2e1c03d47f41396d803d06ef15b6c97f45b75f73f04/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:84cfd4d92668ef5766cc42a9c9474b88960ac2b860767e6e7be255c6fddbd34a", size = 63816, upload-time = "2025-12-06T13:25:15.356Z" }, + { url = "https://files.pythonhosted.org/packages/b4/21/e2901381ed0df62e2308380f30d9c4d87d6b74e33a84faed3478d33a7197/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:60fc025437f9a7c2cc45e0c19ed68ed08ba672be2c5575fd9d98bdd8f01dd61f", size = 56348, upload-time = "2025-12-06T13:25:16.559Z" }, + { url = "https://files.pythonhosted.org/packages/c4/16/3d788388a178a0407aa814b976fe61bfa4af6760d9aac566e59da6e4a8b4/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:edc8446196f04b71d3af76c0bd1fe0a45066ac5bffecca88adb9626ee28c266f", size = 72842, upload-time = "2025-12-06T13:25:18.055Z" }, + { url = "https://files.pythonhosted.org/packages/a6/63/c15b1f8bd47ea48a5a2d52a4ec61f037062932ea6434ab916107b58e861e/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e99f6fa6509c037794da57f906ade271f52276c956d00f748e5b118462021d48", size = 62651, upload-time = "2025-12-06T13:25:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b8/f544a2e37c778d59208966d4ef19742a0be37c12fc8149ff34483c176616/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d94020ef09f624d841aa9a3a6029df8cf65d60d7a6d5c8687579fa68bd679b65", size = 58295, upload-time = "2025-12-06T13:25:20.822Z" }, + { url = "https://files.pythonhosted.org/packages/03/99/1fae8a3b7ac181e36f6e7864a62d42d5b1f4fa7edf408c6711e28fba6b4d/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f64ce70d89942a23602dee910dec9b48e5edf94351e1b378186b74fcc00d7f66", size = 60960, upload-time = "2025-12-06T13:25:22.099Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9e/cd4c727742345ad8384569a4466f1a1428f4e5cc94d9c2ab2f53d30be3fe/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8ea99f56e45c469818b9781903be86ba4153769f007ba0655fa3b46dc332803d", size = 74863, upload-time = "2025-12-06T13:25:23.442Z" }, + { url = "https://files.pythonhosted.org/packages/28/86/a236ecfc5b494e1e922da149689f690abc84248c7c1358f5605b8c9fdd60/pybase64-1.4.3-cp314-cp314t-win32.whl", hash = "sha256:343b1901103cc72362fd1f842524e3bb24978e31aea7ff11e033af7f373f66ab", size = 34513, upload-time = "2025-12-06T13:25:24.592Z" }, + { url = "https://files.pythonhosted.org/packages/56/ce/ca8675f8d1352e245eb012bfc75429ee9cf1f21c3256b98d9a329d44bf0f/pybase64-1.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:57aff6f7f9dea6705afac9d706432049642de5b01080d3718acc23af87c5af76", size = 36702, upload-time = "2025-12-06T13:25:25.72Z" }, + { url = "https://files.pythonhosted.org/packages/3b/30/4a675864877397179b09b720ee5fcb1cf772cf7bebc831989aff0a5f79c1/pybase64-1.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e906aa08d4331e799400829e0f5e4177e76a3281e8a4bc82ba114c6b30e405c9", size = 31904, upload-time = "2025-12-06T13:25:26.826Z" }, + { url = "https://files.pythonhosted.org/packages/17/45/92322aec1b6979e789b5710f73c59f2172bc37c8ce835305434796824b7b/pybase64-1.4.3-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:2baaa092f3475f3a9c87ac5198023918ea8b6c125f4c930752ab2cbe3cd1d520", size = 38746, upload-time = "2025-12-06T13:26:25.869Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/f1a07402870388fdfc2ecec0c718111189732f7d0f2d7fe1386e19e8fad0/pybase64-1.4.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:cde13c0764b1af07a631729f26df019070dad759981d6975527b7e8ecb465b6c", size = 32573, upload-time = "2025-12-06T13:26:27.792Z" }, + { url = "https://files.pythonhosted.org/packages/fa/8f/43c3bb11ca9bacf81cb0b7a71500bb65b2eda6d5fe07433c09b543de97f3/pybase64-1.4.3-graalpy312-graalpy250_312_native-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5c29a582b0ea3936d02bd6fe9bf674ab6059e6e45ab71c78404ab2c913224414", size = 43461, upload-time = "2025-12-06T13:26:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4c/2a5258329200be57497d3972b5308558c6de42e3749c6cc2aa1cbe34b25a/pybase64-1.4.3-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6b664758c804fa919b4f1257aa8cf68e95db76fc331de5f70bfc3a34655afe1", size = 36058, upload-time = "2025-12-06T13:26:30.092Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6d/41faa414cde66ec023b0ca8402a8f11cb61731c3dc27c082909cbbd1f929/pybase64-1.4.3-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:f7537fa22ae56a0bf51e4b0ffc075926ad91c618e1416330939f7ef366b58e3b", size = 36231, upload-time = "2025-12-06T13:26:31.656Z" }, +] + [[package]] name = "pycocotools" version = "2.0.10" @@ -5907,6 +6150,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/85/a4ff8758c66f1fc32aa5e9a145908394bf9cf1c79ffd1113cfdeb77e74e4/trove_classifiers-2025.9.11.17-py3-none-any.whl", hash = "sha256:5d392f2d244deb1866556457d6f3516792124a23d1c3a463a2e8668a5d1c15dd", size = 14158, upload-time = "2025-09-11T17:07:49.886Z" }, ] +[[package]] +name = "turbopuffer" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "orjson" }, + { name = "pybase64" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/35/b35aa64d00e741afa77699ebcf3f0ea5032dc4cb360d896534ce6e48b57d/turbopuffer-1.12.0.tar.gz", hash = "sha256:8a79aa97d9bc2c9495ae4713d4f3dbdac536a2d0a6816c7ea36cdf64415584e3", size = 146245, upload-time = "2025-12-20T09:36:33.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/61/4e8c22c73d0b93452169a9222982c478f030cd8b68ff7353b43a7e13287c/turbopuffer-1.12.0-py3-none-any.whl", hash = "sha256:257ba129fec57b1c3b48540f1011cf31ca60d771eeb530be99dc31eb22b1dd73", size = 111373, upload-time = "2025-12-20T09:36:31.835Z" }, +] + +[[package]] +name = "twilio" +version = "9.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aiohttp-retry" }, + { name = "pyjwt" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/ca/ea2f9a210c589a337abee60a1b5e7ab091d453bc18a7e0225a1a55c6c187/twilio-9.9.1.tar.gz", hash = "sha256:0ddf56092d2f613a3da56355eb4a0b39fc7c8ef10f1e073f558fab3e4b6c957b", size = 947827, upload-time = "2026-01-07T09:36:34.187Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/b6/57428b1c26852aeb5802f76787dc45839457984b1026eeab186472783c66/twilio-9.9.1-py2.py3-none-any.whl", hash = "sha256:b4a8b0ef241d7f87f5ea0915b76e50bf2d2ff73a3c67604664bfac09cb1047a7", size = 1846062, upload-time = "2026-01-07T09:36:32.07Z" }, +] + [[package]] name = "twine" version = "6.2.0" @@ -6063,6 +6341,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/99/3ec6335ded5b88c2f7ed25c56ffd952546f7ed007ffb1e1539dc3b57015a/userpath-1.9.2-py3-none-any.whl", hash = "sha256:2cbf01a23d655a1ff8fc166dfb78da1b641d1ceabf0fe5f970767d380b14e89d", size = 9065, upload-time = "2024-02-29T21:39:07.551Z" }, ] +[[package]] +name = "uuid-utils" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/0e/512fb221e4970c2f75ca9dae412d320b7d9ddc9f2b15e04ea8e44710396c/uuid_utils-0.12.0.tar.gz", hash = "sha256:252bd3d311b5d6b7f5dfce7a5857e27bb4458f222586bb439463231e5a9cbd64", size = 20889, upload-time = "2025-12-01T17:29:55.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/43/de5cd49a57b6293b911b6a9a62fc03e55db9f964da7d5882d9edbee1e9d2/uuid_utils-0.12.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:3b9b30707659292f207b98f294b0e081f6d77e1fbc760ba5b41331a39045f514", size = 603197, upload-time = "2025-12-01T17:29:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/02/fa/5fd1d8c9234e44f0c223910808cde0de43bb69f7df1349e49b1afa7f2baa/uuid_utils-0.12.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:add3d820c7ec14ed37317375bea30249699c5d08ff4ae4dbee9fc9bce3bfbf65", size = 305168, upload-time = "2025-12-01T17:29:31.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c6/8633ac9942bf9dc97a897b5154e5dcffa58816ec4dd780b3b12b559ff05c/uuid_utils-0.12.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b8fce83ecb3b16af29c7809669056c4b6e7cc912cab8c6d07361645de12dd79", size = 340580, upload-time = "2025-12-01T17:29:32.362Z" }, + { url = "https://files.pythonhosted.org/packages/f3/88/8a61307b04b4da1c576373003e6d857a04dade52ab035151d62cb84d5cb5/uuid_utils-0.12.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec921769afcb905035d785582b0791d02304a7850fbd6ce924c1a8976380dfc6", size = 346771, upload-time = "2025-12-01T17:29:33.708Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fb/aab2dcf94b991e62aa167457c7825b9b01055b884b888af926562864398c/uuid_utils-0.12.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f3b060330f5899a92d5c723547dc6a95adef42433e9748f14c66859a7396664", size = 474781, upload-time = "2025-12-01T17:29:35.237Z" }, + { url = "https://files.pythonhosted.org/packages/5a/7a/dbd5e49c91d6c86dba57158bbfa0e559e1ddf377bb46dcfd58aea4f0d567/uuid_utils-0.12.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:908dfef7f0bfcf98d406e5dc570c25d2f2473e49b376de41792b6e96c1d5d291", size = 343685, upload-time = "2025-12-01T17:29:36.677Z" }, + { url = "https://files.pythonhosted.org/packages/1a/19/8c4b1d9f450159733b8be421a4e1fb03533709b80ed3546800102d085572/uuid_utils-0.12.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c6a24148926bd0ca63e8a2dabf4cc9dc329a62325b3ad6578ecd60fbf926506", size = 366482, upload-time = "2025-12-01T17:29:37.979Z" }, + { url = "https://files.pythonhosted.org/packages/82/43/c79a6e45687647f80a159c8ba34346f287b065452cc419d07d2212d38420/uuid_utils-0.12.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:64a91e632669f059ef605f1771d28490b1d310c26198e46f754e8846dddf12f4", size = 523132, upload-time = "2025-12-01T17:29:39.293Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a2/b2d75a621260a40c438aa88593827dfea596d18316520a99e839f7a5fb9d/uuid_utils-0.12.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:93c082212470bb4603ca3975916c205a9d7ef1443c0acde8fbd1e0f5b36673c7", size = 614218, upload-time = "2025-12-01T17:29:40.315Z" }, + { url = "https://files.pythonhosted.org/packages/13/6b/ba071101626edd5a6dabf8525c9a1537ff3d885dbc210540574a03901fef/uuid_utils-0.12.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:431b1fb7283ba974811b22abd365f2726f8f821ab33f0f715be389640e18d039", size = 546241, upload-time = "2025-12-01T17:29:41.656Z" }, + { url = "https://files.pythonhosted.org/packages/01/12/9a942b81c0923268e6d85bf98d8f0a61fcbcd5e432fef94fdf4ce2ef8748/uuid_utils-0.12.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd7838c40149100299fa37cbd8bab5ee382372e8e65a148002a37d380df7c8", size = 511842, upload-time = "2025-12-01T17:29:43.107Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a7/c326f5163dd48b79368b87d8a05f5da4668dd228a3f5ca9d79d5fee2fc40/uuid_utils-0.12.0-cp39-abi3-win32.whl", hash = "sha256:487f17c0fee6cbc1d8b90fe811874174a9b1b5683bf2251549e302906a50fed3", size = 179088, upload-time = "2025-12-01T17:29:44.492Z" }, + { url = "https://files.pythonhosted.org/packages/38/92/41c8734dd97213ee1d5ae435cf4499705dc4f2751e3b957fd12376f61784/uuid_utils-0.12.0-cp39-abi3-win_amd64.whl", hash = "sha256:9598e7c9da40357ae8fffc5d6938b1a7017f09a1acbcc95e14af8c65d48c655a", size = 183003, upload-time = "2025-12-01T17:29:45.47Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/52ab0359618987331a1f739af837d26168a4b16281c9c3ab46519940c628/uuid_utils-0.12.0-cp39-abi3-win_arm64.whl", hash = "sha256:c9bea7c5b2aa6f57937ebebeee4d4ef2baad10f86f1b97b58a3f6f34c14b4e84", size = 182975, upload-time = "2025-12-01T17:29:46.444Z" }, +] + [[package]] name = "uv" version = "0.9.8" @@ -6569,7 +6869,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "getstream", extras = ["telemetry", "webrtc"], specifier = ">=2.5.19" }, + { name = "getstream", extras = ["telemetry", "webrtc"], specifier = ">=2.5.20" }, { name = "vision-agents", editable = "agents-core" }, ] @@ -6886,6 +7186,67 @@ dev = [ { name = "pytest-asyncio", specifier = ">=1.0.0" }, ] +[[package]] +name = "vision-agents-plugins-turbopuffer" +source = { editable = "plugins/turbopuffer" } +dependencies = [ + { name = "langchain-google-genai" }, + { name = "langchain-text-splitters" }, + { name = "turbopuffer" }, + { name = "vision-agents" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "langchain-google-genai", specifier = ">=2.1.4" }, + { name = "langchain-text-splitters", specifier = ">=0.3.8" }, + { name = "turbopuffer", specifier = ">=0.3.3" }, + { name = "vision-agents", editable = "agents-core" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.4.1" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, +] + +[[package]] +name = "vision-agents-plugins-twilio" +version = "0.1.0" +source = { editable = "plugins/twilio" } +dependencies = [ + { name = "fastapi" }, + { name = "numpy" }, + { name = "twilio" }, + { name = "vision-agents" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.100.0" }, + { name = "numpy", specifier = ">=1.24.0" }, + { name = "twilio", specifier = ">=9.0.0" }, + { name = "vision-agents", editable = "agents-core" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.4.1" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, +] + [[package]] name = "vision-agents-plugins-ultralytics" source = { editable = "plugins/ultralytics" } From ed29e08bddea52ed68ae7b24c58c39eb054e3a22 Mon Sep 17 00:00:00 2001 From: Daniil Gusev Date: Wed, 7 Jan 2026 15:21:38 +0100 Subject: [PATCH 11/13] Fix missing plugins in pyproject.toml --- agents-core/pyproject.toml | 2 ++ uv.lock | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/agents-core/pyproject.toml b/agents-core/pyproject.toml index 0a4f38989..75757c5a4 100644 --- a/agents-core/pyproject.toml +++ b/agents-core/pyproject.toml @@ -88,6 +88,8 @@ all-plugins = [ "vision-agents-plugins-vogent", "vision-agents-plugins-fast-whisper", "vision-agents-plugins-qwen", + "vision-agents-plugins-twilio", + "vision-agents-plugins-turbopuffer", ] [tool.hatch.metadata] diff --git a/uv.lock b/uv.lock index 4dbf56790..c72743033 100644 --- a/uv.lock +++ b/uv.lock @@ -6451,6 +6451,8 @@ all-plugins = [ { name = "vision-agents-plugins-qwen" }, { name = "vision-agents-plugins-roboflow" }, { name = "vision-agents-plugins-smart-turn" }, + { name = "vision-agents-plugins-turbopuffer" }, + { name = "vision-agents-plugins-twilio" }, { name = "vision-agents-plugins-ultralytics" }, { name = "vision-agents-plugins-vogent" }, { name = "vision-agents-plugins-wizper" }, @@ -6592,6 +6594,8 @@ requires-dist = [ { name = "vision-agents-plugins-roboflow", marker = "extra == 'roboflow'", editable = "plugins/roboflow" }, { name = "vision-agents-plugins-smart-turn", marker = "extra == 'all-plugins'", editable = "plugins/smart_turn" }, { name = "vision-agents-plugins-smart-turn", marker = "extra == 'smart-turn'", editable = "plugins/smart_turn" }, + { name = "vision-agents-plugins-turbopuffer", marker = "extra == 'all-plugins'", editable = "plugins/turbopuffer" }, + { name = "vision-agents-plugins-twilio", marker = "extra == 'all-plugins'", editable = "plugins/twilio" }, { name = "vision-agents-plugins-ultralytics", marker = "extra == 'all-plugins'", editable = "plugins/ultralytics" }, { name = "vision-agents-plugins-ultralytics", marker = "extra == 'ultralytics'", editable = "plugins/ultralytics" }, { name = "vision-agents-plugins-vogent", marker = "extra == 'all-plugins'", editable = "plugins/vogent" }, From 631075f3446e4498dada805b6db64424546810d6 Mon Sep 17 00:00:00 2001 From: Daniil Gusev Date: Wed, 7 Jan 2026 15:26:18 +0100 Subject: [PATCH 12/13] Add optional deps for twilio & turbopuffer --- agents-core/pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/agents-core/pyproject.toml b/agents-core/pyproject.toml index 75757c5a4..7408dfa94 100644 --- a/agents-core/pyproject.toml +++ b/agents-core/pyproject.toml @@ -61,6 +61,8 @@ qwen = ["vision-agents-plugins-qwen"] fish = ["vision-agents-plugins-fish"] fast-whisper = ["vision-agents-plugins-fast-whisper"] decart = ["vision-agents-plugins-decart"] +twilio = ["vision-agents-plugins-twilio"] +turbopuffer = ["vision-agents-plugins-turbopuffer"] all-plugins = [ "vision-agents-plugins-anthropic", From 62f4f22e94a12c96f4c984ff08546e73aca232e6 Mon Sep 17 00:00:00 2001 From: Daniil Gusev Date: Wed, 7 Jan 2026 21:16:07 +0100 Subject: [PATCH 13/13] Set default `agent_idle_timeout` to 60s --- .../vision_agents/core/agents/agent_launcher.py | 4 ++-- uv.lock | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/agents-core/vision_agents/core/agents/agent_launcher.py b/agents-core/vision_agents/core/agents/agent_launcher.py index 6697f1696..a2b164423 100644 --- a/agents-core/vision_agents/core/agents/agent_launcher.py +++ b/agents-core/vision_agents/core/agents/agent_launcher.py @@ -24,7 +24,7 @@ def __init__( self, create_agent: Callable[..., "Agent" | Awaitable["Agent"]], join_call: Callable[..., None | Awaitable[None]] | None = None, - agent_idle_timeout: float = 30.0, + agent_idle_timeout: float = 60.0, agent_idle_cleanup_interval: float = 5.0, ): """ @@ -33,7 +33,7 @@ def __init__( Args: create_agent: A function that creates and returns an Agent instance join_call: Optional function that handles joining a call with the agent - agent_idle_timeout: Optional timeout in seconds for agent to stay alone on the call. Default - `30.0`. + agent_idle_timeout: Optional timeout in seconds for agent to stay alone on the call. Default - `60.0`. `0` means idle agents won't leave the call until it's ended. """ diff --git a/uv.lock b/uv.lock index c72743033..fc2b1dffb 100644 --- a/uv.lock +++ b/uv.lock @@ -6527,6 +6527,12 @@ roboflow = [ smart-turn = [ { name = "vision-agents-plugins-smart-turn" }, ] +turbopuffer = [ + { name = "vision-agents-plugins-turbopuffer" }, +] +twilio = [ + { name = "vision-agents-plugins-twilio" }, +] ultralytics = [ { name = "vision-agents-plugins-ultralytics" }, ] @@ -6595,7 +6601,9 @@ requires-dist = [ { name = "vision-agents-plugins-smart-turn", marker = "extra == 'all-plugins'", editable = "plugins/smart_turn" }, { name = "vision-agents-plugins-smart-turn", marker = "extra == 'smart-turn'", editable = "plugins/smart_turn" }, { name = "vision-agents-plugins-turbopuffer", marker = "extra == 'all-plugins'", editable = "plugins/turbopuffer" }, + { name = "vision-agents-plugins-turbopuffer", marker = "extra == 'turbopuffer'", editable = "plugins/turbopuffer" }, { name = "vision-agents-plugins-twilio", marker = "extra == 'all-plugins'", editable = "plugins/twilio" }, + { name = "vision-agents-plugins-twilio", marker = "extra == 'twilio'", editable = "plugins/twilio" }, { name = "vision-agents-plugins-ultralytics", marker = "extra == 'all-plugins'", editable = "plugins/ultralytics" }, { name = "vision-agents-plugins-ultralytics", marker = "extra == 'ultralytics'", editable = "plugins/ultralytics" }, { name = "vision-agents-plugins-vogent", marker = "extra == 'all-plugins'", editable = "plugins/vogent" }, @@ -6605,7 +6613,7 @@ requires-dist = [ { name = "vision-agents-plugins-xai", marker = "extra == 'all-plugins'", editable = "plugins/xai" }, { name = "vision-agents-plugins-xai", marker = "extra == 'xai'", editable = "plugins/xai" }, ] -provides-extras = ["all-plugins", "anthropic", "aws", "cartesia", "decart", "deepgram", "dev", "elevenlabs", "fast-whisper", "fish", "gemini", "getstream", "heygen", "huggingface", "inworld", "kokoro", "moondream", "moonshine", "openai", "openrouter", "qwen", "roboflow", "smart-turn", "ultralytics", "vogent", "wizper", "xai"] +provides-extras = ["all-plugins", "anthropic", "aws", "cartesia", "decart", "deepgram", "dev", "elevenlabs", "fast-whisper", "fish", "gemini", "getstream", "heygen", "huggingface", "inworld", "kokoro", "moondream", "moonshine", "openai", "openrouter", "qwen", "roboflow", "smart-turn", "turbopuffer", "twilio", "ultralytics", "vogent", "wizper", "xai"] [[package]] name = "vision-agents-plugins-anthropic"