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/routes.py ADDED
@@ -0,0 +1,618 @@
1
+ import asyncio
2
+ from dataclasses import dataclass, field
3
+ from enum import Enum
4
+ import inspect
5
+ from typing import (
6
+ TYPE_CHECKING,
7
+ Any,
8
+ AsyncIterable,
9
+ Callable,
10
+ Dict,
11
+ List,
12
+ Literal,
13
+ Optional,
14
+ Union,
15
+ )
16
+ import weakref
17
+
18
+ from loguru import logger
19
+
20
+ from line.bus import Message
21
+ from line.events import EventType
22
+ from line.utils.aio import await_tasks_safe
23
+
24
+ if TYPE_CHECKING:
25
+ from line.bridge import Bridge
26
+
27
+ # We do not support async handlers here because these event handlers should be synchronous.
28
+ OnEventHandler = Union[
29
+ # def handler() -> None
30
+ Callable[[], None],
31
+ # def handler(message: BusMessage) -> None
32
+ Callable[[Message], None],
33
+ ]
34
+
35
+
36
+ class _EventHandlerDict:
37
+ """Event handler dictionary."""
38
+
39
+ fn: OnEventHandler
40
+ # Whether the method takes an argument.
41
+ # This should be True only if the function takes an argument.
42
+ # self is not considered an argument here and will be filtered out.
43
+ has_argument: bool
44
+
45
+ def __init__(self, fn: OnEventHandler):
46
+ self.fn = fn
47
+
48
+ signature = inspect.signature(fn)
49
+ arguments = [param for param in signature.parameters.values() if param.name != "self"]
50
+ if len(arguments) > 1:
51
+ raise ValueError(
52
+ f"Event handler {fn} takes more than one argument. "
53
+ "Handlers can take 0 or 1 arguments. "
54
+ "See OnEventHandler for function signatures that are supported."
55
+ )
56
+ self.has_argument = len(arguments) > 0
57
+
58
+ def __call__(self, *args, **kwargs) -> None:
59
+ if self.has_argument:
60
+ self.fn(*args, **kwargs)
61
+ else:
62
+ self.fn()
63
+
64
+
65
+ class RouteState(Enum):
66
+ """State of a route."""
67
+
68
+ RUNNING = "running"
69
+ INTERRUPTED = "interrupted"
70
+ SUSPENDED = "suspended"
71
+ EXITED = "exited"
72
+
73
+
74
+ @dataclass
75
+ class RouteConfig:
76
+ """Configuration for a route execution.
77
+
78
+ Args:
79
+ operations: List of operations to perform on the event.
80
+ suspended: Whether the route is suspended. If `True`, this route won't execute.
81
+ source: The source node to filter events from. TODO: Deprecate this.
82
+ interrupt_events: List of all events that can interrupt this route.
83
+ If *any* of these events are received, the route will be interrupted.
84
+ suspend_on_events: List of events that will suspend the route.
85
+ If *any* of these events are received, the route will be suspended.
86
+ resume_on_events: List of events that will resume the route.
87
+ If *any* of these events are received, the route will be resumed.
88
+ interrupt_handlers: Dictionary of event handlers for interrupt events.
89
+ filter_fn: Optional custom filter function that takes a BusMessage.
90
+ event_property_filters: Dictionary of event property filters.
91
+ """
92
+
93
+ operations: List[Dict] = field(default_factory=list)
94
+
95
+ state: RouteState = RouteState.RUNNING
96
+ source: Optional[str] = None
97
+ max_concurrent_tasks: Optional[int] = None
98
+
99
+ interrupt_handlers: Dict[str, _EventHandlerDict] = field(default_factory=dict)
100
+ suspend_handlers: Dict[str, _EventHandlerDict] = field(default_factory=dict)
101
+ resume_handlers: Dict[str, _EventHandlerDict] = field(default_factory=dict)
102
+
103
+ # These filters operate on the message object.
104
+ event_property_filters: Dict[str, Union[Any, Callable[[Any], bool]]] = field(default_factory=dict)
105
+ filter_fn: Optional[Callable[[Message], bool]] = None
106
+
107
+
108
+ class RouteBuilder:
109
+ """Builder for event routes.
110
+
111
+ Unlike :class:`RouteBuilder`, this class handles not just how to process the event,
112
+ but also how to emit results. There is no distinction between the two.
113
+
114
+ Simpler interface
115
+ ------------------
116
+ This means that we can do the following:
117
+ ```
118
+ bridge.on(Events.A).broadcast(Events.B)
119
+ ```
120
+
121
+ instead of:
122
+ ```
123
+ bridge.on(Events.A).for_each(lambda msg: None).broadcast(Events.B)
124
+ ```
125
+
126
+ This is a more natural way to think about the problem instead of forcing an emission.
127
+
128
+ Using primitives
129
+ ----------------
130
+ There are 4 primitives that all routes support:
131
+ - `map: Callable[[Any], Any]`: Apply a function to the event.
132
+ - `filter: Callable[[Any], bool]`: Filter the event.
133
+ - `reduce: Callable[[Any], Any]`: Reduce the event.
134
+ - `broadcast: Callable[[Any], None]`: Broadcast the event.
135
+ """
136
+
137
+ def __init__(self, bridge: "Bridge"):
138
+ self.bridge = weakref.proxy(bridge)
139
+ self.route_config = RouteConfig()
140
+ self.route_handler: "RouteHandler" = None
141
+
142
+ def _set_route_handler(self, route_handler: "RouteHandler") -> None:
143
+ """Creates a weak reference to the route_handler."""
144
+ self.route_handler = weakref.proxy(route_handler)
145
+
146
+ def _has_control_operation(self) -> bool:
147
+ """Check if the route has a control operation."""
148
+ return (
149
+ len(self.route_config.operations) > 0 and self.route_config.operations[0]["_fn_type"] == "control"
150
+ )
151
+
152
+ def _validate_pre(self):
153
+ """Validate the route configuration before adding an operation."""
154
+ if self._has_control_operation():
155
+ raise ValueError("Control operations must be the first and only operation in a route.")
156
+
157
+ def _add_control_operation(self, fn: Callable[[Message], None]) -> "RouteBuilder":
158
+ """Add a control operation to the route.
159
+
160
+ Control operations are special because they run synchronously.
161
+
162
+ Args:
163
+ fn: Function to run.
164
+
165
+ Returns:
166
+ self
167
+ """
168
+ if len(self.route_config.operations) > 0:
169
+ raise ValueError("Control operations must be the first and only operation in a route.")
170
+
171
+ self.route_config.operations.append({"_fn_type": "control", "fn": fn})
172
+
173
+ def map(self, fn: Callable[[Any], Any]) -> "RouteBuilder":
174
+ """Call a function on the current data.
175
+
176
+ Args:
177
+ fn: The function to call on the data.
178
+ """
179
+ self._validate_pre()
180
+ self.route_config.operations.append({"_fn_type": "map", "fn": fn})
181
+ return self
182
+
183
+ def stream(
184
+ self,
185
+ generator_fn: Optional[Callable[[Any], Union[Any, AsyncIterable]]] = None,
186
+ ) -> "RouteBuilder":
187
+ """Stream results from a generator function through the remaining pipeline.
188
+
189
+ Args:
190
+ generator_fn: Optional generator function. If None, assumes the previous
191
+ operation's output is already an async generator.
192
+ """
193
+ self._validate_pre()
194
+ self.route_config.operations.append({"_fn_type": "stream", "fn": generator_fn})
195
+ return self
196
+
197
+ def filter(self, fn: Callable[[Any], bool]) -> "RouteBuilder":
198
+ """Filter the event - continue only if function returns True."""
199
+ self._validate_pre()
200
+ self.route_config.operations.append({"_fn_type": "filter", "fn": fn})
201
+ return self
202
+
203
+ def broadcast(self, event_type: Optional[EventType] = None) -> "RouteBuilder":
204
+ """Broadcast current data to specified event.
205
+
206
+ Args:
207
+ event_type: The type of event to broadcast the result of the previous operation to.
208
+ If `None`, we assume that the previous operation is returning or yielding an `EventInstance`.
209
+ If provided, the previous operation must return or yield a mapping, which will be used to
210
+ construct the event object: `event_type(**mapping)`.
211
+
212
+ Note:
213
+ It is strongly recommended to have the operation before `broadcast`
214
+ return or yield :class:`EventInstance` objects.
215
+ This is the preferred design as it allows you (the user) to specify the event type and
216
+ data that is the result of the previous operation.
217
+
218
+ Note:
219
+ This method does not return a value. It should be treated as a terminal operation.
220
+ """
221
+ self._validate_pre()
222
+ self.route_config.operations.append({"_fn_type": "broadcast", "event": event_type})
223
+ return self
224
+
225
+ def _add_on_event_handler(
226
+ self,
227
+ event_type: EventType,
228
+ handler: OnEventHandler,
229
+ method_name: Literal["suspend", "resume", "interrupt"],
230
+ ) -> None:
231
+ assert self.route_handler is not None, (
232
+ f"self._set_route_handler is not initialized. It is required for configuring {method_name}."
233
+ )
234
+
235
+ if method_name == "suspend":
236
+ handlers = self.route_config.suspend_handlers
237
+ elif method_name == "resume":
238
+ handlers = self.route_config.resume_handlers
239
+ elif method_name == "interrupt":
240
+ handlers = self.route_config.interrupt_handlers
241
+
242
+ if event_type in handlers:
243
+ raise ValueError(f"Event {event_type} already registered for {method_name}")
244
+
245
+ handler = _EventHandlerDict(handler) if handler is not None else None
246
+ handlers[event_type] = handler
247
+
248
+ # Add implicit handler to the bridge to handle the suspend.
249
+ if method_name == "suspend":
250
+ self.bridge.on(event_type)._add_control_operation(
251
+ lambda message: self.route_handler._suspend(message, handler)
252
+ )
253
+ elif method_name == "resume":
254
+ self.bridge.on(event_type)._add_control_operation(
255
+ lambda message: self.route_handler._resume(message, handler)
256
+ )
257
+ elif method_name == "interrupt":
258
+ self.bridge.on(event_type)._add_control_operation(
259
+ lambda message: self.route_handler._interrupt(message, handler)
260
+ )
261
+ return self
262
+
263
+ def suspend_on(self, event_type: EventType, handler: OnEventHandler = None) -> "RouteBuilder":
264
+ """Suspend route from running when event is received.
265
+
266
+ Args:
267
+ event_type: EventType that should suspend this route.
268
+ handler: Function that runs after the route is suspended.
269
+
270
+ Raises:
271
+ ValueError: If the event is already registered for suspend.
272
+ """
273
+ return self._add_on_event_handler(event_type, handler, "suspend")
274
+
275
+ def resume_on(self, event_type: EventType, handler: OnEventHandler = None) -> "RouteBuilder":
276
+ """Resume route execution when any of these events are received."""
277
+ return self._add_on_event_handler(event_type, handler, "resume")
278
+
279
+ def interrupt_on(self, event_type: EventType, handler: OnEventHandler = None) -> "RouteBuilder":
280
+ """Interrupt this route execution when any of these events are received.
281
+
282
+ Args:
283
+ event: EventType that should interrupt this route.
284
+ handler: Optional callable that runs after the route is cancelled but before the lock is released.
285
+ Receives the interrupt event type as argument.
286
+
287
+ Raises:
288
+ ValueError: If the event is already registered for interrupt.
289
+ """
290
+ return self._add_on_event_handler(event_type, handler, "interrupt")
291
+
292
+ def on(self, event: str) -> "RouteBuilder":
293
+ """Start a new route on a different event."""
294
+ return self.bridge.on(event)
295
+
296
+
297
+ class RouteHandler:
298
+ """Handles execution of a configured route.
299
+
300
+ This class is responsible for executing the route built by :class:`RouteBuilder`.
301
+ """
302
+
303
+ def __init__(self, route_builder: RouteBuilder, bridge: "Bridge"):
304
+ self.route_builder = route_builder
305
+ self.route_builder._set_route_handler(self)
306
+
307
+ self.bridge = bridge
308
+
309
+ # Task management for concurrent task limiting.
310
+ self._active_tasks: list[asyncio.Task] = [] # Track all active tasks.
311
+ self._task_lock = asyncio.Lock() # Thread-safe access to active_tasks.
312
+ # Task for cancelling all active tasks. There can only be one cancel task at a time.
313
+ self._task_cancel_all_tasks: Optional[asyncio.Task] = None
314
+
315
+ @property
316
+ def route_config(self) -> RouteConfig:
317
+ return self.route_builder.route_config
318
+
319
+ def should_process_message(self, message: Message) -> bool:
320
+ """Check if the message should be processed based on all configured filters.
321
+
322
+ Args:
323
+ message: The message to check.
324
+ """
325
+ if self.route_config.state == RouteState.SUSPENDED or len(self.route_config.operations) == 0:
326
+ return False
327
+
328
+ # Apply custom filter function if provided.
329
+ if self.route_config.filter_fn is not None:
330
+ if not self.route_config.filter_fn(message):
331
+ return False
332
+
333
+ # Apply event property filters.
334
+ event_filters = self.route_config.event_property_filters or {}
335
+ for prop_name, expected_value in event_filters.items():
336
+ if not hasattr(message.event, prop_name):
337
+ logger.debug(f"Event {message.event} does not have property {prop_name}")
338
+ return False
339
+
340
+ actual_value = getattr(message.event, prop_name)
341
+ if callable(expected_value):
342
+ if not expected_value(actual_value):
343
+ return False
344
+ elif expected_value != actual_value:
345
+ return False
346
+
347
+ return True
348
+
349
+ async def handle(self, message: Message) -> Any:
350
+ """Handle an incoming message through the route.
351
+
352
+ Returns:
353
+ None: if the route is suspended or has no operations.
354
+ Any: Output result of the route.
355
+ """
356
+ if self.route_config.state == RouteState.SUSPENDED or len(self.route_config.operations) == 0:
357
+ return None
358
+
359
+ # We do not check self.should_process_message because we assume the bridge does the check.
360
+ # And we don't want to do the check twice.
361
+ # if not self.should_process_message(message):
362
+ # return None
363
+
364
+ # Check source filter (legacy support).
365
+ # TODO: We have to filter out events that are not from the source node instead of a .filter() method
366
+ # because we don't know what the return type of the previous operation is.
367
+ # This is more of a utility method.
368
+ if self.route_config.source and message.source != self.route_config.source:
369
+ return None
370
+
371
+ # Wait for the current cancel task to complete before running cancel again.
372
+ if self._task_cancel_all_tasks is not None:
373
+ await await_tasks_safe(self._task_cancel_all_tasks)
374
+
375
+ # Control operations are special because they run synchronously.
376
+ # This allows us to run them without releasing the event lock.
377
+ # This will prevent tasks from being spawned on another route while
378
+ # we are running a control operation.
379
+ if self.route_builder._has_control_operation():
380
+ return self.route_config.operations[0]["fn"](message)
381
+
382
+ # Create new task for this execution.
383
+ try:
384
+ async with self._task_lock:
385
+ if self.route_config.max_concurrent_tasks is not None:
386
+ self._active_tasks = [task for task in self._active_tasks if not task.done()]
387
+ if len(self._active_tasks) >= self.route_config.max_concurrent_tasks:
388
+ oldest_task = self._active_tasks.pop(0)
389
+ # NOTE: We are waiting for the task to be cancelled before creating a new task.
390
+ # Usually this is what we want, but should we expose this as an option to the user?
391
+ await self._cancel_task_with_cleanup(oldest_task)
392
+
393
+ task = asyncio.create_task(self._process_operations(message, self.route_config.operations))
394
+ self._active_tasks.append(task)
395
+
396
+ result = await task
397
+ # TODO: Do we really want to wait to clean up rather than just returning.
398
+ await self._clean_active_tasks_safe()
399
+ return result
400
+ except asyncio.CancelledError:
401
+ # TODO (AD): Improve the debug log here to include information
402
+ # about which route is being cancelled.
403
+ logger.debug(f"Route execution (handler {self}) cancelled")
404
+ return None
405
+
406
+ def _interrupt(
407
+ self,
408
+ message: Message,
409
+ handler: Optional[_EventHandlerDict] = None,
410
+ ) -> None:
411
+ """Request interruption of this route if it's currently executing.
412
+
413
+ Execution Order:
414
+ 1. Cancel all active tasks.
415
+ Tasks that come in after this event will not be cancelled, but they will not be executed
416
+ until the interrupt is complete.
417
+ 2. After *all* active tasks are cancelled, run the handler.
418
+ 3. Clean up canceled tasks from `self._active_tasks`.
419
+
420
+ Note:
421
+ The `handler` is only executed after all active tasks are cancelled.
422
+ It is called regardless of whether the tasks were cancelled or completed.
423
+
424
+ Args:
425
+ message: The message that triggered the interrupt.
426
+ handler: The handler to run after all active tasks are cancelled.
427
+ """
428
+ event = message.event
429
+
430
+ if not self._active_tasks:
431
+ logger.debug("No active tasks to interrupt.")
432
+ return
433
+
434
+ logger.debug(f"Interrupting route due to event {type(event)}")
435
+
436
+ # Cancel all active tasks.
437
+ async def await_cancel_active_tasks(
438
+ canceled_tasks: List[asyncio.Task], prev_cancel_task: Optional[asyncio.Task]
439
+ ):
440
+ # Wait for the current cancel task to complete.
441
+ if prev_cancel_task is not None:
442
+ await await_tasks_safe(prev_cancel_task)
443
+
444
+ await await_tasks_safe(canceled_tasks)
445
+
446
+ if handler is not None:
447
+ handler(message)
448
+
449
+ # Clear these tasks from the active tasks list.
450
+ await self._clean_active_tasks_safe()
451
+
452
+ # NOTE: There might be a race condition here where the task list is being modified somewhere else.
453
+ # We do an unsafe check because we want to cancel as soon as possible.
454
+ active_tasks = [task for task in self._active_tasks if task and not task.done()]
455
+ for task in active_tasks:
456
+ task.cancel()
457
+
458
+ prev_cancel_task = self._task_cancel_all_tasks
459
+ self._task_cancel_all_tasks = asyncio.create_task(
460
+ await_cancel_active_tasks(active_tasks, prev_cancel_task)
461
+ )
462
+
463
+ def _suspend(
464
+ self,
465
+ message: Message,
466
+ handler: Optional[_EventHandlerDict] = None,
467
+ ) -> None:
468
+ """Suspend this route.
469
+
470
+ This means that future messages will not be processed by this route
471
+ until the route is resumed.
472
+
473
+ Execution order:
474
+ 1. Suspend the route. We do this synchronously to prevent future tasks from being created.
475
+ 2. Interrupt all active tasks. Do not run the interrupt handler,
476
+ as there is no guaranteed handler for this event.
477
+ 3. Run the suspend handler.
478
+ """
479
+ self.route_config.state = RouteState.SUSPENDED
480
+
481
+ self._interrupt(message, handler)
482
+ assert len(self._active_tasks) == 0, (
483
+ "All tasks should have been cancelled and no remaining tasks should be active."
484
+ )
485
+
486
+ def _resume(
487
+ self,
488
+ message: Message,
489
+ handler: Optional[_EventHandlerDict] = None,
490
+ ) -> None:
491
+ """Resume this route.
492
+
493
+ Execution order:
494
+ 1. Run the resume handler.
495
+ 2. Resume the route.
496
+
497
+ Args:
498
+ message: The message that triggered the resume.
499
+ handler: The handler to run before the route is resumed.
500
+
501
+ Note:
502
+ The `handler` is only executed after all active tasks are cancelled.
503
+ It is called regardless of whether the tasks were cancelled or completed.
504
+ """
505
+ self.route_config.state = RouteState.RUNNING
506
+ if handler is not None:
507
+ handler(message)
508
+
509
+ async def _clean_active_tasks_safe(self) -> None:
510
+ """Clear the active tasks list."""
511
+ async with self._task_lock:
512
+ self._active_tasks = [task for task in self._active_tasks if not task.done()]
513
+
514
+ # TODO(noah): this is claude-generated and kind of sus to me.
515
+ async def _cancel_task_with_cleanup(self, task: asyncio.Task) -> None:
516
+ """Cancel a task and wait for it to complete."""
517
+ if task.done():
518
+ return
519
+
520
+ task.cancel()
521
+ try:
522
+ await task
523
+ except asyncio.CancelledError:
524
+ pass
525
+
526
+ async def _process_operations(self, data: Any, operations: List[dict]) -> Any:
527
+ """Process data through a sequence of operations."""
528
+ if not operations:
529
+ return data
530
+
531
+ current_data = data
532
+
533
+ for i, operation in enumerate(operations):
534
+ fn_type = operation["_fn_type"]
535
+ fn = operation.get("fn")
536
+ remaining_ops = operations[i + 1 :]
537
+
538
+ if fn_type == "map":
539
+ if asyncio.iscoroutinefunction(fn):
540
+ current_data = await fn(current_data)
541
+ else:
542
+ current_data = fn(current_data)
543
+
544
+ elif fn_type == "stream":
545
+ # Explicit streaming operation
546
+ await self._handle_stream(current_data, fn, remaining_ops)
547
+ return None # Stream consumed
548
+
549
+ elif fn_type == "filter":
550
+ if asyncio.iscoroutinefunction(fn):
551
+ result = await fn(current_data)
552
+ else:
553
+ result = fn(current_data)
554
+ if not result:
555
+ # Filtered out, stop processing.
556
+ return None
557
+
558
+ elif fn_type == "broadcast":
559
+ event_cls: Optional[EventType] = operation["event"]
560
+ event = current_data
561
+ if current_data is not None and self.bridge.bus:
562
+ if event_cls is not None:
563
+ try:
564
+ event = event_cls(**current_data)
565
+ except Exception as e:
566
+ logger.error(
567
+ f"Error coercing data to {event_cls.__name__} with input {current_data}: {e}",
568
+ exc_info=True,
569
+ )
570
+ raise e
571
+ await self.bridge.bus.broadcast(Message(source=self.bridge.node_id, event=event))
572
+
573
+ return None
574
+
575
+ async def _handle_stream(
576
+ self,
577
+ data: Any,
578
+ generator_fn: Optional[Callable],
579
+ remaining_ops: List[dict],
580
+ ) -> None:
581
+ """Handle explicit streaming operation."""
582
+ try:
583
+ if generator_fn is None:
584
+ # Assume data is already an async generator
585
+ if hasattr(data, "__aiter__"):
586
+ # data is async iterable
587
+ async for yielded in data:
588
+ await self._process_operations(yielded, remaining_ops)
589
+ elif hasattr(data, "__iter__") and not isinstance(data, (str, bytes)):
590
+ # data is regular iterable (but not string/bytes)
591
+ for yielded in data:
592
+ await self._process_operations(yielded, remaining_ops)
593
+ else:
594
+ # data is not iterable, treat as single result
595
+ await self._process_operations(data, remaining_ops)
596
+ elif self._is_async_generator_function(generator_fn):
597
+ async for yielded in generator_fn(data):
598
+ await self._process_operations(yielded, remaining_ops)
599
+ elif self._is_generator_function(generator_fn):
600
+ for yielded in generator_fn(data):
601
+ await self._process_operations(yielded, remaining_ops)
602
+ else:
603
+ # Not a generator, treat as single result and continue
604
+ result = generator_fn(data)
605
+ if asyncio.iscoroutine(result):
606
+ result = await result
607
+ await self._process_operations(result, remaining_ops)
608
+ except asyncio.CancelledError as e:
609
+ logger.debug("Stream operation cancelled")
610
+ raise e # Re-raise to propagate cancellation
611
+
612
+ def _is_async_generator_function(self, fn: Callable) -> bool:
613
+ """Check if function returns an async generator."""
614
+ return inspect.isasyncgenfunction(fn)
615
+
616
+ def _is_generator_function(self, fn: Callable) -> bool:
617
+ """Check if function returns a generator."""
618
+ return inspect.isgeneratorfunction(fn)
line/tools/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ from line.tools.system_tools import EndCall, EndCallArgs, end_call
2
+ from line.tools.tool_types import ToolDefinition
3
+
4
+ __all__ = [
5
+ "EndCall",
6
+ "end_call",
7
+ "EndCallArgs",
8
+ "ToolDefinition",
9
+ ]