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/harness.py ADDED
@@ -0,0 +1,257 @@
1
+ """
2
+ ConversationHarness - WebSocket communication layer for agents
3
+ Handles input/output queues and event coordination
4
+ """
5
+
6
+ import asyncio
7
+ from asyncio import QueueEmpty
8
+ import json
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from fastapi import WebSocket, WebSocketDisconnect
12
+ from loguru import logger
13
+ from pydantic import TypeAdapter
14
+
15
+ from line.events import (
16
+ AgentSpeechSent,
17
+ AgentStartedSpeaking,
18
+ AgentStoppedSpeaking,
19
+ UserStartedSpeaking,
20
+ UserStoppedSpeaking,
21
+ UserTranscriptionReceived,
22
+ UserUnknownInputReceived,
23
+ )
24
+ from line.harness_types import (
25
+ AgentSpeechInput,
26
+ AgentStateInput,
27
+ EndCallOutput,
28
+ ErrorOutput,
29
+ InputMessage,
30
+ LogEventOutput,
31
+ LogMetricOutput,
32
+ MessageOutput,
33
+ OutputMessage,
34
+ ToolCallOutput,
35
+ TranscriptionInput,
36
+ TransferOutput,
37
+ UserStateInput,
38
+ )
39
+
40
+
41
+ class State:
42
+ """User voice states."""
43
+
44
+ SPEAKING = "speaking"
45
+ IDLE = "idle"
46
+
47
+
48
+ class ConversationHarness:
49
+ """
50
+ Manages WebSocket communication, input/output queues, and coordination events
51
+ for reasoning agents. Handles message parsing and event triggering.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ websocket: WebSocket,
57
+ shutdown_event: asyncio.Event,
58
+ ):
59
+ """
60
+ Initialize the conversation harness
61
+
62
+ Args:
63
+ websocket: FastAPI WebSocket connection
64
+ shutdown_event: Event to signal shutdown
65
+ """
66
+ self.websocket = websocket
67
+
68
+ # Use provided queues and events
69
+ self.input_queue = asyncio.Queue()
70
+ self.shutdown_event = shutdown_event
71
+
72
+ # Task management
73
+ self.input_task: Optional[asyncio.Task] = None
74
+
75
+ # State tracking
76
+ self.is_running = False
77
+
78
+ async def start(self):
79
+ """
80
+ Start the harness tasks for input and output processing
81
+ """
82
+ if self.is_running:
83
+ logger.warning("ConversationHarness already running")
84
+ return
85
+
86
+ self.is_running = True
87
+ logger.debug("Starting ConversationHarness")
88
+
89
+ # Start input and output tasks
90
+ self.input_task = asyncio.create_task(self._input_processor())
91
+
92
+ logger.debug("ConversationHarness started with input/output processors")
93
+
94
+ async def _input_processor(self):
95
+ """
96
+ Continuously receive messages from WebSocket, parse them, and handle events
97
+ """
98
+ try:
99
+ while not self.shutdown_event.is_set():
100
+ try:
101
+ # Receive message from WebSocket
102
+ message = await self.websocket.receive_json()
103
+ input = TypeAdapter(InputMessage).validate_python(message)
104
+ # Process the message and handle events
105
+ await self.input_queue.put(input)
106
+
107
+ except WebSocketDisconnect:
108
+ logger.info("WebSocket disconnected")
109
+ self.shutdown_event.set()
110
+ break
111
+ except json.JSONDecodeError as e:
112
+ logger.exception(f"Failed to parse JSON message: {e}")
113
+ continue
114
+ except Exception as e:
115
+ logger.exception(f"Error in input processor: {e}")
116
+ if not self.shutdown_event.is_set():
117
+ await asyncio.sleep(0.1) # Brief pause before retry
118
+
119
+ except asyncio.CancelledError:
120
+ logger.info("Input processor cancelled")
121
+ except Exception as e:
122
+ logger.exception(f"Unexpected error in input processor: {e}")
123
+
124
+ async def get(self) -> InputMessage:
125
+ """Get a message from the input queue"""
126
+ return await self.input_queue.get()
127
+
128
+ async def _send(self, output: OutputMessage):
129
+ try:
130
+ if not self.shutdown_event.is_set():
131
+ await self.websocket.send_json(output.model_dump())
132
+ except Exception as e:
133
+ logger.warning(f"Failed to send message via WebSocket: {e}")
134
+ self.shutdown_event.set()
135
+
136
+ async def end_call(self):
137
+ """
138
+ Send end_call message and signal shutdown
139
+ """
140
+ await self._send(EndCallOutput())
141
+ logger.info("End call message sent")
142
+
143
+ async def transfer_call(self, destination: str = ""):
144
+ """
145
+ Send transfer_call message
146
+
147
+ Args:
148
+ destination: Optional destination for call transfer
149
+ """
150
+ await self._send(TransferOutput(target_phone_number=destination))
151
+ logger.info(f"Transfer call message sent to {destination}")
152
+ self.shutdown_event.set()
153
+
154
+ async def send_message(self, message: str):
155
+ """Send a message via WebSocket with connection state checking"""
156
+ logger.info(f'🤖 Agent said: "{message}"')
157
+ await self._send(MessageOutput(content=message))
158
+
159
+ async def send_error(self, error: str):
160
+ """Send an error message via WebSocket with connection state checking"""
161
+ await self._send(ErrorOutput(content=error))
162
+
163
+ async def send_tool_call(
164
+ self,
165
+ tool_name: str,
166
+ tool_args: Dict[str, Any],
167
+ tool_call_id: Optional[str] = None,
168
+ result: Optional[str] = None,
169
+ ):
170
+ """Send a tool call result via WebSocket with connection state checking"""
171
+ await self._send(
172
+ ToolCallOutput(
173
+ name=tool_name,
174
+ arguments=tool_args,
175
+ result=result,
176
+ id=tool_call_id,
177
+ )
178
+ )
179
+
180
+ async def log_event(self, event: str, metadata: Optional[Dict[str, Any]] = None):
181
+ """
182
+ Send a log event via WebSocket
183
+
184
+ Args:
185
+ event: The event name/type being logged
186
+ metadata: Optional metadata dictionary for the event
187
+ """
188
+ logger.debug(f"📊 Logging event: {event}" + (f" - {metadata}" if metadata else ""))
189
+ await self._send(LogEventOutput(event=event, metadata=metadata))
190
+
191
+ async def log_metric(self, name: str, value: Any):
192
+ """
193
+ Send a log metric via WebSocket
194
+
195
+ Args:
196
+ name: The metric name
197
+ value: The metric value (can be any JSON-serializable type)
198
+ """
199
+ logger.debug(f"📈 Logging metric: {name}={value}")
200
+ await self._send(LogMetricOutput(name=name, value=value))
201
+
202
+ async def cleanup(self):
203
+ """
204
+ Clean up resources and stop all tasks
205
+ """
206
+ logger.info("Cleaning up ConversationHarness")
207
+
208
+ # Signal shutdown
209
+ self.shutdown_event.set()
210
+ self.is_running = False
211
+
212
+ # Cancel tasks
213
+ if self.input_task and not self.input_task.done():
214
+ self.input_task.cancel()
215
+ try:
216
+ await self.input_task
217
+ except asyncio.CancelledError:
218
+ pass
219
+
220
+ # Clear any remaining messages in queues
221
+ while not self.input_queue.empty():
222
+ try:
223
+ self.input_queue.get_nowait()
224
+ self.input_queue.task_done()
225
+ except QueueEmpty:
226
+ break
227
+
228
+ logger.info("ConversationHarness cleanup completed")
229
+
230
+ def map_to_events(self, message: InputMessage) -> List[Any]:
231
+ """Convert harness-specific message to bus events."""
232
+ if isinstance(message, UserStateInput):
233
+ if message.value == State.SPEAKING:
234
+ logger.info("🎤 User started speaking")
235
+ return [UserStartedSpeaking()]
236
+ elif message.value == State.IDLE:
237
+ logger.info("🔇 User stopped speaking")
238
+ return [UserStoppedSpeaking()]
239
+ elif isinstance(message, TranscriptionInput):
240
+ logger.info(f'📝 User said: "{message.content}"')
241
+ return [UserTranscriptionReceived(content=message.content)]
242
+ elif isinstance(message, AgentStateInput):
243
+ if message.value == State.SPEAKING:
244
+ logger.info("🎤 Agent started speaking")
245
+ return [AgentStartedSpeaking()]
246
+ elif message.value == State.IDLE:
247
+ logger.info("🔇 Agent stopped speaking")
248
+ return [AgentStoppedSpeaking()]
249
+ elif isinstance(message, AgentSpeechInput):
250
+ logger.info(f'🗣️ Agent speech sent: "{message.content}"')
251
+ return [AgentSpeechSent(content=message.content)]
252
+ else:
253
+ # Fallback for unknown types.
254
+ logger.warning(f"Unknown message type: {type(message).__name__} ({message.model_dump_json()})")
255
+ return [UserUnknownInputReceived(input_data=message.model_dump_json())]
256
+
257
+ return [] # No events for unhandled states
line/harness_types.py ADDED
@@ -0,0 +1,109 @@
1
+ from typing import Dict, Literal, Optional, Union
2
+
3
+ from pydantic import BaseModel
4
+
5
+ ########################################################
6
+ # Copied and adapted from Bifrost agent_types.py
7
+ ########################################################
8
+
9
+ # Input messages to be sent over the websocket to the user code
10
+
11
+
12
+ class TranscriptionInput(BaseModel):
13
+ content: str
14
+ type: Literal["message"] = "message"
15
+
16
+
17
+ class DTMFInput(BaseModel):
18
+ button: str
19
+ type: Literal["dtmf"] = "dtmf"
20
+
21
+
22
+ class UserStateInput(BaseModel):
23
+ value: str
24
+ type: Literal["user_state"] = "user_state"
25
+
26
+
27
+ class AgentStateInput(BaseModel):
28
+ value: str
29
+ type: Literal["agent_state"] = "agent_state"
30
+
31
+
32
+ class ValidationErrorInput(BaseModel):
33
+ error_message: str
34
+ error_type: str
35
+ type: Literal["validation_error"] = "validation_error"
36
+
37
+
38
+ class AgentSpeechInput(BaseModel):
39
+ content: str
40
+ type: Literal["agent_speech"] = "agent_speech"
41
+
42
+
43
+ InputMessage = Union[
44
+ TranscriptionInput,
45
+ DTMFInput,
46
+ UserStateInput,
47
+ AgentStateInput,
48
+ ValidationErrorInput,
49
+ AgentSpeechInput,
50
+ ]
51
+
52
+
53
+ # Output messages to be received from the user code
54
+
55
+
56
+ class ErrorOutput(BaseModel):
57
+ type: Literal["error"] = "error"
58
+ content: str
59
+
60
+
61
+ class DTMFOutput(BaseModel):
62
+ type: Literal["dtmf"] = "dtmf"
63
+ button: str
64
+
65
+
66
+ class MessageOutput(BaseModel):
67
+ type: Literal["message"] = "message"
68
+ content: str
69
+
70
+
71
+ class ToolCallOutput(BaseModel):
72
+ type: Literal["tool_call"] = "tool_call"
73
+ name: str
74
+ arguments: Dict[str, object]
75
+ result: Optional[str] = None
76
+ id: Optional[str] = None
77
+
78
+
79
+ class TransferOutput(BaseModel):
80
+ type: Literal["transfer"] = "transfer"
81
+ target_phone_number: str
82
+
83
+
84
+ class EndCallOutput(BaseModel):
85
+ type: Literal["end_call"] = "end_call"
86
+
87
+
88
+ class LogEventOutput(BaseModel):
89
+ type: Literal["log_event"] = "log_event"
90
+ event: str
91
+ metadata: Optional[Dict[str, object]] = None
92
+
93
+
94
+ class LogMetricOutput(BaseModel):
95
+ type: Literal["log_metric"] = "log_metric"
96
+ name: str
97
+ value: object
98
+
99
+
100
+ OutputMessage = Union[
101
+ ErrorOutput,
102
+ DTMFOutput,
103
+ MessageOutput,
104
+ ToolCallOutput,
105
+ TransferOutput,
106
+ EndCallOutput,
107
+ LogEventOutput,
108
+ LogMetricOutput,
109
+ ]
line/nodes/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from line.nodes.base import Node
2
+ from line.nodes.reasoning import ReasoningNode
3
+
4
+ __all__ = [
5
+ "Node",
6
+ "ReasoningNode",
7
+ ]
line/nodes/base.py ADDED
@@ -0,0 +1,60 @@
1
+ from typing import TYPE_CHECKING, AsyncGenerator, Optional
2
+ from uuid import uuid4
3
+
4
+ from loguru import logger
5
+
6
+ from line.bus import Message
7
+ from line.events import EventType
8
+
9
+ if TYPE_CHECKING:
10
+ from line.bridge import Bridge
11
+
12
+
13
+ class Node:
14
+ """A base class for all nodes.
15
+
16
+ Nodes are the building blocks of the agentic system. They are responsible for:
17
+ - Maintaining state
18
+ - Generating responses
19
+ - Handling tool calls
20
+ - Interrupting the generation process
21
+
22
+ Nodes are stateful, and can be used to build multi-agent workflows.
23
+
24
+ All nodes have an `id` that is used to identify them.
25
+ When a :class:`Bridge` is created from a node, the node's `id` is used to identify the node in the bridge.
26
+ It can be used when filtering by `source`.
27
+ We do not require that nodes have a unique `id`.
28
+ """
29
+
30
+ def __init__(self, node_id: Optional[str] = None):
31
+ self.id = node_id or uuid4().hex
32
+ self._bridge: Optional[Bridge] = None
33
+
34
+ async def start(self):
35
+ """Start the node, in an async context.
36
+
37
+ This method is called when the VoiceAgentSystem is started. Use this method to run
38
+ initialization logic that needs to run in an async context (eg, database connections).
39
+ """
40
+ pass
41
+
42
+ def __str__(self):
43
+ return f"{type(self).__name__}(id={self.id})"
44
+
45
+ async def cleanup(self):
46
+ """Clean up the node."""
47
+ logger.debug(f"{self} cleanup completed")
48
+
49
+ def on_interrupt_generate(self, message: Message) -> None:
50
+ """Handle interrupt event.
51
+
52
+ Args:
53
+ message: The interrupt message.
54
+ """
55
+ logger.debug(f"{self} interrupt received.")
56
+
57
+ async def generate(self, message: Message) -> AsyncGenerator[EventType, None]:
58
+ """Generate a response to the message."""
59
+ raise NotImplementedError("Subclasses must implement `generate`.")
60
+ yield
@@ -0,0 +1,66 @@
1
+ """
2
+ ConversationContext - Data structure for conversation state in ReasoningNode template method.
3
+
4
+ This class provides a clean abstraction for conversation data that gets passed
5
+ to specialized processing methods in ReasoningNode subclasses.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Any, List, Optional
10
+
11
+ from line.events import EventInstance, UserTranscriptionReceived
12
+
13
+
14
+ @dataclass
15
+ class ConversationContext:
16
+ """
17
+ Encapsulates conversation state for ReasoningNode template method pattern.
18
+
19
+ This standardizes how conversation data is passed between the template method
20
+ (ReasoningNode.generate) and specialized processing (process_context).
21
+
22
+ Attributes:
23
+ events: List of conversation events
24
+ system_prompt: The system prompt for this reasoning node
25
+ metadata: Additional context data for specialized processing
26
+ """
27
+
28
+ events: List[EventInstance]
29
+ system_prompt: str
30
+ metadata: dict = None
31
+
32
+ def __post_init__(self):
33
+ """Initialize metadata if not provided."""
34
+ if self.metadata is None:
35
+ self.metadata = {}
36
+
37
+ def format_events(self, max_messages: int = None) -> str:
38
+ """
39
+ Format conversation messages as a string for LLM prompts.
40
+
41
+ Args:
42
+ max_messages: Maximum number of recent messages to include
43
+
44
+ Returns:
45
+ Formatted conversation string
46
+ """
47
+ events = self.events
48
+ if max_messages is not None:
49
+ events = events[-max_messages:]
50
+
51
+ return "\n".join(f"{type(event)}: {event}" for event in events)
52
+
53
+ def get_latest_user_transcript_message(self) -> Optional[str]:
54
+ """Get the most recent user message content."""
55
+ for msg in reversed(self.events):
56
+ if isinstance(msg, UserTranscriptionReceived):
57
+ return msg.content
58
+ return None
59
+
60
+ def get_event_count(self) -> int:
61
+ """Get total number of messages in context."""
62
+ return len(self.events)
63
+
64
+ def add_metadata(self, key: str, value: Any) -> None:
65
+ """Add metadata for specialized processing."""
66
+ self.metadata[key] = value