elevenagents-livekit-plugin 0.1.0__tar.gz
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.
- elevenagents_livekit_plugin-0.1.0/PKG-INFO +13 -0
- elevenagents_livekit_plugin-0.1.0/pyproject.toml +22 -0
- elevenagents_livekit_plugin-0.1.0/setup.cfg +4 -0
- elevenagents_livekit_plugin-0.1.0/src/elevenagents_livekit_plugin/__init__.py +5 -0
- elevenagents_livekit_plugin-0.1.0/src/elevenagents_livekit_plugin/adapter.py +93 -0
- elevenagents_livekit_plugin-0.1.0/src/elevenagents_livekit_plugin/bridge.py +83 -0
- elevenagents_livekit_plugin-0.1.0/src/elevenagents_livekit_plugin/livekit_client.py +190 -0
- elevenagents_livekit_plugin-0.1.0/src/elevenagents_livekit_plugin/server.py +92 -0
- elevenagents_livekit_plugin-0.1.0/src/elevenagents_livekit_plugin/tools.py +99 -0
- elevenagents_livekit_plugin-0.1.0/src/elevenagents_livekit_plugin.egg-info/PKG-INFO +13 -0
- elevenagents_livekit_plugin-0.1.0/src/elevenagents_livekit_plugin.egg-info/SOURCES.txt +12 -0
- elevenagents_livekit_plugin-0.1.0/src/elevenagents_livekit_plugin.egg-info/dependency_links.txt +1 -0
- elevenagents_livekit_plugin-0.1.0/src/elevenagents_livekit_plugin.egg-info/requires.txt +9 -0
- elevenagents_livekit_plugin-0.1.0/src/elevenagents_livekit_plugin.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: elevenagents-livekit-plugin
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bridge between LiveKit Agents and ElevenAgents Voice Orchestration
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: livekit>=1.0.0
|
|
7
|
+
Requires-Dist: livekit-api>=1.0.0
|
|
8
|
+
Requires-Dist: fastapi>=0.100.0
|
|
9
|
+
Requires-Dist: uvicorn>=0.20.0
|
|
10
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "elevenagents-livekit-plugin"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Bridge between LiveKit Agents and ElevenAgents Voice Orchestration"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"livekit>=1.0.0",
|
|
12
|
+
"livekit-api>=1.0.0",
|
|
13
|
+
"fastapi>=0.100.0",
|
|
14
|
+
"uvicorn>=0.20.0",
|
|
15
|
+
"python-dotenv>=1.0.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = ["pytest", "pytest-asyncio"]
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["src"]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Converts LiveKit text chunks and tool calls into OpenAI-compatible SSE format."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _make_id() -> str:
|
|
9
|
+
return f"chatcmpl-{uuid.uuid4().hex[:12]}"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def format_chunk(content: str, model: str = "livekit-agent") -> str:
|
|
13
|
+
"""Format a text chunk as an OpenAI chat completion SSE chunk."""
|
|
14
|
+
chunk = {
|
|
15
|
+
"id": _make_id(),
|
|
16
|
+
"object": "chat.completion.chunk",
|
|
17
|
+
"created": int(time.time()),
|
|
18
|
+
"model": model,
|
|
19
|
+
"choices": [
|
|
20
|
+
{
|
|
21
|
+
"index": 0,
|
|
22
|
+
"delta": {"content": content},
|
|
23
|
+
"finish_reason": None,
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
}
|
|
27
|
+
return f"data: {json.dumps(chunk)}\n\n"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def format_first_chunk(model: str = "livekit-agent") -> str:
|
|
31
|
+
"""Format the initial SSE chunk with the role."""
|
|
32
|
+
chunk = {
|
|
33
|
+
"id": _make_id(),
|
|
34
|
+
"object": "chat.completion.chunk",
|
|
35
|
+
"created": int(time.time()),
|
|
36
|
+
"model": model,
|
|
37
|
+
"choices": [
|
|
38
|
+
{
|
|
39
|
+
"index": 0,
|
|
40
|
+
"delta": {"role": "assistant", "content": ""},
|
|
41
|
+
"finish_reason": None,
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
}
|
|
45
|
+
return f"data: {json.dumps(chunk)}\n\n"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def format_tool_call(tool_name: str, tool_args: dict, model: str = "livekit-agent") -> str:
|
|
49
|
+
"""Format a tool call as an OpenAI chat completion SSE chunk."""
|
|
50
|
+
chunk = {
|
|
51
|
+
"id": _make_id(),
|
|
52
|
+
"object": "chat.completion.chunk",
|
|
53
|
+
"created": int(time.time()),
|
|
54
|
+
"model": model,
|
|
55
|
+
"choices": [
|
|
56
|
+
{
|
|
57
|
+
"index": 0,
|
|
58
|
+
"delta": {
|
|
59
|
+
"tool_calls": [
|
|
60
|
+
{
|
|
61
|
+
"index": 0,
|
|
62
|
+
"id": f"call_{uuid.uuid4().hex[:8]}",
|
|
63
|
+
"type": "function",
|
|
64
|
+
"function": {
|
|
65
|
+
"name": tool_name,
|
|
66
|
+
"arguments": json.dumps(tool_args),
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
},
|
|
71
|
+
"finish_reason": None,
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
}
|
|
75
|
+
return f"data: {json.dumps(chunk)}\n\n"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def format_done_chunk(model: str = "livekit-agent") -> str:
|
|
79
|
+
"""Format the final SSE chunk with finish_reason=stop."""
|
|
80
|
+
chunk = {
|
|
81
|
+
"id": _make_id(),
|
|
82
|
+
"object": "chat.completion.chunk",
|
|
83
|
+
"created": int(time.time()),
|
|
84
|
+
"model": model,
|
|
85
|
+
"choices": [
|
|
86
|
+
{
|
|
87
|
+
"index": 0,
|
|
88
|
+
"delta": {},
|
|
89
|
+
"finish_reason": "stop",
|
|
90
|
+
}
|
|
91
|
+
],
|
|
92
|
+
}
|
|
93
|
+
return f"data: {json.dumps(chunk)}\n\ndata: [DONE]\n\n"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Main entry point — wires up the LiveKit client and HTTP server."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import uvicorn
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
|
|
10
|
+
from .livekit_client import LiveKitClient
|
|
11
|
+
from .server import create_app
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("elevenagents-livekit-plugin")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ElevenAgentsBridge:
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
livekit_url: str | None = None,
|
|
21
|
+
api_key: str | None = None,
|
|
22
|
+
api_secret: str | None = None,
|
|
23
|
+
room_name: str = "elevenagents-bridge",
|
|
24
|
+
identity: str = "elevenagents-bridge",
|
|
25
|
+
port: int = 8013,
|
|
26
|
+
host: str = "0.0.0.0",
|
|
27
|
+
buffer_words: str = "... ",
|
|
28
|
+
):
|
|
29
|
+
load_dotenv()
|
|
30
|
+
|
|
31
|
+
self.livekit_url = livekit_url or os.getenv("LIVEKIT_URL", "")
|
|
32
|
+
self.api_key = api_key or os.getenv("LIVEKIT_API_KEY", "")
|
|
33
|
+
self.api_secret = api_secret or os.getenv("LIVEKIT_API_SECRET", "")
|
|
34
|
+
self.room_name = room_name
|
|
35
|
+
self.identity = identity
|
|
36
|
+
self.port = port
|
|
37
|
+
self.host = host
|
|
38
|
+
|
|
39
|
+
if not all([self.livekit_url, self.api_key, self.api_secret]):
|
|
40
|
+
raise ValueError(
|
|
41
|
+
"LIVEKIT_URL, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must be "
|
|
42
|
+
"set via arguments or environment variables."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
self.lk_client = LiveKitClient(
|
|
46
|
+
url=self.livekit_url,
|
|
47
|
+
api_key=self.api_key,
|
|
48
|
+
api_secret=self.api_secret,
|
|
49
|
+
room_name=self.room_name,
|
|
50
|
+
identity=self.identity,
|
|
51
|
+
)
|
|
52
|
+
self.app = create_app(self.lk_client, buffer_words=buffer_words)
|
|
53
|
+
|
|
54
|
+
def run(self) -> None:
|
|
55
|
+
"""Start the bridge (blocking)."""
|
|
56
|
+
asyncio.run(self._run())
|
|
57
|
+
|
|
58
|
+
async def _run(self) -> None:
|
|
59
|
+
# Connect to LiveKit room
|
|
60
|
+
await self.lk_client.connect()
|
|
61
|
+
logger.info(
|
|
62
|
+
"Bridge connected to LiveKit at %s, room '%s'",
|
|
63
|
+
self.livekit_url,
|
|
64
|
+
self.room_name,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Start HTTP server
|
|
68
|
+
config = uvicorn.Config(
|
|
69
|
+
self.app, host=self.host, port=self.port, log_level="info"
|
|
70
|
+
)
|
|
71
|
+
server = uvicorn.Server(config)
|
|
72
|
+
|
|
73
|
+
logger.info("Starting server on %s:%d", self.host, self.port)
|
|
74
|
+
logger.info(
|
|
75
|
+
"ElevenLabs custom LLM endpoint: http://%s:%d/v1",
|
|
76
|
+
self.host,
|
|
77
|
+
self.port,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
await server.serve()
|
|
82
|
+
finally:
|
|
83
|
+
await self.lk_client.disconnect()
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Connects to a LiveKit room and relays text to/from the agent."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import datetime
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import AsyncGenerator, Optional
|
|
9
|
+
|
|
10
|
+
from livekit.api import AccessToken, LiveKitAPI, VideoGrants
|
|
11
|
+
from livekit.protocol import agent_dispatch
|
|
12
|
+
from livekit.rtc import Room, TextStreamReader
|
|
13
|
+
|
|
14
|
+
from .tools import TOOL_SIGNAL_PREFIX
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("elevenagents-livekit-plugin.client")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class StreamEvent:
|
|
21
|
+
"""An event in the response stream — either a text chunk or a tool call."""
|
|
22
|
+
|
|
23
|
+
type: str # "text" or "tool_call"
|
|
24
|
+
content: str = ""
|
|
25
|
+
tool_name: str = ""
|
|
26
|
+
tool_args: dict = None
|
|
27
|
+
|
|
28
|
+
def __post_init__(self):
|
|
29
|
+
if self.tool_args is None:
|
|
30
|
+
self.tool_args = {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LiveKitClient:
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
url: str,
|
|
37
|
+
api_key: str,
|
|
38
|
+
api_secret: str,
|
|
39
|
+
room_name: str = "elevenagents-bridge",
|
|
40
|
+
identity: str = "elevenagents-bridge",
|
|
41
|
+
):
|
|
42
|
+
self.url = url
|
|
43
|
+
self.api_key = api_key
|
|
44
|
+
self.api_secret = api_secret
|
|
45
|
+
self.room_name = room_name
|
|
46
|
+
self.identity = identity
|
|
47
|
+
self.room: Optional[Room] = None
|
|
48
|
+
self._response_queue: asyncio.Queue[Optional[StreamEvent]] = asyncio.Queue()
|
|
49
|
+
self._connected = False
|
|
50
|
+
|
|
51
|
+
def _generate_token(self) -> str:
|
|
52
|
+
token = AccessToken(
|
|
53
|
+
api_key=self.api_key,
|
|
54
|
+
api_secret=self.api_secret,
|
|
55
|
+
)
|
|
56
|
+
token.with_identity(self.identity)
|
|
57
|
+
token.with_grants(
|
|
58
|
+
VideoGrants(
|
|
59
|
+
room_join=True,
|
|
60
|
+
room=self.room_name,
|
|
61
|
+
can_publish=True,
|
|
62
|
+
can_subscribe=True,
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
token.with_ttl(datetime.timedelta(hours=24))
|
|
66
|
+
return token.to_jwt()
|
|
67
|
+
|
|
68
|
+
async def connect(self) -> None:
|
|
69
|
+
"""Connect to the LiveKit room and register text stream handlers."""
|
|
70
|
+
self.room = Room()
|
|
71
|
+
|
|
72
|
+
def on_stream(reader: TextStreamReader, participant_identity: str) -> None:
|
|
73
|
+
# Skip our own messages (echo from send_text)
|
|
74
|
+
if participant_identity == self.identity:
|
|
75
|
+
return
|
|
76
|
+
logger.debug("Stream from %s", participant_identity)
|
|
77
|
+
asyncio.ensure_future(self._handle_stream(reader))
|
|
78
|
+
|
|
79
|
+
self.room.register_text_stream_handler("lk.chat", on_stream)
|
|
80
|
+
self.room.register_text_stream_handler("lk.transcription", on_stream)
|
|
81
|
+
|
|
82
|
+
token = self._generate_token()
|
|
83
|
+
await self.room.connect(self.url, token)
|
|
84
|
+
self._connected = True
|
|
85
|
+
logger.info(
|
|
86
|
+
"Connected to room '%s' as '%s'", self.room_name, self.identity
|
|
87
|
+
)
|
|
88
|
+
await self._dispatch_agent()
|
|
89
|
+
|
|
90
|
+
async def _dispatch_agent(self) -> None:
|
|
91
|
+
"""Dispatch a LiveKit agent to join this room."""
|
|
92
|
+
http_url = self.url.replace("ws://", "http://").replace("wss://", "https://")
|
|
93
|
+
api = LiveKitAPI(http_url, self.api_key, self.api_secret)
|
|
94
|
+
try:
|
|
95
|
+
await api.agent_dispatch.create_dispatch(
|
|
96
|
+
agent_dispatch.CreateAgentDispatchRequest(room=self.room_name)
|
|
97
|
+
)
|
|
98
|
+
logger.info("Dispatched agent to room '%s'", self.room_name)
|
|
99
|
+
except Exception as e:
|
|
100
|
+
logger.warning("Failed to dispatch agent: %s", e)
|
|
101
|
+
finally:
|
|
102
|
+
await api.aclose()
|
|
103
|
+
|
|
104
|
+
async def _handle_stream(self, reader: TextStreamReader) -> None:
|
|
105
|
+
"""Handle a text stream — detect tool signals, otherwise stream text."""
|
|
106
|
+
first = True
|
|
107
|
+
is_tool = False
|
|
108
|
+
tool_chunks: list[str] = []
|
|
109
|
+
|
|
110
|
+
async for chunk in reader:
|
|
111
|
+
if first:
|
|
112
|
+
first = False
|
|
113
|
+
if chunk.startswith(TOOL_SIGNAL_PREFIX):
|
|
114
|
+
is_tool = True
|
|
115
|
+
tool_chunks.append(chunk)
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
if is_tool:
|
|
119
|
+
tool_chunks.append(chunk)
|
|
120
|
+
else:
|
|
121
|
+
await self._response_queue.put(
|
|
122
|
+
StreamEvent(type="text", content=chunk)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if is_tool:
|
|
126
|
+
full = "".join(tool_chunks)
|
|
127
|
+
json_str = full[len(TOOL_SIGNAL_PREFIX):]
|
|
128
|
+
try:
|
|
129
|
+
data = json.loads(json_str)
|
|
130
|
+
tool_name = data.pop("tool")
|
|
131
|
+
await self._response_queue.put(
|
|
132
|
+
StreamEvent(type="tool_call", tool_name=tool_name, tool_args=data)
|
|
133
|
+
)
|
|
134
|
+
logger.info("Tool call: %s", tool_name)
|
|
135
|
+
except (json.JSONDecodeError, KeyError) as e:
|
|
136
|
+
logger.warning("Bad tool signal: %s", e)
|
|
137
|
+
else:
|
|
138
|
+
# Text stream done
|
|
139
|
+
await self._response_queue.put(None)
|
|
140
|
+
|
|
141
|
+
async def _ensure_agent_in_room(self) -> None:
|
|
142
|
+
"""Re-dispatch agent if none present."""
|
|
143
|
+
for p in self.room.remote_participants.values():
|
|
144
|
+
if p.kind == 4:
|
|
145
|
+
return
|
|
146
|
+
logger.info("No agent in room, re-dispatching...")
|
|
147
|
+
await self._dispatch_agent()
|
|
148
|
+
for _ in range(20):
|
|
149
|
+
await asyncio.sleep(0.5)
|
|
150
|
+
for p in self.room.remote_participants.values():
|
|
151
|
+
if p.kind == 4:
|
|
152
|
+
logger.info("Agent joined: %s", p.identity)
|
|
153
|
+
return
|
|
154
|
+
logger.warning("Agent did not join after re-dispatch")
|
|
155
|
+
|
|
156
|
+
async def send_and_stream(
|
|
157
|
+
self, text: str, timeout: float = 30.0, tool_wait: float = 0.5
|
|
158
|
+
) -> AsyncGenerator[StreamEvent, None]:
|
|
159
|
+
"""Send text to the agent and yield streamed response events."""
|
|
160
|
+
if not self._connected or self.room is None:
|
|
161
|
+
raise RuntimeError("Not connected to LiveKit room")
|
|
162
|
+
|
|
163
|
+
await self._ensure_agent_in_room()
|
|
164
|
+
|
|
165
|
+
# Drain stale events
|
|
166
|
+
while not self._response_queue.empty():
|
|
167
|
+
self._response_queue.get_nowait()
|
|
168
|
+
|
|
169
|
+
await self.room.local_participant.send_text(text, topic="lk.chat")
|
|
170
|
+
logger.info("Sent: %s", text[:100])
|
|
171
|
+
|
|
172
|
+
text_done = False
|
|
173
|
+
while True:
|
|
174
|
+
try:
|
|
175
|
+
wait = tool_wait if text_done else timeout
|
|
176
|
+
event = await asyncio.wait_for(
|
|
177
|
+
self._response_queue.get(), timeout=wait
|
|
178
|
+
)
|
|
179
|
+
except asyncio.TimeoutError:
|
|
180
|
+
break
|
|
181
|
+
if event is None:
|
|
182
|
+
text_done = True
|
|
183
|
+
continue
|
|
184
|
+
yield event
|
|
185
|
+
|
|
186
|
+
async def disconnect(self) -> None:
|
|
187
|
+
if self.room:
|
|
188
|
+
await self.room.disconnect()
|
|
189
|
+
self._connected = False
|
|
190
|
+
logger.info("Disconnected from room")
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""FastAPI server exposing an OpenAI-compatible /v1/chat/completions endpoint."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
import fastapi
|
|
7
|
+
from fastapi.responses import StreamingResponse
|
|
8
|
+
|
|
9
|
+
from .adapter import format_chunk, format_done_chunk, format_first_chunk, format_tool_call
|
|
10
|
+
from .livekit_client import LiveKitClient
|
|
11
|
+
|
|
12
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
13
|
+
logger = logging.getLogger("elevenagents-livekit-plugin.server")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def extract_text(content) -> str:
|
|
17
|
+
"""Extract text from content that may be a string or a list of content parts."""
|
|
18
|
+
if isinstance(content, str):
|
|
19
|
+
return content
|
|
20
|
+
if isinstance(content, list):
|
|
21
|
+
parts = []
|
|
22
|
+
for part in content:
|
|
23
|
+
if isinstance(part, dict) and part.get("type") == "text":
|
|
24
|
+
parts.append(part.get("text", ""))
|
|
25
|
+
elif isinstance(part, str):
|
|
26
|
+
parts.append(part)
|
|
27
|
+
return " ".join(parts)
|
|
28
|
+
return str(content) if content else ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
DEFAULT_BUFFER_WORDS = "... "
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def create_app(lk_client: LiveKitClient, buffer_words: str = DEFAULT_BUFFER_WORDS) -> fastapi.FastAPI:
|
|
35
|
+
app = fastapi.FastAPI(title="ElevenAgents LiveKit Plugin")
|
|
36
|
+
|
|
37
|
+
@app.post("/v1/chat/completions")
|
|
38
|
+
async def chat_completions(request: fastapi.Request) -> StreamingResponse:
|
|
39
|
+
body = await request.json()
|
|
40
|
+
logger.debug("Incoming request body: %s", json.dumps(body, indent=2))
|
|
41
|
+
|
|
42
|
+
messages = body.get("messages", [])
|
|
43
|
+
if not messages:
|
|
44
|
+
return fastapi.responses.JSONResponse(
|
|
45
|
+
{"error": "No messages provided"}, status_code=400
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Extract the latest user message (content can be string or list)
|
|
49
|
+
user_message = None
|
|
50
|
+
for msg in reversed(messages):
|
|
51
|
+
if msg.get("role") == "user":
|
|
52
|
+
user_message = extract_text(msg.get("content", ""))
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
if not user_message:
|
|
56
|
+
return fastapi.responses.JSONResponse(
|
|
57
|
+
{"error": "No user message found"}, status_code=400
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
logger.debug("Extracted user message: %s", user_message)
|
|
61
|
+
model = body.get("model", "livekit-agent")
|
|
62
|
+
|
|
63
|
+
async def event_stream():
|
|
64
|
+
yield format_first_chunk(model)
|
|
65
|
+
# Send buffer words immediately so ElevenLabs doesn't time out
|
|
66
|
+
if buffer_words:
|
|
67
|
+
yield format_chunk(buffer_words, model)
|
|
68
|
+
try:
|
|
69
|
+
async for event in lk_client.send_and_stream(user_message):
|
|
70
|
+
if event.type == "text":
|
|
71
|
+
yield format_chunk(event.content, model)
|
|
72
|
+
elif event.type == "tool_call":
|
|
73
|
+
yield format_tool_call(
|
|
74
|
+
event.tool_name, event.tool_args, model
|
|
75
|
+
)
|
|
76
|
+
logger.info(
|
|
77
|
+
"Forwarding tool call to ElevenLabs: %s",
|
|
78
|
+
event.tool_name,
|
|
79
|
+
)
|
|
80
|
+
yield format_done_chunk(model)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
logger.error("Error streaming response: %s", str(e))
|
|
83
|
+
yield format_chunk(f"Error: {str(e)}", model)
|
|
84
|
+
yield format_done_chunk(model)
|
|
85
|
+
|
|
86
|
+
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
|
87
|
+
|
|
88
|
+
@app.get("/health")
|
|
89
|
+
async def health():
|
|
90
|
+
return {"status": "ok", "connected": lk_client._connected}
|
|
91
|
+
|
|
92
|
+
return app
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pre-built ElevenLabs tools for LiveKit agents.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from elevenagents_livekit_plugin import elevenagents_tools
|
|
6
|
+
|
|
7
|
+
class MyAgent(Agent):
|
|
8
|
+
def __init__(self):
|
|
9
|
+
super().__init__(
|
|
10
|
+
instructions="...",
|
|
11
|
+
tools=[*elevenagents_tools()],
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
When the agent's LLM calls these tools, they signal back to the bridge
|
|
15
|
+
via the lk.chat TextStream topic. The bridge picks up the signal and
|
|
16
|
+
includes the tool call in the SSE response back to ElevenLabs Voice Engine.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
|
|
22
|
+
from livekit.agents import RunContext, function_tool
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("elevenagents-livekit-plugin.tools")
|
|
25
|
+
|
|
26
|
+
TOOL_SIGNAL_PREFIX = "__ELEVENAGENTS_TOOL__:"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def _send_tool_signal(ctx: RunContext, tool_name: str, args: dict) -> None:
|
|
30
|
+
"""Send a tool call signal to the bridge via TextStream on lk.chat topic."""
|
|
31
|
+
room = ctx.session.room_io.room
|
|
32
|
+
payload = TOOL_SIGNAL_PREFIX + json.dumps({"tool": tool_name, **args})
|
|
33
|
+
await room.local_participant.send_text(payload, topic="lk.chat")
|
|
34
|
+
logger.info("Sent tool signal: %s", tool_name)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@function_tool(name="end_call")
|
|
38
|
+
async def _end_call(
|
|
39
|
+
ctx: RunContext, reason: str, system__message_to_speak: str = ""
|
|
40
|
+
) -> str:
|
|
41
|
+
"""End the current voice call. Call this when:
|
|
42
|
+
1. The user says goodbye or wants to end the conversation.
|
|
43
|
+
2. The main task has been completed and the user is satisfied.
|
|
44
|
+
3. The conversation has reached a natural conclusion.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
reason: Why the call is ending.
|
|
48
|
+
system__message_to_speak: A farewell message to speak before ending.
|
|
49
|
+
"""
|
|
50
|
+
await _send_tool_signal(
|
|
51
|
+
ctx,
|
|
52
|
+
"end_call",
|
|
53
|
+
{"reason": reason, "system__message_to_speak": system__message_to_speak},
|
|
54
|
+
)
|
|
55
|
+
return system__message_to_speak if system__message_to_speak else "Ending the call."
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@function_tool(name="skip_turn")
|
|
59
|
+
async def _skip_turn(ctx: RunContext, reason: str = "") -> str:
|
|
60
|
+
"""Skip the agent's turn and wait silently for the user to speak.
|
|
61
|
+
Call this when the user says they need a moment to think, are
|
|
62
|
+
looking something up, or otherwise need a pause.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
reason: Why the pause is needed.
|
|
66
|
+
"""
|
|
67
|
+
await _send_tool_signal(ctx, "skip_turn", {"reason": reason})
|
|
68
|
+
return ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@function_tool(name="language_detection")
|
|
72
|
+
async def _language_detection(ctx: RunContext, reason: str, language: str) -> str:
|
|
73
|
+
"""Switch the conversation language. ONLY call this tool by itself with
|
|
74
|
+
NO text response. ONLY call ONCE when the user FIRST speaks in a different
|
|
75
|
+
language or explicitly requests a language change. Do NOT call again if you
|
|
76
|
+
are already responding in the target language.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
reason: Why the language switch is needed.
|
|
80
|
+
language: The language code to switch to (e.g. 'es', 'fr', 'de', 'ja').
|
|
81
|
+
"""
|
|
82
|
+
await _send_tool_signal(
|
|
83
|
+
ctx, "language_detection", {"reason": reason, "language": language}
|
|
84
|
+
)
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def elevenagents_tools() -> list:
|
|
89
|
+
"""Return all ElevenLabs tools for use in a LiveKit agent.
|
|
90
|
+
|
|
91
|
+
Example:
|
|
92
|
+
class MyAgent(Agent):
|
|
93
|
+
def __init__(self):
|
|
94
|
+
super().__init__(
|
|
95
|
+
instructions="You are a helpful assistant.",
|
|
96
|
+
tools=[*elevenagents_tools()],
|
|
97
|
+
)
|
|
98
|
+
"""
|
|
99
|
+
return [_end_call, _skip_turn, _language_detection]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: elevenagents-livekit-plugin
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bridge between LiveKit Agents and ElevenAgents Voice Orchestration
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: livekit>=1.0.0
|
|
7
|
+
Requires-Dist: livekit-api>=1.0.0
|
|
8
|
+
Requires-Dist: fastapi>=0.100.0
|
|
9
|
+
Requires-Dist: uvicorn>=0.20.0
|
|
10
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
src/elevenagents_livekit_plugin/__init__.py
|
|
3
|
+
src/elevenagents_livekit_plugin/adapter.py
|
|
4
|
+
src/elevenagents_livekit_plugin/bridge.py
|
|
5
|
+
src/elevenagents_livekit_plugin/livekit_client.py
|
|
6
|
+
src/elevenagents_livekit_plugin/server.py
|
|
7
|
+
src/elevenagents_livekit_plugin/tools.py
|
|
8
|
+
src/elevenagents_livekit_plugin.egg-info/PKG-INFO
|
|
9
|
+
src/elevenagents_livekit_plugin.egg-info/SOURCES.txt
|
|
10
|
+
src/elevenagents_livekit_plugin.egg-info/dependency_links.txt
|
|
11
|
+
src/elevenagents_livekit_plugin.egg-info/requires.txt
|
|
12
|
+
src/elevenagents_livekit_plugin.egg-info/top_level.txt
|
elevenagents_livekit_plugin-0.1.0/src/elevenagents_livekit_plugin.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
elevenagents_livekit_plugin
|