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.
- sopops-0.1.0/.gitignore +16 -0
- sopops-0.1.0/PKG-INFO +130 -0
- sopops-0.1.0/README.md +112 -0
- sopops-0.1.0/pyproject.toml +36 -0
- sopops-0.1.0/src/sopops/__init__.py +11 -0
- sopops-0.1.0/src/sopops/client.py +300 -0
- sopops-0.1.0/src/sopops/livekit.py +284 -0
- sopops-0.1.0/src/sopops/mcp.py +364 -0
- sopops-0.1.0/src/sopops/platforms.py +113 -0
- sopops-0.1.0/src/sopops/py.typed +0 -0
- sopops-0.1.0/src/sopops/stream.py +138 -0
- sopops-0.1.0/tests/test_client.py +184 -0
- sopops-0.1.0/tests/test_livekit.py +302 -0
- sopops-0.1.0/tests/test_mcp.py +150 -0
- sopops-0.1.0/uv.lock +3342 -0
sopops-0.1.0/.gitignore
ADDED
|
@@ -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]
|