cartesia-line 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of cartesia-line might be problematic. Click here for more details.

line/bus.py ADDED
@@ -0,0 +1,401 @@
1
+ """
2
+ Bus - Typed event routing system for agent communication.
3
+
4
+ Routes typed events between agents using broadcast or request-response patterns.
5
+ Provides type-safe event handling with Pydantic validation and fluent subscription API.
6
+
7
+ Examples:
8
+ Create and start bus::
9
+
10
+ bus = Bus()
11
+ await bus.start()
12
+
13
+ Register agents::
14
+
15
+ bus.register_bridge("clipboard", clipboard_bridge)
16
+
17
+ Send typed events::
18
+
19
+ await bus.broadcast(FormCompleted(node_id="intake", status="done"))
20
+ response = await bus.call(ToolCall(node_id="agent", tool_name="calculator"))
21
+
22
+ Subscribe to events::
23
+ bridge.on(UserTranscriptionReceived, source="intake").map(handler)
24
+ """
25
+
26
+ import asyncio
27
+ import time
28
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
29
+ import uuid
30
+
31
+ from loguru import logger
32
+ from pydantic import BaseModel, Field
33
+
34
+ if TYPE_CHECKING:
35
+ from line.bridge import Bridge
36
+
37
+ from line.events import AgentHandoff, Authorize, ToolCall
38
+
39
+
40
+ class Message(BaseModel):
41
+ """Message sent between agents through the bus."""
42
+
43
+ # The unique identifier for the message.
44
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
45
+
46
+ # The source of the message.
47
+ source: str
48
+ # The event being sent.
49
+ event: Any
50
+
51
+ # The timestamp when the message was created.
52
+ timestamp: float = Field(default_factory=time.time)
53
+
54
+ def __str__(self) -> str:
55
+ return (
56
+ f"f{type(self)}(id={self.id}, "
57
+ f"source={self.source}, "
58
+ f"event={type(self.event)}({self.event}), "
59
+ f"timestamp={self.timestamp})"
60
+ )
61
+
62
+
63
+ class Bus:
64
+ """
65
+ Routes messages between agents in memory.
66
+
67
+ Handles broadcast events and request-response calls.
68
+ Provides event-driven communication between agents and bridges.
69
+ """
70
+
71
+ # ================================================================
72
+ # Lifecycle Methods
73
+ # ================================================================
74
+
75
+ def __init__(self, max_queue_size: int = 1000):
76
+ """
77
+ Create agent bus.
78
+
79
+ Args:
80
+ max_queue_size: Max messages before dropping.
81
+ """
82
+ self.max_queue_size = max_queue_size # Prevents memory overflow during high message volume.
83
+
84
+ self.message_queue = asyncio.Queue(
85
+ maxsize=max_queue_size
86
+ ) # Decouples message sending from processing for async handling.
87
+ self.running = False # Prevents router from processing messages during shutdown.
88
+ self.router_task: Optional[asyncio.Task] = None # Allows graceful cancellation during cleanup.
89
+
90
+ self.bridges: Dict[str, "Bridge"] = {} # node_id → Bridge instance.
91
+
92
+ self.pending_requests: Dict[
93
+ str, asyncio.Future
94
+ ] = {} # Allows synchronous-style calls over async message bus.
95
+
96
+ self.shutdown_event = asyncio.Event() # Ensures all tasks stop together.
97
+
98
+ async def start(self) -> None:
99
+ """
100
+ Start the message router.
101
+
102
+ Initializes background task for message routing between bridges.
103
+ """
104
+ if self.running:
105
+ logger.warning("Bus already running")
106
+ return
107
+
108
+ self.running = True
109
+
110
+ # Log system state summary before starting router
111
+ self._log_system_summary()
112
+
113
+ self.router_task = asyncio.create_task(self._message_router())
114
+
115
+ logger.info("Bus message router started")
116
+
117
+ async def cleanup(self) -> None:
118
+ """
119
+ Stop message routing and clean up resources.
120
+
121
+ Cancels all background tasks, cleans up pending requests,
122
+ and ensures graceful shutdown of all bus components.
123
+ """
124
+ logger.info("Cleaning up Bus")
125
+ self.running = False
126
+ self.shutdown_event.set()
127
+
128
+ # Cancel router task.
129
+ if self.router_task and not self.router_task.done():
130
+ self.router_task.cancel()
131
+ try:
132
+ await self.router_task
133
+ except asyncio.CancelledError:
134
+ pass
135
+
136
+ # Cancel pending requests to prevent hanging futures.
137
+ for future in self.pending_requests.values():
138
+ if not future.done():
139
+ future.cancel()
140
+
141
+ self.pending_requests.clear()
142
+ logger.info("Bus cleanup completed")
143
+
144
+ def _log_system_summary(self) -> None:
145
+ """Log a clean visual summary of the Bus state before starting."""
146
+ bridge_count = len(self.bridges)
147
+
148
+ # Build visual representation
149
+ summary_lines = [
150
+ "🚌 Bus System Ready",
151
+ "=" * 60,
152
+ f"📊 System Overview: {bridge_count} bridges registered",
153
+ "",
154
+ "🏗️ System Architecture:",
155
+ ]
156
+
157
+ # Group bridges by type for better visualization
158
+ node_bridges = []
159
+ system_bridges = []
160
+
161
+ for name in sorted(self.bridges.keys()):
162
+ bridge = self.bridges[name]
163
+ route_count = len(getattr(bridge, "routes", {}))
164
+
165
+ # Get route patterns for this bridge
166
+ route_patterns = []
167
+ if hasattr(bridge, "routes"):
168
+ route_patterns = list(bridge.routes.keys())[:3] # Show first 3 routes
169
+ if len(bridge.routes) > 3:
170
+ route_patterns.append("...")
171
+
172
+ auth_info = ""
173
+ if hasattr(bridge, "authorized_nodes") and bridge.authorized_nodes:
174
+ auth_nodes = sorted(bridge.authorized_nodes)
175
+ auth_info = f" 🔐[{', '.join(auth_nodes)}]"
176
+
177
+ route_info = f"({route_count} routes)" if route_count else "(no routes)"
178
+ pattern_info = f" → {route_patterns}" if route_patterns else ""
179
+
180
+ bridge_line = f" 📡 {name:<12} {route_info}{auth_info}{pattern_info}"
181
+
182
+ if name in ["user", "tools", "state"]:
183
+ system_bridges.append(bridge_line)
184
+ else:
185
+ node_bridges.append(bridge_line)
186
+
187
+ # Add system bridges
188
+ if system_bridges:
189
+ summary_lines.append(" 🔧 System Bridges:")
190
+ summary_lines.extend(system_bridges)
191
+ summary_lines.append("")
192
+
193
+ # Add node bridges
194
+ if node_bridges:
195
+ summary_lines.append(" 🧠 Agent Bridges:")
196
+ summary_lines.extend(node_bridges)
197
+ summary_lines.append("")
198
+
199
+ summary_lines.extend(
200
+ [
201
+ " 🔄 Message Flow: Bridges ↔ Bus ↔ All Bridges",
202
+ "=" * 60,
203
+ "🎯 Starting message router...",
204
+ ]
205
+ )
206
+
207
+ # Log as single message
208
+ logger.info("\n" + "\n".join(summary_lines))
209
+
210
+ # ================================================================
211
+ # Registration & Configuration
212
+ # ================================================================
213
+
214
+ def register_bridge(self, node_id: str, bridge: "Bridge") -> None:
215
+ """
216
+ Register event bridge.
217
+
218
+ Args:
219
+ node_id: Node identifier.
220
+ bridge: Bridge for event routing.
221
+ """
222
+ self.bridges[node_id] = bridge
223
+ bridge.set_bus(self)
224
+
225
+ # ================================================================
226
+ # Public Messaging API
227
+ # ================================================================
228
+
229
+ async def broadcast(self, message: Message) -> None:
230
+ """
231
+ Send message to all matching bridges.
232
+
233
+ Args:
234
+ message: Message to broadcast.
235
+
236
+ Examples:
237
+ Basic message broadcast::
238
+
239
+ await bus.broadcast(Message(
240
+ source="intake",
241
+ event=ConversationTurn(role="user", content="Hello"),
242
+ ))
243
+
244
+ Form completion broadcast::
245
+
246
+ await bus.broadcast(Message(
247
+ source="intake",
248
+ event=FormCompleted(status="done", fields={"name": "John"}),
249
+ ))
250
+
251
+ See Also:
252
+ :meth:`call` - For responses
253
+ :meth:`request` - For specific agents
254
+ """
255
+ logger.debug(f"Bus: Broadcasting message: {message}")
256
+ await self._queue_message(message)
257
+
258
+ # ================================================================
259
+ # Message Routing
260
+ # ================================================================
261
+
262
+ async def _queue_message(self, message: Message) -> None:
263
+ """
264
+ Queue message for the router to process.
265
+
266
+ Args:
267
+ message: Message to queue.
268
+ """
269
+ try:
270
+ await self.message_queue.put(message)
271
+ logger.debug(f"Bus (_queue_message): Message queued: {message}")
272
+ except asyncio.QueueFull:
273
+ logger.error("Bus message queue is full, dropping message")
274
+
275
+ def _get_queue_info_synchronous(self) -> Dict[str, Any]:
276
+ """Get information about what is on the queue.
277
+
278
+ Note:
279
+ This is incredibly expensive to do (in the land of low latency).
280
+ So call this method only for debugging purposes.
281
+ Never push code that calls this method to production.
282
+
283
+ Returns:
284
+ A dictionary of the `self.message_queue` information.
285
+ """
286
+ return {
287
+ "queue_size": self.message_queue.qsize(),
288
+ "max_queue_size": self.max_queue_size,
289
+ "is_full": self.message_queue.full(),
290
+ "is_empty": self.message_queue.empty(),
291
+ "messages": self._peek_queue_contents(),
292
+ }
293
+
294
+ def _peek_queue_contents(self) -> List[Message]:
295
+ """
296
+ Synchronously peek at all messages in the queue.
297
+ Note: This creates a copy of the queue contents and is not thread-safe.
298
+
299
+ Returns:
300
+ List of Message objects currently in the queue.
301
+ """
302
+ messages = []
303
+ temp_queue = asyncio.Queue()
304
+
305
+ # Drain the original queue into a temporary queue
306
+ while not self.message_queue.empty():
307
+ try:
308
+ # Use get_nowait() to avoid blocking
309
+ message = self.message_queue.get_nowait()
310
+ messages.append(message)
311
+ temp_queue.put_nowait(message)
312
+ except asyncio.QueueEmpty:
313
+ break
314
+
315
+ # Restore the original queue
316
+ while not temp_queue.empty():
317
+ try:
318
+ message = temp_queue.get_nowait()
319
+ self.message_queue.put_nowait(message)
320
+ except asyncio.QueueFull:
321
+ break
322
+
323
+ return messages
324
+
325
+ async def _message_router(self) -> None:
326
+ """Main message routing loop that processes queued messages."""
327
+ logger.info("Bus message router started")
328
+
329
+ try:
330
+ while self.running:
331
+ try:
332
+ # Timeout allows checking shutdown flag periodically.
333
+ # logger.debug(f"Bus: Waiting for message in the _message_router")
334
+ message = await asyncio.wait_for(self.message_queue.get(), timeout=1.0)
335
+ logger.debug(f"Bus: Message received: {message}")
336
+
337
+ # Delegate to specific routing logic based on pattern.
338
+ await self._route_message(message)
339
+
340
+ except asyncio.TimeoutError:
341
+ # Prevents infinite blocking during shutdown.
342
+ continue
343
+ except Exception as e:
344
+ logger.exception(f"Error in message router: {e}")
345
+
346
+ except asyncio.CancelledError:
347
+ logger.info("Message router cancelled")
348
+ except Exception as e:
349
+ logger.exception(f"Unexpected error in message router: {e}")
350
+
351
+ async def _route_message(self, message: Message) -> None:
352
+ """Route message to bridges based on direct or broadcast pattern."""
353
+ logger.debug(f"Bus: Routing message: {message}")
354
+ try:
355
+ # Handle agent handoff events or transfer_to_* tool calls directly.
356
+ # TODO: Consider adding certain types of events as being high priority to handle.
357
+ # For example, it would be reasonable to prioritize these events:
358
+ # - AgentResponse should be sent to the user bridge immediately.
359
+ # - Interruption events should be processed by the interruption routes first.
360
+ event = message.event
361
+ if isinstance(event, AgentHandoff):
362
+ logger.info(f"Bus: Handling handoff to {event.target_agent}")
363
+ await self._handle_handoff(message, event.target_agent)
364
+ return
365
+ elif isinstance(event, ToolCall) and event.tool_name.startswith("transfer_to_"):
366
+ target_agent = event.tool_name.replace("transfer_to_", "")
367
+ logger.info(f"Bus: Handling handoff to {target_agent}")
368
+ await self._handle_handoff(message, target_agent)
369
+ return
370
+ else:
371
+ # Broadcast to all bridges
372
+ tasks = []
373
+ for _, bridge in self.bridges.items():
374
+ # NOTE: Do not await this. We want to fire and forget.
375
+ # We want the bridges to process the tasks in the background.
376
+ # This is to ensure that future messages are not blocked by the task.
377
+ # NOTE: There is an implicit ordering here determined by the order of the bridges.
378
+ # TODO: Consider making this explicit.
379
+ tasks.append(asyncio.create_task(bridge.handle_event(message)))
380
+
381
+ except Exception as e:
382
+ logger.exception(f"Error routing message {message.id}: {e}")
383
+
384
+ async def _handle_handoff(self, message: Message, target_agent: str) -> None:
385
+ """Handle handoff from transfer_to_* tool calls."""
386
+ from_agent = message.source
387
+
388
+ # Get reason from the event
389
+ reason = ""
390
+ if isinstance(message.event, AgentHandoff):
391
+ reason = message.event.reason
392
+ elif isinstance(message.event, ToolCall):
393
+ reason = message.event.tool_args.get("reason", "")
394
+
395
+ logger.info(f"Processing handoff: {from_agent} -> {target_agent} ({reason})")
396
+
397
+ # Tell user bridge to change authorization
398
+ user_auth_event = Authorize(node_id="system", agent=target_agent)
399
+ await self.broadcast(user_auth_event)
400
+
401
+ logger.info(f"Handoff completed: {from_agent} -> {target_agent}")
line/call_request.py ADDED
@@ -0,0 +1,25 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class PreCallResult(BaseModel):
7
+ """Result from pre_call_handler containing metadata and config."""
8
+
9
+ metadata: Dict[str, Any] = Field(default_factory=dict, description="Metadata to include with the call")
10
+ config: Dict[str, Any] = Field(default_factory=dict, description="Configuration for the call")
11
+
12
+
13
+ class CallRequest(BaseModel):
14
+ """Request body for the /chats endpoint."""
15
+
16
+ call_id: str
17
+ from_: str = Field(alias="from") # Using from_ to avoid Python keyword conflict
18
+ to: str
19
+ agent_call_id: str # Agent call ID for logging and correlation
20
+ metadata: Optional[Dict[str, Any]] = None
21
+
22
+ model_config = ConfigDict(
23
+ # Allow both field name (from_) and alias (from) for input
24
+ populate_by_name=True
25
+ )
line/events.py ADDED
@@ -0,0 +1,218 @@
1
+ """
2
+ Typed event definitions for the agent bus system.
3
+
4
+ Each event inherits from EventMeta which provides automatic node identification
5
+ and instance tracking for distributed agent communication.
6
+ """
7
+
8
+ import json
9
+ from typing import Any, Dict, Optional, Type, TypeVar, Union
10
+ import uuid
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+ T = TypeVar("T")
15
+ EventInstance = T
16
+
17
+ # Type[T] means EventType has to be a class (not an instance), and it allows us to refer to T in
18
+ # order to instantiate T later.
19
+ EventType = Type[T]
20
+
21
+ EventTypeOrAlias = Union[EventType, str]
22
+
23
+ __all__ = [
24
+ "AgentResponse",
25
+ "ToolResult",
26
+ "ToolCall",
27
+ "EndCall",
28
+ "AgentGenerationComplete",
29
+ "Authorize",
30
+ "AgentError",
31
+ "TransferCall",
32
+ "AgentHandoff",
33
+ "AgentStartedSpeaking",
34
+ "AgentStoppedSpeaking",
35
+ "UserStartedSpeaking",
36
+ "UserStoppedSpeaking",
37
+ "UserTranscriptionReceived",
38
+ "AgentSpeechSent",
39
+ "UserUnknownInputReceived",
40
+ "LogMetric",
41
+ ]
42
+
43
+
44
+ class AgentResponse(BaseModel):
45
+ """Agent message to be sent to the user."""
46
+
47
+ content: str
48
+ chunk_type: str = "text"
49
+
50
+
51
+ class ToolResult(BaseModel):
52
+ """Tool execution result
53
+ - This will appear in the transcript in the Agent's current turn.
54
+
55
+ Attributes:
56
+ - tool_name: Name of the tool that was called.
57
+ - tool_args: Arguments that were passed to the tool.
58
+ - result: Result returned by the tool.
59
+ - result_str: String representation of the result (computed).
60
+ - error: Error message if the tool call failed (None if successful).
61
+ - metadata: Additional metadata about the tool call.
62
+ - tool_call_id: Reference to the ToolCall instance that triggered this result (if applicable).
63
+ """
64
+
65
+ tool_name: str = ""
66
+ tool_args: dict = Field(default_factory=dict)
67
+ result: Optional[object] = None
68
+ error: Optional[str] = None
69
+ metadata: Optional[Dict] = None
70
+ tool_call_id: Optional[str] = None
71
+
72
+ @property
73
+ def result_str(self) -> Optional[str]:
74
+ """String representation of the result, automatically computed from result."""
75
+ if self.result is not None:
76
+ try:
77
+ return json.dumps(self.result)
78
+ except Exception:
79
+ return str(self.result)
80
+ return None
81
+
82
+ @property
83
+ def success(self) -> bool:
84
+ """Returns True if there was no error, False otherwise."""
85
+ return self.error is None
86
+
87
+
88
+ class ToolCall(BaseModel):
89
+ """Tool execution request
90
+ - This will appear in the transcript in the Agent's current turn.
91
+
92
+ Attributes:
93
+ - tool_name: Name of the tool that was called
94
+ - tool_args: Arguments that were passed to the tool
95
+ - tool_call_id: Unique identifier for the tool call
96
+ - raw_response: Raw response from the tool call
97
+ """
98
+
99
+ tool_name: str
100
+ tool_args: Dict = Field(default_factory=dict)
101
+ tool_call_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
102
+ raw_response: Dict = Field(default_factory=dict)
103
+
104
+
105
+ class EndCall(BaseModel):
106
+ """End the call."""
107
+
108
+ @property
109
+ def content(self) -> str:
110
+ """Returns string representation of the end call event."""
111
+ return self.__repr__()
112
+
113
+
114
+ class AgentGenerationComplete(BaseModel):
115
+ """Agent generation completion event."""
116
+
117
+
118
+ class Authorize(BaseModel):
119
+ """Change the authorized agent."""
120
+
121
+ agent: str
122
+
123
+
124
+ class AgentError(BaseModel):
125
+ """Send error message to user."""
126
+
127
+ error: str
128
+ code: Optional[str] = None
129
+
130
+
131
+ class TransferCall(BaseModel):
132
+ """Transfer call to destination."""
133
+
134
+ destination: str
135
+ reason: Optional[str] = None
136
+
137
+
138
+ class AgentHandoff(BaseModel):
139
+ """Agent handoff event for transfer_to_* patterns."""
140
+
141
+ target_agent: str
142
+ reason: str = ""
143
+
144
+
145
+ class AgentStartedSpeaking(BaseModel):
146
+ """Agent started speaking event."""
147
+
148
+
149
+ class AgentStoppedSpeaking(BaseModel):
150
+ """Agent stopped speaking event."""
151
+
152
+
153
+ class UserStartedSpeaking(BaseModel):
154
+ """User started speaking event."""
155
+
156
+
157
+ class UserStoppedSpeaking(BaseModel):
158
+ """User stopped speaking event."""
159
+
160
+
161
+ class UserTranscriptionReceived(BaseModel):
162
+ """User transcription received event."""
163
+
164
+ content: str
165
+
166
+
167
+ class AgentSpeechSent(BaseModel):
168
+ """Agent speech content sent event."""
169
+
170
+ content: str
171
+
172
+
173
+ class UserUnknownInputReceived(BaseModel):
174
+ """User unknown input received event."""
175
+
176
+ input_data: str
177
+
178
+
179
+ class LogMetric(BaseModel):
180
+ """Log metric event for tracking usage metrics."""
181
+
182
+ name: str
183
+ value: Any
184
+
185
+
186
+ class _EventsRegistry:
187
+ """A singleton registry of all events.
188
+
189
+ Usage:
190
+ >>> registry = EventsRegistry()
191
+ >>> registry.register("system.eventA", SystemEventA)
192
+ >>> registry.register("system.eventB", SystemEventB)
193
+ >>> registry.get("system.eventA")
194
+ <class 'system.eventA'>
195
+ >>> registry.get("system.eventB")
196
+ <class 'system.eventB'>
197
+ """
198
+
199
+ _instance = None
200
+
201
+ def __new__(cls, *args, **kwargs):
202
+ if cls._instance is None:
203
+ cls._instance = super().__new__(cls)
204
+ cls._instance.events = {} # Dict[EventType, str]
205
+ return cls._instance
206
+
207
+ def register(self, alias: str, event_type: EventType):
208
+ if event_type in self.events:
209
+ raise ValueError(f"Event type {event_type} already registered with alias {alias}")
210
+ if not isinstance(alias, str):
211
+ raise TypeError(f"Alias {alias} is not a string")
212
+ self.events[event_type] = alias
213
+
214
+ def get(self, event_type: EventType) -> Optional[str]:
215
+ return self.events.get(event_type, None)
216
+
217
+
218
+ EventsRegistry = _EventsRegistry()