sopops 0.1.0__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.
- sopops/__init__.py +11 -0
- sopops/client.py +300 -0
- sopops/livekit.py +284 -0
- sopops/mcp.py +364 -0
- sopops/platforms.py +113 -0
- sopops/py.typed +0 -0
- sopops/stream.py +138 -0
- sopops-0.1.0.dist-info/METADATA +130 -0
- sopops-0.1.0.dist-info/RECORD +11 -0
- sopops-0.1.0.dist-info/WHEEL +4 -0
- sopops-0.1.0.dist-info/entry_points.txt +2 -0
sopops/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""sopops client: fetch an agent's instructions at call start, and send each finished session back.
|
|
2
|
+
|
|
3
|
+
Framework adapters live in their own modules and import their framework lazily: `sopops.livekit`.
|
|
4
|
+
`SessionStream` optionally sends a session's events while the call runs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .client import Instructions, Sopops, SopopsError, SopopsUnavailable, fetch, release_pin
|
|
8
|
+
from .stream import SessionStream
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
__all__ = ["Instructions", "SessionStream", "Sopops", "SopopsError", "SopopsUnavailable", "fetch", "release_pin"]
|
sopops/client.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""The framework-agnostic client: instructions at call start (with a disk cache, so a sopops outage never
|
|
2
|
+
blocks a call) and canonical sessions at call end."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import tempfile
|
|
13
|
+
from dataclasses import asdict, dataclass, field, replace
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Literal, Mapping, Sequence
|
|
16
|
+
from urllib.parse import quote
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("sopops")
|
|
21
|
+
|
|
22
|
+
Source = Literal["live", "cache", "fallback"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SopopsError(Exception):
|
|
26
|
+
"""sopops answered with an error, or couldn't be reached."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SopopsUnavailable(SopopsError):
|
|
30
|
+
"""No instructions: sopops failed, nothing was cached for this agent, and no fallback was given."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Instructions:
|
|
35
|
+
"""An agent's compiled instructions, and what they were built from."""
|
|
36
|
+
|
|
37
|
+
text: str
|
|
38
|
+
release: int | None
|
|
39
|
+
agent: str
|
|
40
|
+
blocks: list[dict[str, Any]] = field(default_factory=list)
|
|
41
|
+
"""Each file the prompt was built from, in prompt order: {kind, id, path, version}."""
|
|
42
|
+
hash: str | None = None
|
|
43
|
+
platform_ref: str | None = None
|
|
44
|
+
"""`livekit:tonys-pizza`, `elevenlabs:<agent id>`, `vapi:<assistant id>`."""
|
|
45
|
+
tools: list[str] = field(default_factory=list)
|
|
46
|
+
tool_sops: list[str] = field(default_factory=list)
|
|
47
|
+
source: Source = "live"
|
|
48
|
+
"""`live` from sopops, `cache` the last good copy on disk (sopops failed), `fallback` the text you passed."""
|
|
49
|
+
unfilled: tuple[str, ...] = ()
|
|
50
|
+
"""Placeholders still in `text`: ones you passed no value for. Empty unless you passed `variables`."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
PLACEHOLDER = re.compile(r"\{\{\s*([A-Za-z0-9_]+)\s*\}\}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def fill(text: str, variables: Mapping[str, Any]) -> str:
|
|
57
|
+
"""`{{placeholders}}` replaced with your values. One with no value is left as it is, the way sopops leaves a
|
|
58
|
+
placeholder it has no value for, so a missing value never mangles the instructions."""
|
|
59
|
+
return PLACEHOLDER.sub(lambda m: str(variables[m.group(1)]) if m.group(1) in variables else m.group(0), text)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def unfilled(text: str) -> tuple[str, ...]:
|
|
63
|
+
"""The placeholders still in the text, in order, without repeats."""
|
|
64
|
+
return tuple(dict.fromkeys(PLACEHOLDER.findall(text)))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _filled(got: Instructions, variables: Mapping[str, Any] | None) -> Instructions:
|
|
68
|
+
"""Fill what this call knows, and say what didn't line up. A mismatch is never fatal: the call goes on with the
|
|
69
|
+
placeholder as it is, which is what sopops serves anyway."""
|
|
70
|
+
if not variables:
|
|
71
|
+
return got
|
|
72
|
+
wanted = set(unfilled(got.text))
|
|
73
|
+
text = fill(got.text, variables)
|
|
74
|
+
left = unfilled(text)
|
|
75
|
+
if left:
|
|
76
|
+
log.warning("sopops: no value for %s in %s's instructions; it stays as a placeholder", _names(left), got.agent)
|
|
77
|
+
spare = [k for k in variables if k not in wanted]
|
|
78
|
+
if spare:
|
|
79
|
+
log.warning("sopops: %s has no placeholder in %s's instructions; check the spelling", _names(spare), got.agent)
|
|
80
|
+
return replace(got, text=text, unfilled=left)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _names(names: Sequence[str]) -> str:
|
|
84
|
+
return ", ".join(f"{{{{{n}}}}}" for n in names)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def release_pin(metadata: str | Mapping[str, Any] | None) -> int | None:
|
|
88
|
+
"""A release pinned for this call (A/B tests) in job or room metadata: `{"sopops_release": 3}` or
|
|
89
|
+
`{"sopops": {"release": 3}}`, as a JSON string or a dict. None if there's none."""
|
|
90
|
+
if not metadata:
|
|
91
|
+
return None
|
|
92
|
+
if isinstance(metadata, str):
|
|
93
|
+
try:
|
|
94
|
+
metadata = json.loads(metadata)
|
|
95
|
+
except ValueError:
|
|
96
|
+
return None
|
|
97
|
+
if not isinstance(metadata, Mapping):
|
|
98
|
+
return None
|
|
99
|
+
nested = metadata.get("sopops")
|
|
100
|
+
value = metadata.get("sopops_release", nested.get("release") if isinstance(nested, Mapping) else None)
|
|
101
|
+
try:
|
|
102
|
+
return int(value) if value is not None and not isinstance(value, bool) else None
|
|
103
|
+
except (TypeError, ValueError):
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def fetch(
|
|
108
|
+
agent: str,
|
|
109
|
+
release: int | None = None,
|
|
110
|
+
*,
|
|
111
|
+
fallback: str | None = None,
|
|
112
|
+
variables: Mapping[str, Any] | None = None,
|
|
113
|
+
url: str | None = None,
|
|
114
|
+
token: str | None = None,
|
|
115
|
+
timeout: float = 2.0,
|
|
116
|
+
cache_dir: str | os.PathLike[str] | None = None,
|
|
117
|
+
) -> Instructions:
|
|
118
|
+
"""The agent's instructions, blocking: one call for code that isn't async.
|
|
119
|
+
|
|
120
|
+
`agent` is the sopc agent id or its platform ref (`livekit:tonys-pizza`). `release` pins one; without it you get
|
|
121
|
+
the live release. `url` and `token` default to `SOPOPS_URL` and `SOPOPS_TOKEN`, as `Sopops` reads them.
|
|
122
|
+
|
|
123
|
+
Falls back the same way `Sopops.fetch_instructions` does: the last good copy on disk, then `fallback`, else
|
|
124
|
+
`SopopsUnavailable`. Inside async code, await that method on a `Sopops` you keep, so the client is reused.
|
|
125
|
+
|
|
126
|
+
ins = sopops.fetch("tonys-pizza") # the live release
|
|
127
|
+
ins = sopops.fetch("tonys-pizza", 14) # pinned, e.g. one arm of an A/B test
|
|
128
|
+
ins = sopops.fetch("tonys-pizza", variables={"now": "7:15pm", "caller": "Sam"}) # filled here, not in sopops
|
|
129
|
+
prompt = ins.text
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
asyncio.get_running_loop()
|
|
133
|
+
except RuntimeError:
|
|
134
|
+
pass
|
|
135
|
+
else:
|
|
136
|
+
raise RuntimeError("sopops.fetch blocks: in async code use `await Sopops(...).fetch_instructions(agent, release)`")
|
|
137
|
+
client = Sopops(url, token, timeout=timeout, cache_dir=cache_dir)
|
|
138
|
+
return asyncio.run(client.fetch_instructions(agent, release, fallback=fallback, variables=variables))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class Sopops:
|
|
142
|
+
"""A sopops workspace.
|
|
143
|
+
|
|
144
|
+
`url` is the workspace's API URL (`http://sopops.internal:8484/v1/workspaces/prod`), default `SOPOPS_URL`.
|
|
145
|
+
`token` is a workspace token (Settings → Tokens; scope `runtime` to fetch, `ingest` to send sessions), default
|
|
146
|
+
`SOPOPS_TOKEN` (or `SOPOPS_INGEST_TOKEN`).
|
|
147
|
+
`timeout` bounds the fetch at call start; on any failure the last good copy on disk is used
|
|
148
|
+
(`cache_dir`, default `SOPOPS_CACHE_DIR` or `~/.cache/sopops`).
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(
|
|
152
|
+
self,
|
|
153
|
+
url: str | None = None,
|
|
154
|
+
token: str | None = None,
|
|
155
|
+
*,
|
|
156
|
+
timeout: float = 2.0,
|
|
157
|
+
cache_dir: str | os.PathLike[str] | None = None,
|
|
158
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
159
|
+
) -> None:
|
|
160
|
+
url = url or os.environ.get("SOPOPS_URL")
|
|
161
|
+
if not url:
|
|
162
|
+
raise ValueError("pass url= or set SOPOPS_URL to the workspace's API URL, e.g. http://sopops:8484/v1/workspaces/prod")
|
|
163
|
+
self.url = url.rstrip("/")
|
|
164
|
+
self.token = token if token is not None else (os.environ.get("SOPOPS_TOKEN") or os.environ.get("SOPOPS_INGEST_TOKEN"))
|
|
165
|
+
self.timeout = timeout
|
|
166
|
+
self.cache_dir = Path(cache_dir or os.environ.get("SOPOPS_CACHE_DIR") or Path.home() / ".cache" / "sopops")
|
|
167
|
+
self._transport = transport
|
|
168
|
+
|
|
169
|
+
def _client(self, timeout: float) -> httpx.AsyncClient:
|
|
170
|
+
headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
|
|
171
|
+
return httpx.AsyncClient(base_url=self.url, headers=headers, timeout=timeout, transport=self._transport)
|
|
172
|
+
|
|
173
|
+
# --- call start ------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
async def fetch_instructions(self, agent: str, release: int | None = None, *, fallback: str | None = None,
|
|
176
|
+
variables: Mapping[str, Any] | None = None) -> Instructions:
|
|
177
|
+
"""The agent's instructions in the live release, or in `release` if pinned. `agent` is the sopc agent id
|
|
178
|
+
or its platform ref (`livekit:tonys-pizza`).
|
|
179
|
+
|
|
180
|
+
`variables` fills `{{placeholders}}` this call knows and sopops can't: the time, the caller, anything your
|
|
181
|
+
code works out per call. It happens here, in your process, so those values never reach sopops. A placeholder
|
|
182
|
+
you pass no value for is left as it is and listed in `ins.unfilled`. The cache keeps the unfilled text, so a
|
|
183
|
+
cached copy still fills with this call's values.
|
|
184
|
+
|
|
185
|
+
Never waits longer than `timeout`. If sopops fails, returns the last good copy from disk (`source="cache"`),
|
|
186
|
+
else `fallback` (`source="fallback"`), else raises SopopsUnavailable."""
|
|
187
|
+
params = {"release": str(release)} if release is not None else None
|
|
188
|
+
try:
|
|
189
|
+
async with self._client(self.timeout) as http:
|
|
190
|
+
res = await asyncio.wait_for(http.get(f"/agents/{agent}", params=params), self.timeout + 0.5)
|
|
191
|
+
if res.status_code >= 400:
|
|
192
|
+
raise SopopsError(f"sopops answered {res.status_code} for agent '{agent}': {_detail(res)}")
|
|
193
|
+
body = res.json()
|
|
194
|
+
got = Instructions(
|
|
195
|
+
text=body["prompt"], release=body["release"], agent=body["agent"], blocks=body.get("components", []),
|
|
196
|
+
hash=body.get("hash"), platform_ref=body.get("platform_ref"), tools=body.get("tools", []), tool_sops=body.get("tool_sops", []),
|
|
197
|
+
)
|
|
198
|
+
except (httpx.HTTPError, asyncio.TimeoutError, SopopsError, ValueError, KeyError) as e:
|
|
199
|
+
reason = str(e) or type(e).__name__
|
|
200
|
+
cached = self._read_cache(agent, release)
|
|
201
|
+
if cached is not None:
|
|
202
|
+
log.warning("sopops: using the cached instructions for %s (release %s): %s", agent, cached.release, reason)
|
|
203
|
+
return _filled(cached, variables)
|
|
204
|
+
if fallback is not None:
|
|
205
|
+
log.error("sopops: using the fallback instructions for %s, nothing cached: %s", agent, reason)
|
|
206
|
+
return _filled(Instructions(text=fallback, release=None, agent=agent, source="fallback"), variables)
|
|
207
|
+
raise SopopsUnavailable(f"no instructions for '{agent}': {reason}") from e
|
|
208
|
+
self._write_cache(agent, release, got) # unfilled, so the next call fills it with its own values
|
|
209
|
+
return _filled(got, variables)
|
|
210
|
+
|
|
211
|
+
def _cache_file(self, agent: str, release: int | None) -> Path:
|
|
212
|
+
key = hashlib.sha256(f"{self.url}\n{agent}\n{release if release is not None else 'live'}".encode()).hexdigest()[:24]
|
|
213
|
+
return self.cache_dir / f"{key}.json"
|
|
214
|
+
|
|
215
|
+
def _read_cache(self, agent: str, release: int | None) -> Instructions | None:
|
|
216
|
+
try:
|
|
217
|
+
data = json.loads(self._cache_file(agent, release).read_text())
|
|
218
|
+
return Instructions(**{**data, "source": "cache"})
|
|
219
|
+
except (OSError, ValueError, TypeError):
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
def _write_cache(self, agent: str, release: int | None, got: Instructions) -> None:
|
|
223
|
+
"""Atomically, so a crash mid-write never leaves a broken copy. Failures only log: the call goes on."""
|
|
224
|
+
path = self._cache_file(agent, release)
|
|
225
|
+
try:
|
|
226
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".tmp-")
|
|
228
|
+
with os.fdopen(fd, "w") as f:
|
|
229
|
+
json.dump(asdict(got), f)
|
|
230
|
+
os.replace(tmp, path)
|
|
231
|
+
except OSError as e:
|
|
232
|
+
log.warning("sopops: couldn't cache instructions for %s: %s", agent, e)
|
|
233
|
+
|
|
234
|
+
async def record_served(self, platform: str, call_id: str, agent: str, release: int, *, timeout: float = 3.0) -> None:
|
|
235
|
+
"""Tell sopops which release a call ran on once the platform's id for it is known (an ElevenLabs
|
|
236
|
+
conversation id, a Vapi call id), so the session its post-call webhook sends links to that release."""
|
|
237
|
+
async with self._client(timeout) as http:
|
|
238
|
+
res = await http.post("/runtime/served", json={"platform": platform, "call_id": call_id, "agent": agent, "release": release})
|
|
239
|
+
if res.status_code >= 400:
|
|
240
|
+
raise SopopsError(f"sopops answered {res.status_code}: {_detail(res)}")
|
|
241
|
+
|
|
242
|
+
# --- call end ----------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
async def post_session(self, session: Mapping[str, Any], *, retries: int = 2, timeout: float = 5.0) -> dict[str, Any]:
|
|
245
|
+
"""Send one finished session in sopops's canonical format (`platform`, `external_id`, `turns`, ...).
|
|
246
|
+
Safe to repeat: sopops upserts on (platform, external_id). Retries connection errors, 429 and 5xx."""
|
|
247
|
+
last: Exception | None = None
|
|
248
|
+
async with self._client(timeout) as http:
|
|
249
|
+
for attempt in range(retries + 1):
|
|
250
|
+
if attempt:
|
|
251
|
+
await asyncio.sleep(0.5 * 2 ** (attempt - 1))
|
|
252
|
+
try:
|
|
253
|
+
res = await http.post("/sessions", json=dict(session))
|
|
254
|
+
except httpx.HTTPError as e:
|
|
255
|
+
last = SopopsError(f"couldn't reach sopops: {type(e).__name__}")
|
|
256
|
+
continue
|
|
257
|
+
if res.status_code == 429 or res.status_code >= 500:
|
|
258
|
+
last = SopopsError(f"sopops answered {res.status_code}: {_detail(res)}")
|
|
259
|
+
continue
|
|
260
|
+
if res.status_code >= 400:
|
|
261
|
+
raise SopopsError(f"sopops refused the session ({res.status_code}): {_detail(res)}")
|
|
262
|
+
out: dict[str, Any] = res.json()
|
|
263
|
+
return out
|
|
264
|
+
assert last is not None
|
|
265
|
+
raise last
|
|
266
|
+
|
|
267
|
+
# --- during the call (optional: see SessionStream) ------------------------------------------------
|
|
268
|
+
|
|
269
|
+
async def open_session(self, session: Mapping[str, Any], *, timeout: float = 3.0) -> dict[str, Any]:
|
|
270
|
+
"""Open a session while the call runs, so its events can be sent as they happen: `platform`,
|
|
271
|
+
`external_id` and optionally `channel`, `agent`, `release`, `version_ref`, `started_at`, `metadata`.
|
|
272
|
+
Returns `{id, created}`. Safe to repeat: keyed by (platform, external_id), like `post_session`."""
|
|
273
|
+
return await self._post("/sessions/open", dict(session), timeout)
|
|
274
|
+
|
|
275
|
+
async def append_events(self, platform: str, external_id: str, events: Sequence[Mapping[str, Any]], *, timeout: float = 3.0) -> dict[str, Any]:
|
|
276
|
+
"""Add events to an open session: `{seq, type: "turn" | "tool_call", turn | tool_call}`, with `seq` counting
|
|
277
|
+
from 0 per session. Order doesn't matter and a resent `seq` is ignored. Returns `{accepted, duplicates}`.
|
|
278
|
+
The full session sent with `post_session` at the end replaces what was streamed."""
|
|
279
|
+
path = f"/sessions/{quote(platform, safe='')}/{quote(external_id, safe='')}/events"
|
|
280
|
+
return await self._post(path, {"events": [dict(e) for e in events]}, timeout)
|
|
281
|
+
|
|
282
|
+
async def _post(self, path: str, body: dict[str, Any], timeout: float) -> dict[str, Any]:
|
|
283
|
+
"""One POST, no retries."""
|
|
284
|
+
try:
|
|
285
|
+
async with self._client(timeout) as http:
|
|
286
|
+
res = await http.post(path, json=body)
|
|
287
|
+
except httpx.HTTPError as e:
|
|
288
|
+
raise SopopsError(f"couldn't reach sopops: {type(e).__name__}") from e
|
|
289
|
+
if res.status_code >= 400:
|
|
290
|
+
raise SopopsError(f"sopops answered {res.status_code}: {_detail(res)}")
|
|
291
|
+
out: dict[str, Any] = res.json()
|
|
292
|
+
return out
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _detail(res: httpx.Response) -> str:
|
|
296
|
+
try:
|
|
297
|
+
detail = res.json().get("detail")
|
|
298
|
+
except (ValueError, AttributeError):
|
|
299
|
+
detail = None
|
|
300
|
+
return str(detail) if detail else res.text[:200]
|
sopops/livekit.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""LiveKit Agents adapter (`pip install "sopops[livekit]"`).
|
|
2
|
+
|
|
3
|
+
from sopops.livekit import attach
|
|
4
|
+
|
|
5
|
+
async def entrypoint(ctx: JobContext):
|
|
6
|
+
await ctx.connect()
|
|
7
|
+
session = AgentSession(...)
|
|
8
|
+
ins = await attach(ctx, session, agent="tonys-pizza")
|
|
9
|
+
await session.start(room=ctx.room, agent=Agent(instructions=ins.text))
|
|
10
|
+
|
|
11
|
+
`attach` fetches the instructions (honoring a release pinned in job or room metadata), keeps the release in
|
|
12
|
+
`session.userdata`, and at shutdown posts the session (`session.history` with timestamps and tool calls) to
|
|
13
|
+
sopops, with what it ran with: the session's LLM, STT and TTS models and voice (`agent_config`), so sopops can
|
|
14
|
+
compare an agent's versions with no extra setup. `test=` names an A/B test and `config=` adds settings. With `stream=True` it also sends each turn and tool call as it happens. Nothing here imports livekit at
|
|
15
|
+
import time; the objects are used through their documented attributes and events.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from typing import TYPE_CHECKING, Any, Callable, Mapping
|
|
23
|
+
|
|
24
|
+
from .client import Instructions, Sopops, release_pin
|
|
25
|
+
from .stream import SessionStream
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING: # only for type checkers: the adapter needs no livekit import at runtime
|
|
28
|
+
from livekit.agents import AgentSession, JobContext
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger("sopops.livekit")
|
|
31
|
+
|
|
32
|
+
RELEASE_KEY = "sopops_release"
|
|
33
|
+
AGENT_KEY = "sopops_agent"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def from_livekit(history: Any) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
37
|
+
"""Map a LiveKit `ChatContext` (`session.history`), or its `to_dict(exclude_timestamp=False)`, to sopops
|
|
38
|
+
`(turns, tool_calls)`. Times are Unix seconds (`at`). A message that repeats the one before it is dropped
|
|
39
|
+
(LiveKit can add a user transcript twice)."""
|
|
40
|
+
data = history.to_dict(exclude_timestamp=False) if hasattr(history, "to_dict") else history
|
|
41
|
+
turns: list[dict[str, Any]] = []
|
|
42
|
+
tools: dict[str, dict[str, Any]] = {}
|
|
43
|
+
for item in data.get("items", []):
|
|
44
|
+
kind, at = item.get("type"), item.get("created_at")
|
|
45
|
+
if kind == "message":
|
|
46
|
+
turn = _message_turn(item.get("role"), item.get("content", []), at, item.get("interrupted", False))
|
|
47
|
+
if not _repeats(turns[-1] if turns else None, turn):
|
|
48
|
+
turns.append(turn)
|
|
49
|
+
elif kind == "function_call":
|
|
50
|
+
name, call_id = item.get("name"), item.get("call_id")
|
|
51
|
+
turns.append(_tool_turn(name, item.get("arguments"), at))
|
|
52
|
+
tools[call_id] = {"name": name, "arguments": item.get("arguments"), "call_id": call_id, "at": at}
|
|
53
|
+
elif kind == "function_call_output" and item.get("call_id") in tools:
|
|
54
|
+
tools[item["call_id"]].update(result=item.get("output"), is_error=bool(item.get("is_error", False)))
|
|
55
|
+
return turns, list(tools.values())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _message_turn(role: Any, content: Any, at: Any, interrupted: Any) -> dict[str, Any]:
|
|
59
|
+
text = " ".join(c for c in content or [] if isinstance(c, str)).strip()
|
|
60
|
+
return {"role": role, "text": text, "at": at, "interrupted": bool(interrupted)}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _tool_turn(name: Any, arguments: Any, at: Any) -> dict[str, Any]:
|
|
64
|
+
return {"role": "tool", "text": f"{name}({arguments if arguments is not None else ''})", "name": name, "at": at}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _repeats(prev: Mapping[str, Any] | None, turn: Mapping[str, Any]) -> bool:
|
|
68
|
+
"""LiveKit can add a user transcript twice."""
|
|
69
|
+
return prev is not None and prev["role"] == turn["role"] and prev["text"] == turn["text"] and prev.get("name") is None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def pinned_release(ctx: JobContext) -> int | None:
|
|
73
|
+
"""A release pinned for this call in the job's dispatch metadata, else the room's metadata."""
|
|
74
|
+
job = getattr(ctx, "job", None)
|
|
75
|
+
room = getattr(ctx, "room", None)
|
|
76
|
+
return release_pin(getattr(job, "metadata", None)) or release_pin(getattr(room, "metadata", None))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def attach(
|
|
80
|
+
ctx: JobContext,
|
|
81
|
+
session: AgentSession[Any],
|
|
82
|
+
agent: str | None = None,
|
|
83
|
+
*,
|
|
84
|
+
client: Sopops | None = None,
|
|
85
|
+
release: int | None = None,
|
|
86
|
+
fallback: str | None = None,
|
|
87
|
+
stream: bool = False,
|
|
88
|
+
test: str | None = None,
|
|
89
|
+
config: Mapping[str, Any] | None = None,
|
|
90
|
+
variables: Mapping[str, Any] | None = None,
|
|
91
|
+
on_guidance: Callable[[Mapping[str, Any]], Any] | None = None,
|
|
92
|
+
) -> Instructions:
|
|
93
|
+
"""Fetch the agent's instructions, note the release in `session.userdata`, and post the session at shutdown.
|
|
94
|
+
|
|
95
|
+
`agent`: the sopc agent id or platform ref; default `livekit:<the job's agent name>`. `release` pins one;
|
|
96
|
+
default: a pin in job or room metadata, else the live release. `fallback`: text to use if sopops is down and
|
|
97
|
+
nothing is cached (see `Sopops.fetch_instructions`). Call it after `ctx.connect()` for room metadata.
|
|
98
|
+
|
|
99
|
+
`stream=True` also sends each turn and tool call while the call runs (see `record_session`).
|
|
100
|
+
`test`: an A/B test this call is part of. `config`: settings to report beyond the models `agent_config` reads
|
|
101
|
+
(`{"version": "v2", "turn_detection": "eou"}`); they win over what's read.
|
|
102
|
+
`variables`: values for `{{placeholders}}` only this call knows, like the time or the caller. They're filled
|
|
103
|
+
here, in your process, so they never reach sopops. A placeholder you pass no value for is left as it is and
|
|
104
|
+
listed in `ins.unfilled`, so you can log one that shouldn't be there.
|
|
105
|
+
`on_guidance`: not yet used: reserved for runtime guidance in a later version."""
|
|
106
|
+
client = client or Sopops()
|
|
107
|
+
agent = agent or f"livekit:{ctx.job.agent_name}"
|
|
108
|
+
pin = release if release is not None else pinned_release(ctx)
|
|
109
|
+
ins = await client.fetch_instructions(agent, pin, fallback=fallback, variables=variables)
|
|
110
|
+
_remember(session, ins)
|
|
111
|
+
record_session(ctx, session, client=client, agent=ins.agent, stream=stream, test=test, config=config, on_guidance=on_guidance)
|
|
112
|
+
return ins
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def record_session(
|
|
116
|
+
ctx: JobContext,
|
|
117
|
+
session: AgentSession[Any],
|
|
118
|
+
*,
|
|
119
|
+
client: Sopops | None = None,
|
|
120
|
+
agent: str | None = None,
|
|
121
|
+
stream: bool = False,
|
|
122
|
+
test: str | None = None,
|
|
123
|
+
config: Mapping[str, Any] | None = None,
|
|
124
|
+
on_guidance: Callable[[Mapping[str, Any]], Any] | None = None,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Post `session.history` to sopops when the job shuts down, keyed by the job id (rooms get reused), with the
|
|
127
|
+
release from `session.userdata`. Errors are logged, never raised into shutdown.
|
|
128
|
+
|
|
129
|
+
`stream=True` also opens the session now and sends each turn and tool call as it happens, in batches from a
|
|
130
|
+
background task (see `SessionStream`); it never blocks or raises into the agent. The full post at shutdown
|
|
131
|
+
still happens and replaces what was streamed. `test` and `config`: see `attach`. `on_guidance`: not yet used: reserved for runtime guidance in
|
|
132
|
+
a later version."""
|
|
133
|
+
client = client or Sopops()
|
|
134
|
+
started = datetime.now(timezone.utc)
|
|
135
|
+
live = _stream(ctx, session, client, agent, started, on_guidance, test, config) if stream else None
|
|
136
|
+
|
|
137
|
+
async def send(reason: str = "") -> None:
|
|
138
|
+
if live is not None:
|
|
139
|
+
await live.aclose()
|
|
140
|
+
try:
|
|
141
|
+
turns, tool_calls = from_livekit(session.history)
|
|
142
|
+
userdata = _userdata(session)
|
|
143
|
+
body = {
|
|
144
|
+
"platform": "livekit",
|
|
145
|
+
"external_id": ctx.job.id,
|
|
146
|
+
"agent": agent or _get(userdata, AGENT_KEY) or ctx.job.agent_name or None,
|
|
147
|
+
"release": _get(userdata, RELEASE_KEY),
|
|
148
|
+
"started_at": started.isoformat(),
|
|
149
|
+
"ended_at": datetime.now(timezone.utc).isoformat(),
|
|
150
|
+
"turns": turns,
|
|
151
|
+
"tool_calls": tool_calls,
|
|
152
|
+
"metadata": {"room": ctx.room.name, "shutdown_reason": reason},
|
|
153
|
+
"test": test,
|
|
154
|
+
"config": {**agent_config(session), **dict(config or {})},
|
|
155
|
+
}
|
|
156
|
+
# stay inside the job's shutdown budget (LiveKit's graceful shutdown is 10s by default)
|
|
157
|
+
await client.post_session(body, retries=1, timeout=3.0)
|
|
158
|
+
except Exception as e: # noqa: BLE001 - a failed post must not break shutdown
|
|
159
|
+
log.error("sopops: couldn't send session %s: %s", getattr(ctx.job, "id", "?"), e)
|
|
160
|
+
|
|
161
|
+
ctx.add_shutdown_callback(send)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _stream(
|
|
165
|
+
ctx: JobContext, session: AgentSession[Any], client: Sopops, agent: str | None, started: datetime, on_guidance: Callable[[Mapping[str, Any]], Any] | None,
|
|
166
|
+
test: str | None = None, config: Mapping[str, Any] | None = None,
|
|
167
|
+
) -> SessionStream:
|
|
168
|
+
"""Open a SessionStream for the job and feed it from the session's `conversation_item_added` and
|
|
169
|
+
`function_tools_executed` events."""
|
|
170
|
+
userdata = _userdata(session)
|
|
171
|
+
live = SessionStream(
|
|
172
|
+
client,
|
|
173
|
+
{
|
|
174
|
+
"platform": "livekit",
|
|
175
|
+
"external_id": ctx.job.id,
|
|
176
|
+
"channel": "voice",
|
|
177
|
+
"agent": agent or _get(userdata, AGENT_KEY) or ctx.job.agent_name or None,
|
|
178
|
+
"release": _get(userdata, RELEASE_KEY),
|
|
179
|
+
"started_at": started.isoformat(),
|
|
180
|
+
"metadata": {"room": ctx.room.name},
|
|
181
|
+
"test": test,
|
|
182
|
+
"config": {**agent_config(session), **dict(config or {})},
|
|
183
|
+
},
|
|
184
|
+
on_guidance=on_guidance,
|
|
185
|
+
)
|
|
186
|
+
last: list[dict[str, Any]] = [] # the last turn sent, to drop repeats like from_livekit does
|
|
187
|
+
|
|
188
|
+
def on_item(event: Any) -> None:
|
|
189
|
+
try:
|
|
190
|
+
item = event.item
|
|
191
|
+
if getattr(item, "type", "message") != "message":
|
|
192
|
+
return
|
|
193
|
+
turn = _message_turn(item.role, item.content, getattr(item, "created_at", None), getattr(item, "interrupted", False))
|
|
194
|
+
if not _repeats(last[0] if last else None, turn):
|
|
195
|
+
last[:] = [turn]
|
|
196
|
+
live.turn(turn)
|
|
197
|
+
except Exception as e: # noqa: BLE001 - never raise into the agent
|
|
198
|
+
log.warning("sopops: couldn't stream a conversation item: %s", e)
|
|
199
|
+
|
|
200
|
+
def on_tools(event: Any) -> None:
|
|
201
|
+
try:
|
|
202
|
+
outputs = list(getattr(event, "function_call_outputs", None) or [])
|
|
203
|
+
for i, call in enumerate(event.function_calls):
|
|
204
|
+
out = outputs[i] if i < len(outputs) else None
|
|
205
|
+
at = getattr(call, "created_at", None)
|
|
206
|
+
turn = _tool_turn(call.name, call.arguments, at)
|
|
207
|
+
last[:] = [turn]
|
|
208
|
+
live.turn(turn)
|
|
209
|
+
tool_call = {"name": call.name, "arguments": call.arguments, "call_id": call.call_id, "at": at}
|
|
210
|
+
if out is not None:
|
|
211
|
+
tool_call.update(result=out.output, is_error=bool(getattr(out, "is_error", False)))
|
|
212
|
+
live.tool_call(tool_call)
|
|
213
|
+
except Exception as e: # noqa: BLE001 - never raise into the agent
|
|
214
|
+
log.warning("sopops: couldn't stream a tool call: %s", e)
|
|
215
|
+
|
|
216
|
+
session.on("conversation_item_added", on_item)
|
|
217
|
+
session.on("function_tools_executed", on_tools)
|
|
218
|
+
live.start()
|
|
219
|
+
return live
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def agent_config(session: Any) -> dict[str, Any]:
|
|
223
|
+
"""What an `AgentSession` runs with: `llm`, `stt`, `tts` as `<provider>/<model>` (LiveKit Inference models are
|
|
224
|
+
already written that way), and `voice` when the TTS keeps one. Whatever a plugin doesn't say is left out."""
|
|
225
|
+
out: dict[str, Any] = {}
|
|
226
|
+
for role in ("llm", "stt", "tts"):
|
|
227
|
+
try:
|
|
228
|
+
part = getattr(session, role, None)
|
|
229
|
+
except Exception: # noqa: BLE001 - a property that can't answer leaves the setting out
|
|
230
|
+
part = None
|
|
231
|
+
if part is None:
|
|
232
|
+
continue
|
|
233
|
+
name = _model_name(getattr(part, "provider", None), getattr(part, "model", None))
|
|
234
|
+
if name:
|
|
235
|
+
out[role] = name
|
|
236
|
+
if role == "tts":
|
|
237
|
+
opts = getattr(part, "_opts", None)
|
|
238
|
+
voice = getattr(part, "voice", None) or getattr(opts, "voice", None) or getattr(opts, "voice_id", None)
|
|
239
|
+
if isinstance(voice, str) and voice:
|
|
240
|
+
out["voice"] = voice
|
|
241
|
+
return out
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _model_name(provider: Any, model: Any) -> str | None:
|
|
245
|
+
model = model if isinstance(model, str) and model.lower() not in ("", "unknown") else None
|
|
246
|
+
provider = provider if isinstance(provider, str) and provider.lower() not in ("", "unknown", "livekit") else None
|
|
247
|
+
if not model:
|
|
248
|
+
return None
|
|
249
|
+
return model if "/" in model or not provider else f"{_provider_name(provider)}/{model}"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _provider_name(provider: str) -> str:
|
|
253
|
+
"""Some plugins report their API host as the provider (`api.anthropic.com`): keep the company's name."""
|
|
254
|
+
host = provider.lower().split("://")[-1].split("/")[0]
|
|
255
|
+
labels = [p for p in host.split(".") if p]
|
|
256
|
+
return labels[-2] if len(labels) >= 2 else host
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _userdata(session: Any) -> Any:
|
|
260
|
+
try:
|
|
261
|
+
return session.userdata
|
|
262
|
+
except (ValueError, AttributeError): # LiveKit raises ValueError when no userdata was set
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _get(userdata: Any, key: str) -> Any:
|
|
267
|
+
return userdata.get(key) if isinstance(userdata, Mapping) else getattr(userdata, key, None)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _remember(session: Any, ins: Instructions) -> None:
|
|
271
|
+
userdata = _userdata(session)
|
|
272
|
+
if userdata is None:
|
|
273
|
+
session.userdata = {RELEASE_KEY: ins.release, AGENT_KEY: ins.agent}
|
|
274
|
+
elif isinstance(userdata, dict):
|
|
275
|
+
userdata[RELEASE_KEY], userdata[AGENT_KEY] = ins.release, ins.agent
|
|
276
|
+
else:
|
|
277
|
+
try:
|
|
278
|
+
setattr(userdata, RELEASE_KEY, ins.release)
|
|
279
|
+
setattr(userdata, AGENT_KEY, ins.agent)
|
|
280
|
+
except AttributeError:
|
|
281
|
+
log.warning("sopops: can't note the release on %s; add a %s field to it", type(userdata).__name__, RELEASE_KEY)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
__all__ = ["agent_config", "attach", "from_livekit", "pinned_release", "record_session"]
|