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/voice_agent_app.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from typing import Awaitable, Callable, Optional
|
|
6
|
+
from urllib.parse import urlencode
|
|
7
|
+
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
|
10
|
+
import uvicorn
|
|
11
|
+
|
|
12
|
+
from line.call_request import CallRequest, PreCallResult
|
|
13
|
+
from line.voice_agent_system import VoiceAgentSystem
|
|
14
|
+
|
|
15
|
+
# Load environment variables from .env file
|
|
16
|
+
load_dotenv()
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VoiceAgentApp:
|
|
22
|
+
"""
|
|
23
|
+
VoiceAgentApp (name tbd) abstracts away the HTTP and websocket handling,
|
|
24
|
+
which should be invisible to developers, because this transport may change
|
|
25
|
+
in the future (eg to WebRTC).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
call_handler,
|
|
31
|
+
pre_call_handler: Optional[Callable[[CallRequest], Awaitable[Optional[PreCallResult]]]] = None,
|
|
32
|
+
):
|
|
33
|
+
self.fastapi_app = FastAPI()
|
|
34
|
+
self.call_handler = call_handler
|
|
35
|
+
self.pre_call_handler = pre_call_handler
|
|
36
|
+
self.ws_route = "/ws"
|
|
37
|
+
|
|
38
|
+
self.fastapi_app.add_api_route("/chats", self.create_chat_session, methods=["POST"])
|
|
39
|
+
self.fastapi_app.add_api_route("/status", self.get_status, methods=["GET"])
|
|
40
|
+
self.fastapi_app.add_websocket_route(self.ws_route, self.websocket_endpoint)
|
|
41
|
+
|
|
42
|
+
async def create_chat_session(self, request: Request) -> dict:
|
|
43
|
+
"""Create a new chat session and return the websocket URL."""
|
|
44
|
+
# Parse JSON body
|
|
45
|
+
body = await request.json()
|
|
46
|
+
|
|
47
|
+
# Create initial CallRequest
|
|
48
|
+
call_request = CallRequest(
|
|
49
|
+
call_id=body.get("call_id", "unknown"),
|
|
50
|
+
from_=body.get("from_", "unknown"),
|
|
51
|
+
to=body.get("to", "unknown"),
|
|
52
|
+
agent_call_id=body.get("agent_call_id", body.get("call_id", "unknown")),
|
|
53
|
+
metadata=body.get("metadata", {}),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Run pre-call handler if provided
|
|
57
|
+
config = None
|
|
58
|
+
if self.pre_call_handler:
|
|
59
|
+
try:
|
|
60
|
+
result = await self.pre_call_handler(call_request)
|
|
61
|
+
if result is None:
|
|
62
|
+
raise HTTPException(status_code=403, detail="Call rejected")
|
|
63
|
+
|
|
64
|
+
# Update call_request metadata with result
|
|
65
|
+
call_request.metadata.update(result.metadata)
|
|
66
|
+
config = result.config
|
|
67
|
+
|
|
68
|
+
except HTTPException:
|
|
69
|
+
raise
|
|
70
|
+
except Exception as e:
|
|
71
|
+
logger.error(f"Error in pre_call_handler: {str(e)}")
|
|
72
|
+
raise HTTPException(status_code=500, detail="Server error in call processing") from e
|
|
73
|
+
|
|
74
|
+
# Create URL parameters from processed call_request
|
|
75
|
+
url_params = {
|
|
76
|
+
"call_id": call_request.call_id,
|
|
77
|
+
"from": call_request.from_,
|
|
78
|
+
"to": call_request.to,
|
|
79
|
+
"agent_call_id": call_request.agent_call_id,
|
|
80
|
+
"metadata": json.dumps(call_request.metadata), # JSON encode metadata
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# Build websocket URL with parameters
|
|
84
|
+
query_string = urlencode(url_params)
|
|
85
|
+
websocket_url = f"{self.ws_route}?{query_string}"
|
|
86
|
+
|
|
87
|
+
response = {"websocket_url": websocket_url}
|
|
88
|
+
if config:
|
|
89
|
+
response["config"] = config
|
|
90
|
+
return response
|
|
91
|
+
|
|
92
|
+
async def get_status(self) -> dict:
|
|
93
|
+
"""Status endpoint that returns OK if the server is running."""
|
|
94
|
+
logger.info("Health check endpoint called - voice agent is ready 🤖✅")
|
|
95
|
+
return {
|
|
96
|
+
"status": "ok",
|
|
97
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
98
|
+
"service": "cartesia-line",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async def websocket_endpoint(self, websocket: WebSocket):
|
|
102
|
+
"""Websocket endpoint that manages the complete call lifecycle."""
|
|
103
|
+
await websocket.accept()
|
|
104
|
+
logger.info("Client connected")
|
|
105
|
+
|
|
106
|
+
# Parse query parameters from WebSocket URL
|
|
107
|
+
query_params = dict(websocket.query_params)
|
|
108
|
+
|
|
109
|
+
# Parse metadata JSON
|
|
110
|
+
metadata = {}
|
|
111
|
+
if "metadata" in query_params:
|
|
112
|
+
try:
|
|
113
|
+
metadata = json.loads(query_params["metadata"])
|
|
114
|
+
except (json.JSONDecodeError, TypeError):
|
|
115
|
+
logger.warning(f"Invalid metadata JSON: {query_params['metadata']}")
|
|
116
|
+
metadata = {}
|
|
117
|
+
|
|
118
|
+
# Create CallRequest from URL parameters
|
|
119
|
+
call_request = CallRequest(
|
|
120
|
+
call_id=query_params.get("call_id", "unknown"),
|
|
121
|
+
from_=query_params.get("from", "unknown"),
|
|
122
|
+
to=query_params.get("to", "unknown"),
|
|
123
|
+
agent_call_id=query_params.get("agent_call_id", "unknown"),
|
|
124
|
+
metadata=metadata,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
system = VoiceAgentSystem(websocket)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
# Handler configures nodes and bridges, then starts system
|
|
131
|
+
await self.call_handler(system, call_request)
|
|
132
|
+
except WebSocketDisconnect:
|
|
133
|
+
logger.info("Client disconnected")
|
|
134
|
+
except Exception as e:
|
|
135
|
+
logger.exception(f"Error: {str(e)}")
|
|
136
|
+
try:
|
|
137
|
+
await system.harness.send_error("System has encountered an error, please try again later.")
|
|
138
|
+
await system.harness.end_call()
|
|
139
|
+
except: # noqa: E722
|
|
140
|
+
pass
|
|
141
|
+
finally:
|
|
142
|
+
await system.cleanup()
|
|
143
|
+
|
|
144
|
+
def run(self, host="0.0.0.0", port=None):
|
|
145
|
+
"""Run the voice agent server."""
|
|
146
|
+
port = port or int(os.getenv("PORT", 8000))
|
|
147
|
+
uvicorn.run(self.fastapi_app, host=host, port=port)
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""
|
|
2
|
+
VoiceAgentSystem
|
|
3
|
+
|
|
4
|
+
A utility for building voice agent systems.
|
|
5
|
+
|
|
6
|
+
- Manages bus and harness setup automatically.
|
|
7
|
+
- Lets you add and customize components easily.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Dict, Optional
|
|
12
|
+
|
|
13
|
+
from loguru import logger
|
|
14
|
+
|
|
15
|
+
from line.bridge import Bridge
|
|
16
|
+
from line.bus import Bus, Message
|
|
17
|
+
from line.events import AgentResponse
|
|
18
|
+
from line.harness import ConversationHarness
|
|
19
|
+
from line.nodes.reasoning import ReasoningNode
|
|
20
|
+
from line.user_bridge import create_user_bridge
|
|
21
|
+
|
|
22
|
+
# Configure logging to filter out DEBUG messages from line modules
|
|
23
|
+
logger.configure(
|
|
24
|
+
handlers=[
|
|
25
|
+
{
|
|
26
|
+
"sink": sys.stderr,
|
|
27
|
+
"filter": lambda record: not (
|
|
28
|
+
record["name"].startswith("line") and record["level"].name == "DEBUG"
|
|
29
|
+
),
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class VoiceAgentSystem:
|
|
36
|
+
"""
|
|
37
|
+
System builder for voice agent applications.
|
|
38
|
+
|
|
39
|
+
Automatically manages Bus and ConversationHarness lifecycle
|
|
40
|
+
while providing fluent API for adding bridges and components.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, websocket):
|
|
44
|
+
"""
|
|
45
|
+
Create voice agent system with websocket.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
websocket: FastAPI WebSocket connection for voice communication.
|
|
49
|
+
"""
|
|
50
|
+
self.websocket = websocket
|
|
51
|
+
self.bus = Bus()
|
|
52
|
+
self.harness = ConversationHarness(websocket, self.bus.shutdown_event)
|
|
53
|
+
self.bridges: Dict[str, Bridge] = {}
|
|
54
|
+
self.components = {} # Store nodes and other components.
|
|
55
|
+
self.authorized_node = None
|
|
56
|
+
self.main_node: Optional[ReasoningNode] = None # Track the main reasoning node.
|
|
57
|
+
|
|
58
|
+
self._add_logging_bridge()
|
|
59
|
+
|
|
60
|
+
def _add_logging_bridge(self):
|
|
61
|
+
"""Add a bridge that logs all messages to the console."""
|
|
62
|
+
bridge = Bridge("logging")
|
|
63
|
+
|
|
64
|
+
def log_message(msg):
|
|
65
|
+
logger.debug(f"Logger Bridge:\n\n{msg}")
|
|
66
|
+
|
|
67
|
+
bridge.on("*").map(log_message)
|
|
68
|
+
self.bridges["logging"] = bridge
|
|
69
|
+
|
|
70
|
+
def _setup_authorized_infrastructure(self, authorized_node: str):
|
|
71
|
+
"""Setup user bridge for the authorized agent."""
|
|
72
|
+
|
|
73
|
+
# Auto-add user bridge for WebSocket communication.
|
|
74
|
+
user_bridge = create_user_bridge(self.harness, authorized_node)
|
|
75
|
+
self.bridges["user"] = user_bridge
|
|
76
|
+
|
|
77
|
+
def with_node(self, node: ReasoningNode, bridge: Optional[Bridge] = None):
|
|
78
|
+
"""
|
|
79
|
+
Add reasoning node and optional bridge.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
name: Node identifier.
|
|
83
|
+
node: Reasoning node instance.
|
|
84
|
+
bridge: Optional NodeBridge instance for the node.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Self for method chaining.
|
|
88
|
+
"""
|
|
89
|
+
self.components[node.id] = node
|
|
90
|
+
|
|
91
|
+
# Track the main/authorized node.
|
|
92
|
+
if self.authorized_node == node.id:
|
|
93
|
+
self.main_node = node
|
|
94
|
+
if bridge:
|
|
95
|
+
self.with_bridge(node.id, bridge)
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
def with_speaking_node(self, node: ReasoningNode, bridge: Bridge) -> "VoiceAgentSystem":
|
|
99
|
+
"""
|
|
100
|
+
Add the speaking reasoning node and optional bridge, setting it as the authorized agent.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
node: Reasoning node instance.
|
|
104
|
+
bridge: Optional NodeBridge instance for the speaking node.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Self for method chaining.
|
|
108
|
+
"""
|
|
109
|
+
if self.authorized_node is None:
|
|
110
|
+
self.authorized_node = bridge.node_id
|
|
111
|
+
self._setup_authorized_infrastructure(authorized_node=bridge.node_id)
|
|
112
|
+
logger.info(f"VoiceAgentSystem: Set authorized agent to '{bridge.node_id}'")
|
|
113
|
+
elif self.authorized_node != bridge.node_id:
|
|
114
|
+
logger.warning(
|
|
115
|
+
f"VoiceAgentSystem: Authorized agent already set to '{self.authorized_node}', "
|
|
116
|
+
f"ignoring '{bridge.node_id}'"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
result = self.with_node(node)
|
|
120
|
+
if bridge:
|
|
121
|
+
result = result.with_bridge(bridge.node_id, bridge)
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
def with_main_bridge(self, bridge: Bridge) -> "VoiceAgentSystem":
|
|
125
|
+
"""
|
|
126
|
+
Add the main bridge using the authorized agent name.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
bridge: Configured NodeBridge instance.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Self for method chaining.
|
|
133
|
+
"""
|
|
134
|
+
if self.authorized_node is None:
|
|
135
|
+
raise ValueError("Must call with_main_node() before with_main_bridge()")
|
|
136
|
+
return self.with_bridge(self.authorized_node, bridge)
|
|
137
|
+
|
|
138
|
+
def with_bridge(self, name: str, bridge: Bridge) -> "VoiceAgentSystem":
|
|
139
|
+
"""
|
|
140
|
+
Add bridge to the system.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
name: Bridge identifier for bus registration.
|
|
144
|
+
bridge: Configured NodeBridge instance.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Self for method chaining.
|
|
148
|
+
"""
|
|
149
|
+
self.bridges[name] = bridge
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
async def start(self):
|
|
153
|
+
"""Start the voice agent system."""
|
|
154
|
+
|
|
155
|
+
# Register all bridges quietly first
|
|
156
|
+
for name, bridge in self.bridges.items():
|
|
157
|
+
logger.debug(f"VoiceAgentSystem: Registering bridge '{name}'")
|
|
158
|
+
self.bus.register_bridge(name, bridge)
|
|
159
|
+
|
|
160
|
+
# Initialize all reasoning nodes quietly
|
|
161
|
+
for name, component in self.components.items():
|
|
162
|
+
if hasattr(component, "start"):
|
|
163
|
+
logger.debug(f"VoiceAgentSystem: Starting component '{name}'")
|
|
164
|
+
await component.start()
|
|
165
|
+
|
|
166
|
+
# Start bus (which will show the summary) then other components
|
|
167
|
+
await self.bus.start()
|
|
168
|
+
await self.harness.start()
|
|
169
|
+
|
|
170
|
+
# Start all bridges quietly
|
|
171
|
+
for _, bridge in self.bridges.items():
|
|
172
|
+
await bridge.start()
|
|
173
|
+
|
|
174
|
+
logger.info("VoiceAgentSystem: All components started successfully")
|
|
175
|
+
|
|
176
|
+
async def cleanup(self):
|
|
177
|
+
"""Clean shutdown of the voice agent system."""
|
|
178
|
+
logger.info("VoiceAgentSystem: Starting system cleanup")
|
|
179
|
+
|
|
180
|
+
# Stop bridges first.
|
|
181
|
+
logger.debug(f"VoiceAgentSystem: Stopping {len(self.bridges)} bridges")
|
|
182
|
+
for name, bridge in self.bridges.items():
|
|
183
|
+
logger.debug(f"VoiceAgentSystem: Stopping bridge '{name}'")
|
|
184
|
+
try:
|
|
185
|
+
await bridge.stop()
|
|
186
|
+
except Exception as e:
|
|
187
|
+
logger.error(f"VoiceAgentSystem: Error stopping bridge '{name}': {e}")
|
|
188
|
+
raise e
|
|
189
|
+
|
|
190
|
+
# Cleanup reasoning nodes.
|
|
191
|
+
logger.debug(f"VoiceAgentSystem: Cleaning up {len(self.components)} components")
|
|
192
|
+
for name, component in self.components.items():
|
|
193
|
+
if hasattr(component, "cleanup"):
|
|
194
|
+
logger.debug(f"VoiceAgentSystem: Cleaning up component '{name}'")
|
|
195
|
+
await component.cleanup()
|
|
196
|
+
|
|
197
|
+
# Then stop infrastructure.
|
|
198
|
+
logger.debug("VoiceAgentSystem: Cleaning up ConversationHarness")
|
|
199
|
+
await self.harness.cleanup()
|
|
200
|
+
|
|
201
|
+
logger.debug("VoiceAgentSystem: Cleaning up Bus")
|
|
202
|
+
await self.bus.cleanup()
|
|
203
|
+
|
|
204
|
+
logger.info("VoiceAgentSystem: System cleanup completed")
|
|
205
|
+
|
|
206
|
+
async def wait_for_shutdown(self):
|
|
207
|
+
"""Wait until system should shut down (WebSocket disconnect)."""
|
|
208
|
+
await self.bus.shutdown_event.wait()
|
|
209
|
+
|
|
210
|
+
async def send_initial_message(self, message: str):
|
|
211
|
+
"""
|
|
212
|
+
Send initial message to user and add as a message to the main node's conversation.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
message (str): Initial greeting message.
|
|
216
|
+
"""
|
|
217
|
+
logger.info(f"VoiceAgentSystem: Sending initial message: '{message[:50]}...'")
|
|
218
|
+
await self.bus.broadcast(Message(source=self.authorized_node, event=AgentResponse(content=message)))
|
|
219
|
+
|
|
220
|
+
# Add to main node conversation history.
|
|
221
|
+
if self.main_node and hasattr(self.main_node, "add_event"):
|
|
222
|
+
logger.debug("VoiceAgentSystem: Adding initial message to main node conversation history")
|
|
223
|
+
self.main_node.add_event(AgentResponse(content=message))
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def user_bridge(self) -> Bridge:
|
|
227
|
+
"""Get the user bridge."""
|
|
228
|
+
if "user" not in self.bridges:
|
|
229
|
+
raise ValueError("User bridge not found. Must call with_speaking_node() first.")
|
|
230
|
+
return self.bridges["user"]
|