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.
- cartesia_line-0.0.1.dist-info/METADATA +25 -0
- cartesia_line-0.0.1.dist-info/RECORD +27 -0
- cartesia_line-0.0.1.dist-info/WHEEL +5 -0
- cartesia_line-0.0.1.dist-info/licenses/LICENSE +201 -0
- cartesia_line-0.0.1.dist-info/top_level.txt +1 -0
- line/__init__.py +29 -0
- line/bridge.py +348 -0
- line/bus.py +401 -0
- line/call_request.py +25 -0
- line/events.py +218 -0
- line/harness.py +257 -0
- line/harness_types.py +109 -0
- line/nodes/__init__.py +7 -0
- line/nodes/base.py +60 -0
- line/nodes/conversation_context.py +66 -0
- line/nodes/reasoning.py +223 -0
- line/routes.py +618 -0
- line/tools/__init__.py +9 -0
- line/tools/system_tools.py +120 -0
- line/tools/tool_types.py +39 -0
- line/user_bridge.py +200 -0
- line/utils/__init__.py +0 -0
- line/utils/aio.py +62 -0
- line/utils/gemini_utils.py +152 -0
- line/utils/openai_utils.py +122 -0
- line/voice_agent_app.py +147 -0
- line/voice_agent_system.py +230 -0
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)
|