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 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"]