sopops 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.
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ *.db
6
+ dist/
7
+ node_modules/
8
+ web/dist/
9
+ src/sopops_server/static/
10
+ *.tsbuildinfo
11
+ .env
12
+ .taste-skill/
13
+ docs/.vitepress/dist/
14
+ docs/.vitepress/cache/
15
+ sdks/node/*.tgz
16
+ sdks/python/dist/
sopops-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.5
2
+ Name: sopops
3
+ Version: 0.1.0
4
+ Summary: Fetch your agent's instructions from sopops at call start, and send each finished session back.
5
+ Project-URL: Homepage, https://sopops.dev
6
+ Project-URL: Documentation, https://docs.sopops.dev
7
+ Project-URL: Repository, https://github.com/amanmibra/sopops
8
+ License-Expression: Apache-2.0
9
+ Keywords: livekit,mcp,prompts,sopc,sopops,voice agents
10
+ Classifier: Typing :: Typed
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: httpx>=0.27
13
+ Provides-Extra: livekit
14
+ Requires-Dist: livekit-agents>=1.0; extra == 'livekit'
15
+ Provides-Extra: mcp
16
+ Requires-Dist: mcp<3,>=2.3; extra == 'mcp'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # sopops
20
+
21
+ The Python client for [sopops](../../README.md): fetch an agent's instructions when a call starts, and send each finished session back.
22
+
23
+ ```sh
24
+ pip install "sopops[livekit]" # or plain sopops without the LiveKit adapter
25
+ pip install "sopops[mcp]" # sopops-mcp, the onboarding MCP server for Claude Code or Codex
26
+ ```
27
+
28
+ It's not on PyPI yet; install from a clone with `pip install "./sdks/python[livekit]"`. The server is a separate package, `sopops-server`.
29
+
30
+ ## LiveKit
31
+
32
+ ```python
33
+ from livekit.agents import Agent, AgentSession, JobContext
34
+ from sopops.livekit import attach
35
+
36
+ async def entrypoint(ctx: JobContext):
37
+ await ctx.connect()
38
+ session = AgentSession(...) # your STT, LLM, TTS
39
+ ins = await attach(ctx, session, agent="tonys-pizza") # SOPOPS_URL, SOPOPS_TOKEN
40
+ await session.start(room=ctx.room, agent=Agent(instructions=ins.text))
41
+ ```
42
+
43
+ `attach` fetches the instructions, notes the release in `session.userdata` (`sopops_release`), and at shutdown posts the session to sopops: `session.history` with timestamps and tool calls, keyed by the job id. To pin a release for a call (A/B tests), put `{"sopops_release": 3}` in the job's dispatch metadata or the room's metadata.
44
+
45
+ ## Any framework
46
+
47
+ ```python
48
+ from sopops import Sopops
49
+
50
+ sopops = Sopops() # SOPOPS_URL, SOPOPS_TOKEN
51
+ ins = await sopops.fetch_instructions("tonys-pizza", release=None, fallback="You are a helpful host.")
52
+ ins.text, ins.release, ins.blocks # the instructions, their release, the files they were built from
53
+
54
+ await sopops.post_session({"platform": "api", "external_id": call_id, "agent": "tonys-pizza", "release": ins.release,
55
+ "turns": [{"role": "user", "text": "Hi", "offset_s": 0.4}]})
56
+ ```
57
+
58
+ | | |
59
+ |---|---|
60
+ | `SOPOPS_URL` | The workspace's API URL, e.g. `http://sopops.internal:8484/v1/workspaces/prod`. |
61
+ | `SOPOPS_TOKEN` | A workspace token (sopops Settings → Tokens) with the scopes the agent needs: `runtime` to fetch instructions, `ingest` to send sessions. `SOPOPS_INGEST_TOKEN` is read too. A workspace without a runtime token serves instructions without one. |
62
+ | `SOPOPS_CACHE_DIR` | Where the last good instructions are kept, default `~/.cache/sopops`. |
63
+
64
+ **Values only the call knows** (`{{placeholders}}` for the time, the caller, anything your code works out) are
65
+ filled in your process, so they never reach sopops:
66
+
67
+ ```python
68
+ ins = await sopops.fetch_instructions("tonys-pizza", variables={"now": "7:15pm", "caller": "Sam"})
69
+ ins.unfilled # placeholders nobody filled, e.g. ("special",)
70
+ ```
71
+
72
+ A placeholder with no value is left as it is, and a value with no placeholder is ignored. Both log a warning and
73
+ neither stops the call. `attach` takes `variables` too.
74
+
75
+ Outside async code, one call does the same:
76
+
77
+ ```python
78
+ import sopops
79
+
80
+ ins = sopops.fetch("tonys-pizza") # the live release
81
+ ins = sopops.fetch("tonys-pizza", 14) # pinned, e.g. one arm of an A/B test
82
+ prompt = ins.text
83
+ ```
84
+
85
+ It reads the same environment variables, caches and falls back the same way, and builds a client per call, so inside
86
+ async code keep a `Sopops` and await `fetch_instructions` instead.
87
+
88
+ **A sopops outage never blocks a call.** The fetch gives up after `timeout` (2s by default). Then it returns the last good copy for that agent and release from disk (`ins.source == "cache"`), else your `fallback` (`"fallback"`), else raises `SopopsUnavailable`. Sessions are retried on connection errors, 429 and 5xx; sending the same session again is safe.
89
+
90
+ ## Streaming (optional)
91
+
92
+ ```python
93
+ ins = await attach(ctx, session, agent="tonys-pizza", stream=True)
94
+ ```
95
+
96
+ With `stream=True`, `attach` opens the session when the call starts and sends each turn and tool call as it happens (LiveKit's `conversation_item_added` and `function_tools_executed` events), in batches from a background task. It never blocks the agent and never raises into it: if sopops is slow or down, or the queue fills, events are logged and dropped. At shutdown it still posts the full session, which replaces what was streamed, so the default (`stream=False`, one post at the end) loses nothing. `on_guidance=` is accepted but not yet used: it's reserved for runtime guidance in a later version.
97
+
98
+ Outside LiveKit, use `SessionStream` directly:
99
+
100
+ ```python
101
+ from sopops import SessionStream
102
+
103
+ stream = SessionStream(sopops, {"platform": "api", "external_id": call_id, "agent": "tonys-pizza", "release": ins.release})
104
+ stream.start()
105
+ stream.turn({"role": "user", "text": "Delivery please.", "at": time.time()})
106
+ stream.tool_call({"name": "check_delivery_zone", "arguments": {"address": "221 Mercer St"}, "result": {"in_zone": True}})
107
+ await stream.aclose() # sends what's queued (at most 1s), then stops
108
+ await sopops.post_session(full_session)
109
+ ```
110
+
111
+ `Sopops.open_session` and `Sopops.append_events` are the two requests underneath (`POST …/sessions/open`, `POST …/sessions/<platform>/<id>/events`). Streaming needs the `ingest` scope, like posting.
112
+
113
+ The session format is in [API reference](../../docs/api/sessions.md#the-canonical-format). Adapters for other frameworks go in their own modules next to `sopops.livekit`, each importing its framework only when used. The TypeScript client is in [sdks/node](../node). A complete LiveKit agent is in [examples/livekit-restaurant](../../examples/livekit-restaurant).
114
+
115
+ ## sopops-mcp (onboarding)
116
+
117
+ `sopops-mcp` is the MCP server a coding agent (Claude Code, Codex) runs to onboard existing agents into sopops. The sopops Import page gives you the command, with your server and a 2-hour onboarding token filled in:
118
+
119
+ ```sh
120
+ claude mcp add sopops --env SOPOPS_URL=http://127.0.0.1:8484 --env SOPOPS_TOKEN=sopops_onb_... \
121
+ -- uvx --from 'sopops[mcp] @ git+https://github.com/amanmibra/sopops@main#subdirectory=sdks/python' sopops-mcp
122
+ ```
123
+
124
+ It reads ElevenLabs and Vapi agents locally (`ELEVENLABS_API_KEY`, `VAPI_API_KEY`, read-only) and writes the sopc project into the repo; everything else (the kickoff, the extraction guide, compiling, fidelity, pushing) goes to the sopops server's `/v1/onboarding` endpoints with `SOPOPS_URL` and `SOPOPS_TOKEN`. `sopops-mcp install-skill --url URL` copies the server's sopops-onboard skill into the repo (`--claude`, `--codex`).
125
+
126
+ ## Development
127
+
128
+ ```sh
129
+ cd sdks/python && uv run pytest
130
+ ```
sopops-0.1.0/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # sopops
2
+
3
+ The Python client for [sopops](../../README.md): fetch an agent's instructions when a call starts, and send each finished session back.
4
+
5
+ ```sh
6
+ pip install "sopops[livekit]" # or plain sopops without the LiveKit adapter
7
+ pip install "sopops[mcp]" # sopops-mcp, the onboarding MCP server for Claude Code or Codex
8
+ ```
9
+
10
+ It's not on PyPI yet; install from a clone with `pip install "./sdks/python[livekit]"`. The server is a separate package, `sopops-server`.
11
+
12
+ ## LiveKit
13
+
14
+ ```python
15
+ from livekit.agents import Agent, AgentSession, JobContext
16
+ from sopops.livekit import attach
17
+
18
+ async def entrypoint(ctx: JobContext):
19
+ await ctx.connect()
20
+ session = AgentSession(...) # your STT, LLM, TTS
21
+ ins = await attach(ctx, session, agent="tonys-pizza") # SOPOPS_URL, SOPOPS_TOKEN
22
+ await session.start(room=ctx.room, agent=Agent(instructions=ins.text))
23
+ ```
24
+
25
+ `attach` fetches the instructions, notes the release in `session.userdata` (`sopops_release`), and at shutdown posts the session to sopops: `session.history` with timestamps and tool calls, keyed by the job id. To pin a release for a call (A/B tests), put `{"sopops_release": 3}` in the job's dispatch metadata or the room's metadata.
26
+
27
+ ## Any framework
28
+
29
+ ```python
30
+ from sopops import Sopops
31
+
32
+ sopops = Sopops() # SOPOPS_URL, SOPOPS_TOKEN
33
+ ins = await sopops.fetch_instructions("tonys-pizza", release=None, fallback="You are a helpful host.")
34
+ ins.text, ins.release, ins.blocks # the instructions, their release, the files they were built from
35
+
36
+ await sopops.post_session({"platform": "api", "external_id": call_id, "agent": "tonys-pizza", "release": ins.release,
37
+ "turns": [{"role": "user", "text": "Hi", "offset_s": 0.4}]})
38
+ ```
39
+
40
+ | | |
41
+ |---|---|
42
+ | `SOPOPS_URL` | The workspace's API URL, e.g. `http://sopops.internal:8484/v1/workspaces/prod`. |
43
+ | `SOPOPS_TOKEN` | A workspace token (sopops Settings → Tokens) with the scopes the agent needs: `runtime` to fetch instructions, `ingest` to send sessions. `SOPOPS_INGEST_TOKEN` is read too. A workspace without a runtime token serves instructions without one. |
44
+ | `SOPOPS_CACHE_DIR` | Where the last good instructions are kept, default `~/.cache/sopops`. |
45
+
46
+ **Values only the call knows** (`{{placeholders}}` for the time, the caller, anything your code works out) are
47
+ filled in your process, so they never reach sopops:
48
+
49
+ ```python
50
+ ins = await sopops.fetch_instructions("tonys-pizza", variables={"now": "7:15pm", "caller": "Sam"})
51
+ ins.unfilled # placeholders nobody filled, e.g. ("special",)
52
+ ```
53
+
54
+ A placeholder with no value is left as it is, and a value with no placeholder is ignored. Both log a warning and
55
+ neither stops the call. `attach` takes `variables` too.
56
+
57
+ Outside async code, one call does the same:
58
+
59
+ ```python
60
+ import sopops
61
+
62
+ ins = sopops.fetch("tonys-pizza") # the live release
63
+ ins = sopops.fetch("tonys-pizza", 14) # pinned, e.g. one arm of an A/B test
64
+ prompt = ins.text
65
+ ```
66
+
67
+ It reads the same environment variables, caches and falls back the same way, and builds a client per call, so inside
68
+ async code keep a `Sopops` and await `fetch_instructions` instead.
69
+
70
+ **A sopops outage never blocks a call.** The fetch gives up after `timeout` (2s by default). Then it returns the last good copy for that agent and release from disk (`ins.source == "cache"`), else your `fallback` (`"fallback"`), else raises `SopopsUnavailable`. Sessions are retried on connection errors, 429 and 5xx; sending the same session again is safe.
71
+
72
+ ## Streaming (optional)
73
+
74
+ ```python
75
+ ins = await attach(ctx, session, agent="tonys-pizza", stream=True)
76
+ ```
77
+
78
+ With `stream=True`, `attach` opens the session when the call starts and sends each turn and tool call as it happens (LiveKit's `conversation_item_added` and `function_tools_executed` events), in batches from a background task. It never blocks the agent and never raises into it: if sopops is slow or down, or the queue fills, events are logged and dropped. At shutdown it still posts the full session, which replaces what was streamed, so the default (`stream=False`, one post at the end) loses nothing. `on_guidance=` is accepted but not yet used: it's reserved for runtime guidance in a later version.
79
+
80
+ Outside LiveKit, use `SessionStream` directly:
81
+
82
+ ```python
83
+ from sopops import SessionStream
84
+
85
+ stream = SessionStream(sopops, {"platform": "api", "external_id": call_id, "agent": "tonys-pizza", "release": ins.release})
86
+ stream.start()
87
+ stream.turn({"role": "user", "text": "Delivery please.", "at": time.time()})
88
+ stream.tool_call({"name": "check_delivery_zone", "arguments": {"address": "221 Mercer St"}, "result": {"in_zone": True}})
89
+ await stream.aclose() # sends what's queued (at most 1s), then stops
90
+ await sopops.post_session(full_session)
91
+ ```
92
+
93
+ `Sopops.open_session` and `Sopops.append_events` are the two requests underneath (`POST …/sessions/open`, `POST …/sessions/<platform>/<id>/events`). Streaming needs the `ingest` scope, like posting.
94
+
95
+ The session format is in [API reference](../../docs/api/sessions.md#the-canonical-format). Adapters for other frameworks go in their own modules next to `sopops.livekit`, each importing its framework only when used. The TypeScript client is in [sdks/node](../node). A complete LiveKit agent is in [examples/livekit-restaurant](../../examples/livekit-restaurant).
96
+
97
+ ## sopops-mcp (onboarding)
98
+
99
+ `sopops-mcp` is the MCP server a coding agent (Claude Code, Codex) runs to onboard existing agents into sopops. The sopops Import page gives you the command, with your server and a 2-hour onboarding token filled in:
100
+
101
+ ```sh
102
+ claude mcp add sopops --env SOPOPS_URL=http://127.0.0.1:8484 --env SOPOPS_TOKEN=sopops_onb_... \
103
+ -- uvx --from 'sopops[mcp] @ git+https://github.com/amanmibra/sopops@main#subdirectory=sdks/python' sopops-mcp
104
+ ```
105
+
106
+ It reads ElevenLabs and Vapi agents locally (`ELEVENLABS_API_KEY`, `VAPI_API_KEY`, read-only) and writes the sopc project into the repo; everything else (the kickoff, the extraction guide, compiling, fidelity, pushing) goes to the sopops server's `/v1/onboarding` endpoints with `SOPOPS_URL` and `SOPOPS_TOKEN`. `sopops-mcp install-skill --url URL` copies the server's sopops-onboard skill into the repo (`--claude`, `--codex`).
107
+
108
+ ## Development
109
+
110
+ ```sh
111
+ cd sdks/python && uv run pytest
112
+ ```
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "sopops"
3
+ version = "0.1.0"
4
+ description = "Fetch your agent's instructions from sopops at call start, and send each finished session back."
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ requires-python = ">=3.10"
8
+ dependencies = ["httpx>=0.27"]
9
+ keywords = ["sopops", "sopc", "voice agents", "livekit", "prompts", "mcp"]
10
+ classifiers = ["Typing :: Typed"]
11
+
12
+ [project.urls]
13
+ Homepage = "https://sopops.dev"
14
+ Documentation = "https://docs.sopops.dev"
15
+ Repository = "https://github.com/amanmibra/sopops"
16
+
17
+ [project.optional-dependencies]
18
+ livekit = ["livekit-agents>=1.0"]
19
+ # sopops-mcp: the onboarding MCP server a coding agent (Claude Code, Codex) runs, talking to sopops over HTTP.
20
+ mcp = ["mcp>=2.3,<3"]
21
+
22
+ [project.scripts]
23
+ sopops-mcp = "sopops.mcp:main"
24
+
25
+ [dependency-groups]
26
+ dev = ["pytest>=8", "respx>=0.22", "mcp>=2.3,<3"]
27
+
28
+ [build-system]
29
+ requires = ["hatchling"]
30
+ build-backend = "hatchling.build"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/sopops"]
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
@@ -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"]
@@ -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]