zonrad-sdk 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.
- zonrad_sdk-0.1.0/PKG-INFO +217 -0
- zonrad_sdk-0.1.0/QUICKSTART.md +203 -0
- zonrad_sdk-0.1.0/pyproject.toml +26 -0
- zonrad_sdk-0.1.0/setup.cfg +4 -0
- zonrad_sdk-0.1.0/tests/test_client_tools.py +239 -0
- zonrad_sdk-0.1.0/tests/test_sse_parsing.py +113 -0
- zonrad_sdk-0.1.0/zonrad/__init__.py +4 -0
- zonrad_sdk-0.1.0/zonrad/_http.py +23 -0
- zonrad_sdk-0.1.0/zonrad/_schema.py +62 -0
- zonrad_sdk-0.1.0/zonrad/_sse.py +18 -0
- zonrad_sdk-0.1.0/zonrad/client.py +32 -0
- zonrad_sdk-0.1.0/zonrad/exceptions.py +34 -0
- zonrad_sdk-0.1.0/zonrad/resources/__init__.py +0 -0
- zonrad_sdk-0.1.0/zonrad/resources/agents.py +20 -0
- zonrad_sdk-0.1.0/zonrad/resources/api_keys.py +15 -0
- zonrad_sdk-0.1.0/zonrad/resources/connections.py +6 -0
- zonrad_sdk-0.1.0/zonrad/resources/models.py +6 -0
- zonrad_sdk-0.1.0/zonrad/resources/policies.py +9 -0
- zonrad_sdk-0.1.0/zonrad/resources/runs.py +125 -0
- zonrad_sdk-0.1.0/zonrad/resources/sessions.py +49 -0
- zonrad_sdk-0.1.0/zonrad/resources/tools.py +63 -0
- zonrad_sdk-0.1.0/zonrad_sdk.egg-info/PKG-INFO +217 -0
- zonrad_sdk-0.1.0/zonrad_sdk.egg-info/SOURCES.txt +24 -0
- zonrad_sdk-0.1.0/zonrad_sdk.egg-info/dependency_links.txt +1 -0
- zonrad_sdk-0.1.0/zonrad_sdk.egg-info/requires.txt +1 -0
- zonrad_sdk-0.1.0/zonrad_sdk.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zonrad-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Zonrad platform (/platform/v1/) — agents, sessions, runs, tools, and connections.
|
|
5
|
+
Author: Zonrad
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://zonrad.ai
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: requests>=2.28
|
|
14
|
+
|
|
15
|
+
# Zonrad Python SDK — Quickstart
|
|
16
|
+
|
|
17
|
+
The Zonrad SDK talks to the `/platform/v1/` boundary of your Zonrad workspace:
|
|
18
|
+
create agents, run them, stream their output, and inspect what they did —
|
|
19
|
+
all from a Python script, with no Django code in sight.
|
|
20
|
+
|
|
21
|
+
This guide is copy-paste runnable end to end.
|
|
22
|
+
|
|
23
|
+
## 1. Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install zonrad-sdk
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The package on PyPI is named `zonrad-sdk` (`zonrad` was already taken), but
|
|
30
|
+
the import is unaffected: `from zonrad import Zonrad`, exactly as below.
|
|
31
|
+
|
|
32
|
+
Working against an unreleased local checkout of this repo instead? Use
|
|
33
|
+
`pip install -e sdk/python` — same import, no PyPI round trip.
|
|
34
|
+
|
|
35
|
+
## 2. Get an API key
|
|
36
|
+
|
|
37
|
+
Every request needs a `PlatformAPIKey`, scoped to exactly one workspace.
|
|
38
|
+
Mint one with an authenticated session (e.g. your zonrad-ui login) against
|
|
39
|
+
the key-management endpoint:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
curl -X POST https://<your-zonrad-host>/platform/v1/workspaces/<workspace_id>/api-keys \
|
|
43
|
+
-H "Authorization: Bearer <your JWT access token>" \
|
|
44
|
+
-H "Content-Type: application/json" \
|
|
45
|
+
-d '{"name": "my laptop"}'
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The response looks like:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"id": 1,
|
|
53
|
+
"name": "my laptop",
|
|
54
|
+
"prefix": "zk_live_7792c540",
|
|
55
|
+
"key": "zk_live_7792c540_9dQwhgufdWlqEkq5hZy-fD5IaZffHAHy",
|
|
56
|
+
"workspace_id": 38,
|
|
57
|
+
"last_used_at": null,
|
|
58
|
+
"revoked_at": null,
|
|
59
|
+
"created_at": "2026-09-18T11:29:08.140046Z"
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Save the `key` and `workspace_id` now** — the raw key is shown exactly
|
|
64
|
+
once and can never be retrieved again (only its prefix, for identifying it
|
|
65
|
+
later). If you lose it, mint a new one and revoke the old.
|
|
66
|
+
|
|
67
|
+
## 3. Your first agent, session, and run
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from zonrad import Zonrad
|
|
71
|
+
|
|
72
|
+
z = Zonrad(api_key="zk_live_...", workspace_id=38)
|
|
73
|
+
|
|
74
|
+
agent = z.agents.create(
|
|
75
|
+
name="Release Notes Bot",
|
|
76
|
+
instructions="Summarize engineering updates concisely for a non-technical audience.",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
session = z.sessions.new(agent_id=agent["id"]) # lazy — no request sent yet
|
|
80
|
+
run = session.run("Say hello in exactly three words.")
|
|
81
|
+
|
|
82
|
+
for event in run.stream():
|
|
83
|
+
if event["event"] == "response.chunk":
|
|
84
|
+
print(event["data"]["content"], end="", flush=True)
|
|
85
|
+
elif event["event"] == "response.completed":
|
|
86
|
+
print() # final newline
|
|
87
|
+
|
|
88
|
+
print("session id (assigned by the server):", session.id)
|
|
89
|
+
print("run id:", run.id)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`workspace_id` is set once, at client construction — every resource call
|
|
93
|
+
uses it automatically, since your API key is already scoped to that one
|
|
94
|
+
workspace.
|
|
95
|
+
|
|
96
|
+
`z.sessions.new()` is lazy: no HTTP request happens until the first
|
|
97
|
+
`.run()`. Use `z.sessions.create(agent_id=...)` instead if you want the
|
|
98
|
+
conversation to exist before you send the first message (e.g. to save its
|
|
99
|
+
`id` before running anything).
|
|
100
|
+
|
|
101
|
+
## 4. Inspecting a trace
|
|
102
|
+
|
|
103
|
+
Every run is recorded. If something looks wrong, pull the full trace —
|
|
104
|
+
every model call and tool call, in order, with token counts:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
trace = z.runs.get_trace(run.id)
|
|
108
|
+
for span in trace["spans"]:
|
|
109
|
+
print(span["type"], span["name"], span.get("total_tokens"))
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## 5. Running your own Python function as a tool
|
|
113
|
+
|
|
114
|
+
`@z.tools.local(...)` registers a plain Python function that runs **on
|
|
115
|
+
this machine** — the agent calls it mid-run, the SDK executes it locally,
|
|
116
|
+
and the conversation continues automatically. No backend deploy, no new
|
|
117
|
+
tool registration anywhere except this script:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from zonrad import Zonrad
|
|
121
|
+
|
|
122
|
+
z = Zonrad(api_key="zk_live_...", workspace_id=38)
|
|
123
|
+
|
|
124
|
+
@z.tools.local()
|
|
125
|
+
def get_local_file(path: str) -> dict:
|
|
126
|
+
"""Reads a file from this machine's filesystem."""
|
|
127
|
+
with open(path) as f:
|
|
128
|
+
return {"content": f.read()}
|
|
129
|
+
|
|
130
|
+
agent = z.agents.create(
|
|
131
|
+
name="File Assistant",
|
|
132
|
+
instructions="Use get_local_file to answer questions about files on the user's machine.",
|
|
133
|
+
)
|
|
134
|
+
session = z.sessions.new(agent_id=agent["id"])
|
|
135
|
+
run = session.run("What's in ~/notes.txt?")
|
|
136
|
+
|
|
137
|
+
for event in run.stream():
|
|
138
|
+
if event["event"] == "response.chunk":
|
|
139
|
+
print(event["data"]["content"], end="", flush=True)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
That's the whole thing — `run.stream()` sees the model call
|
|
143
|
+
`get_local_file`, runs your function, sends the result back, and keeps
|
|
144
|
+
streaming. You never handle the pause yourself.
|
|
145
|
+
|
|
146
|
+
The function's parameter types (`str`, `int`, `float`, `bool`, `list`,
|
|
147
|
+
`dict`, `Optional[...]`) are turned into the schema the model sees — same
|
|
148
|
+
rule as the backend's own `register_tool()` below, so a function looks the
|
|
149
|
+
same either way. Every parameter needs a type hint. Give the tool an
|
|
150
|
+
explicit name/description if the function's own name/docstring aren't
|
|
151
|
+
what you want the model to see: `@z.tools.local(name="read_file",
|
|
152
|
+
description="...")`.
|
|
153
|
+
|
|
154
|
+
**What this can't do:** a name that collides with an existing server-side
|
|
155
|
+
tool, or one your workspace has denied via policy, is rejected with a
|
|
156
|
+
clear error the moment you call `.run()` — not silently ignored. And a
|
|
157
|
+
local tool only exists for runs started by *this* process; it's not
|
|
158
|
+
visible to other users or other scripts, and `z.tools.list()` still only
|
|
159
|
+
shows the server-side registry (see below).
|
|
160
|
+
|
|
161
|
+
## 6. Adding a tool for everyone (backend registration)
|
|
162
|
+
|
|
163
|
+
If a tool should be available to any agent in the workspace, not just from
|
|
164
|
+
your own script, register it in the backend repo instead:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
# backend_django/apps/custom_agents/tools/my_tool.py
|
|
168
|
+
from apps.custom_agents.tool_registry import register_tool
|
|
169
|
+
|
|
170
|
+
@register_tool(
|
|
171
|
+
id="fetch_release_notes",
|
|
172
|
+
name="Fetch Release Notes",
|
|
173
|
+
description="Fetches the latest release notes for a given repo.",
|
|
174
|
+
risk_level="low",
|
|
175
|
+
)
|
|
176
|
+
def fetch_release_notes(repo: str) -> dict:
|
|
177
|
+
...
|
|
178
|
+
return {"notes": "..."}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
This requires a backend deploy and then shows up in `z.tools.list()` for
|
|
182
|
+
everyone — unlike `@z.tools.local(...)`, which is private to the process
|
|
183
|
+
that registered it and needs no deploy at all.
|
|
184
|
+
|
|
185
|
+
## What's not supported yet
|
|
186
|
+
|
|
187
|
+
- **Non-Python SDKs.** Python only, for now.
|
|
188
|
+
- **Streaming partial output *from* a local tool.** Your function's return
|
|
189
|
+
value is submitted as one complete result — there's no equivalent of
|
|
190
|
+
`response.chunk` for a tool's own execution.
|
|
191
|
+
|
|
192
|
+
## Reference
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
z.agents.list() / .create(**fields) / .get(id) / .update(id, **fields) / .delete(id)
|
|
196
|
+
z.sessions.create(agent_id) / .new(agent_id) / .get(session_id)
|
|
197
|
+
session.run(prompt) -> Run
|
|
198
|
+
run.stream() # generator of {"event": ..., "data": {...}} dicts — auto-handles local tool calls
|
|
199
|
+
run.get() / .get_trace() / .resume(decision, reason="")
|
|
200
|
+
z.runs.get(run_id) / .get_trace(run_id) / .resume(run_id, decision, reason="")
|
|
201
|
+
z.tools.list() / z.toolkits.list() # server-side registry (read-only)
|
|
202
|
+
z.tools.local(name=None, description=None, parameters=None) # decorator — registers a local function
|
|
203
|
+
z.connections.list()
|
|
204
|
+
z.models.list()
|
|
205
|
+
z.policies.get() / .update(**fields)
|
|
206
|
+
z.api_keys.list() / .create(name="") / .revoke(key_id)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
A `tool.execution_requested` event your own code never has to handle
|
|
210
|
+
raises `zonrad.ZonradToolNotRegisteredError` internally if no matching
|
|
211
|
+
`@z.tools.local(...)` handler exists — the SDK catches this itself and
|
|
212
|
+
submits it as that call's error, so the run continues rather than your
|
|
213
|
+
script crashing.
|
|
214
|
+
|
|
215
|
+
Errors raise `zonrad.ZonradAPIError` with `.status_code`, `.error_code`
|
|
216
|
+
(when the server sent one, e.g. `"insufficient_credits"`), and `.body`
|
|
217
|
+
(the full parsed response).
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# Zonrad Python SDK — Quickstart
|
|
2
|
+
|
|
3
|
+
The Zonrad SDK talks to the `/platform/v1/` boundary of your Zonrad workspace:
|
|
4
|
+
create agents, run them, stream their output, and inspect what they did —
|
|
5
|
+
all from a Python script, with no Django code in sight.
|
|
6
|
+
|
|
7
|
+
This guide is copy-paste runnable end to end.
|
|
8
|
+
|
|
9
|
+
## 1. Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install zonrad-sdk
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The package on PyPI is named `zonrad-sdk` (`zonrad` was already taken), but
|
|
16
|
+
the import is unaffected: `from zonrad import Zonrad`, exactly as below.
|
|
17
|
+
|
|
18
|
+
Working against an unreleased local checkout of this repo instead? Use
|
|
19
|
+
`pip install -e sdk/python` — same import, no PyPI round trip.
|
|
20
|
+
|
|
21
|
+
## 2. Get an API key
|
|
22
|
+
|
|
23
|
+
Every request needs a `PlatformAPIKey`, scoped to exactly one workspace.
|
|
24
|
+
Mint one with an authenticated session (e.g. your zonrad-ui login) against
|
|
25
|
+
the key-management endpoint:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
curl -X POST https://<your-zonrad-host>/platform/v1/workspaces/<workspace_id>/api-keys \
|
|
29
|
+
-H "Authorization: Bearer <your JWT access token>" \
|
|
30
|
+
-H "Content-Type: application/json" \
|
|
31
|
+
-d '{"name": "my laptop"}'
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The response looks like:
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"id": 1,
|
|
39
|
+
"name": "my laptop",
|
|
40
|
+
"prefix": "zk_live_7792c540",
|
|
41
|
+
"key": "zk_live_7792c540_9dQwhgufdWlqEkq5hZy-fD5IaZffHAHy",
|
|
42
|
+
"workspace_id": 38,
|
|
43
|
+
"last_used_at": null,
|
|
44
|
+
"revoked_at": null,
|
|
45
|
+
"created_at": "2026-09-18T11:29:08.140046Z"
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Save the `key` and `workspace_id` now** — the raw key is shown exactly
|
|
50
|
+
once and can never be retrieved again (only its prefix, for identifying it
|
|
51
|
+
later). If you lose it, mint a new one and revoke the old.
|
|
52
|
+
|
|
53
|
+
## 3. Your first agent, session, and run
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from zonrad import Zonrad
|
|
57
|
+
|
|
58
|
+
z = Zonrad(api_key="zk_live_...", workspace_id=38)
|
|
59
|
+
|
|
60
|
+
agent = z.agents.create(
|
|
61
|
+
name="Release Notes Bot",
|
|
62
|
+
instructions="Summarize engineering updates concisely for a non-technical audience.",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
session = z.sessions.new(agent_id=agent["id"]) # lazy — no request sent yet
|
|
66
|
+
run = session.run("Say hello in exactly three words.")
|
|
67
|
+
|
|
68
|
+
for event in run.stream():
|
|
69
|
+
if event["event"] == "response.chunk":
|
|
70
|
+
print(event["data"]["content"], end="", flush=True)
|
|
71
|
+
elif event["event"] == "response.completed":
|
|
72
|
+
print() # final newline
|
|
73
|
+
|
|
74
|
+
print("session id (assigned by the server):", session.id)
|
|
75
|
+
print("run id:", run.id)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`workspace_id` is set once, at client construction — every resource call
|
|
79
|
+
uses it automatically, since your API key is already scoped to that one
|
|
80
|
+
workspace.
|
|
81
|
+
|
|
82
|
+
`z.sessions.new()` is lazy: no HTTP request happens until the first
|
|
83
|
+
`.run()`. Use `z.sessions.create(agent_id=...)` instead if you want the
|
|
84
|
+
conversation to exist before you send the first message (e.g. to save its
|
|
85
|
+
`id` before running anything).
|
|
86
|
+
|
|
87
|
+
## 4. Inspecting a trace
|
|
88
|
+
|
|
89
|
+
Every run is recorded. If something looks wrong, pull the full trace —
|
|
90
|
+
every model call and tool call, in order, with token counts:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
trace = z.runs.get_trace(run.id)
|
|
94
|
+
for span in trace["spans"]:
|
|
95
|
+
print(span["type"], span["name"], span.get("total_tokens"))
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## 5. Running your own Python function as a tool
|
|
99
|
+
|
|
100
|
+
`@z.tools.local(...)` registers a plain Python function that runs **on
|
|
101
|
+
this machine** — the agent calls it mid-run, the SDK executes it locally,
|
|
102
|
+
and the conversation continues automatically. No backend deploy, no new
|
|
103
|
+
tool registration anywhere except this script:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from zonrad import Zonrad
|
|
107
|
+
|
|
108
|
+
z = Zonrad(api_key="zk_live_...", workspace_id=38)
|
|
109
|
+
|
|
110
|
+
@z.tools.local()
|
|
111
|
+
def get_local_file(path: str) -> dict:
|
|
112
|
+
"""Reads a file from this machine's filesystem."""
|
|
113
|
+
with open(path) as f:
|
|
114
|
+
return {"content": f.read()}
|
|
115
|
+
|
|
116
|
+
agent = z.agents.create(
|
|
117
|
+
name="File Assistant",
|
|
118
|
+
instructions="Use get_local_file to answer questions about files on the user's machine.",
|
|
119
|
+
)
|
|
120
|
+
session = z.sessions.new(agent_id=agent["id"])
|
|
121
|
+
run = session.run("What's in ~/notes.txt?")
|
|
122
|
+
|
|
123
|
+
for event in run.stream():
|
|
124
|
+
if event["event"] == "response.chunk":
|
|
125
|
+
print(event["data"]["content"], end="", flush=True)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
That's the whole thing — `run.stream()` sees the model call
|
|
129
|
+
`get_local_file`, runs your function, sends the result back, and keeps
|
|
130
|
+
streaming. You never handle the pause yourself.
|
|
131
|
+
|
|
132
|
+
The function's parameter types (`str`, `int`, `float`, `bool`, `list`,
|
|
133
|
+
`dict`, `Optional[...]`) are turned into the schema the model sees — same
|
|
134
|
+
rule as the backend's own `register_tool()` below, so a function looks the
|
|
135
|
+
same either way. Every parameter needs a type hint. Give the tool an
|
|
136
|
+
explicit name/description if the function's own name/docstring aren't
|
|
137
|
+
what you want the model to see: `@z.tools.local(name="read_file",
|
|
138
|
+
description="...")`.
|
|
139
|
+
|
|
140
|
+
**What this can't do:** a name that collides with an existing server-side
|
|
141
|
+
tool, or one your workspace has denied via policy, is rejected with a
|
|
142
|
+
clear error the moment you call `.run()` — not silently ignored. And a
|
|
143
|
+
local tool only exists for runs started by *this* process; it's not
|
|
144
|
+
visible to other users or other scripts, and `z.tools.list()` still only
|
|
145
|
+
shows the server-side registry (see below).
|
|
146
|
+
|
|
147
|
+
## 6. Adding a tool for everyone (backend registration)
|
|
148
|
+
|
|
149
|
+
If a tool should be available to any agent in the workspace, not just from
|
|
150
|
+
your own script, register it in the backend repo instead:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
# backend_django/apps/custom_agents/tools/my_tool.py
|
|
154
|
+
from apps.custom_agents.tool_registry import register_tool
|
|
155
|
+
|
|
156
|
+
@register_tool(
|
|
157
|
+
id="fetch_release_notes",
|
|
158
|
+
name="Fetch Release Notes",
|
|
159
|
+
description="Fetches the latest release notes for a given repo.",
|
|
160
|
+
risk_level="low",
|
|
161
|
+
)
|
|
162
|
+
def fetch_release_notes(repo: str) -> dict:
|
|
163
|
+
...
|
|
164
|
+
return {"notes": "..."}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
This requires a backend deploy and then shows up in `z.tools.list()` for
|
|
168
|
+
everyone — unlike `@z.tools.local(...)`, which is private to the process
|
|
169
|
+
that registered it and needs no deploy at all.
|
|
170
|
+
|
|
171
|
+
## What's not supported yet
|
|
172
|
+
|
|
173
|
+
- **Non-Python SDKs.** Python only, for now.
|
|
174
|
+
- **Streaming partial output *from* a local tool.** Your function's return
|
|
175
|
+
value is submitted as one complete result — there's no equivalent of
|
|
176
|
+
`response.chunk` for a tool's own execution.
|
|
177
|
+
|
|
178
|
+
## Reference
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
z.agents.list() / .create(**fields) / .get(id) / .update(id, **fields) / .delete(id)
|
|
182
|
+
z.sessions.create(agent_id) / .new(agent_id) / .get(session_id)
|
|
183
|
+
session.run(prompt) -> Run
|
|
184
|
+
run.stream() # generator of {"event": ..., "data": {...}} dicts — auto-handles local tool calls
|
|
185
|
+
run.get() / .get_trace() / .resume(decision, reason="")
|
|
186
|
+
z.runs.get(run_id) / .get_trace(run_id) / .resume(run_id, decision, reason="")
|
|
187
|
+
z.tools.list() / z.toolkits.list() # server-side registry (read-only)
|
|
188
|
+
z.tools.local(name=None, description=None, parameters=None) # decorator — registers a local function
|
|
189
|
+
z.connections.list()
|
|
190
|
+
z.models.list()
|
|
191
|
+
z.policies.get() / .update(**fields)
|
|
192
|
+
z.api_keys.list() / .create(name="") / .revoke(key_id)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
A `tool.execution_requested` event your own code never has to handle
|
|
196
|
+
raises `zonrad.ZonradToolNotRegisteredError` internally if no matching
|
|
197
|
+
`@z.tools.local(...)` handler exists — the SDK catches this itself and
|
|
198
|
+
submits it as that call's error, so the run continues rather than your
|
|
199
|
+
script crashing.
|
|
200
|
+
|
|
201
|
+
Errors raise `zonrad.ZonradAPIError` with `.status_code`, `.error_code`
|
|
202
|
+
(when the server sent one, e.g. `"insufficient_credits"`), and `.body`
|
|
203
|
+
(the full parsed response).
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "zonrad-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for the Zonrad platform (/platform/v1/) — agents, sessions, runs, tools, and connections."
|
|
9
|
+
readme = "QUICKSTART.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Proprietary" }
|
|
12
|
+
authors = [{ name = "Zonrad" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"requests>=2.28",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://zonrad.ai"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
include = ["zonrad*"]
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from zonrad._schema import ToolSchemaError, build_schema_from_function
|
|
7
|
+
from zonrad.exceptions import ZonradToolNotRegisteredError
|
|
8
|
+
from zonrad.resources.runs import Run
|
|
9
|
+
from zonrad.resources.tools import ToolsResource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _FakeClient:
|
|
13
|
+
workspace_id = 1
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.tools = ToolsResource(self)
|
|
17
|
+
self.submitted = None
|
|
18
|
+
self._next_continuation_lines = None
|
|
19
|
+
|
|
20
|
+
class _HTTP:
|
|
21
|
+
def __init__(self, outer):
|
|
22
|
+
self.outer = outer
|
|
23
|
+
|
|
24
|
+
def stream(self, method, path, json):
|
|
25
|
+
self.outer.submitted = {"method": method, "path": path, "json": json}
|
|
26
|
+
return _FakeStreamResponse(self.outer._next_continuation_lines or [])
|
|
27
|
+
|
|
28
|
+
def __getattr__(self, item):
|
|
29
|
+
if item == "_http":
|
|
30
|
+
return self._HTTP(self)
|
|
31
|
+
raise AttributeError(item)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _FakeStreamResponse:
|
|
35
|
+
def __init__(self, lines):
|
|
36
|
+
self._lines = lines
|
|
37
|
+
|
|
38
|
+
def iter_lines(self, decode_unicode=True):
|
|
39
|
+
yield from self._lines
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _sse_line(event, data):
|
|
43
|
+
return f"data: {json.dumps({'event': event, 'data': data})}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestBuildSchemaFromFunction:
|
|
47
|
+
def test_primitive_types(self):
|
|
48
|
+
def fn(a: str, b: int, c: float, d: bool):
|
|
49
|
+
...
|
|
50
|
+
schema = build_schema_from_function(fn)
|
|
51
|
+
assert schema["properties"]["a"]["type"] == "string"
|
|
52
|
+
assert schema["properties"]["b"]["type"] == "integer"
|
|
53
|
+
assert schema["properties"]["c"]["type"] == "number"
|
|
54
|
+
assert schema["properties"]["d"]["type"] == "boolean"
|
|
55
|
+
assert set(schema["required"]) == {"a", "b", "c", "d"}
|
|
56
|
+
|
|
57
|
+
def test_list_and_dict(self):
|
|
58
|
+
def fn(items: list, meta: dict):
|
|
59
|
+
...
|
|
60
|
+
schema = build_schema_from_function(fn)
|
|
61
|
+
assert schema["properties"]["items"]["type"] == "array"
|
|
62
|
+
assert schema["properties"]["meta"]["type"] == "object"
|
|
63
|
+
|
|
64
|
+
def test_optional_excludes_from_required(self):
|
|
65
|
+
def fn(name: str, nickname: Optional[str] = None):
|
|
66
|
+
...
|
|
67
|
+
schema = build_schema_from_function(fn)
|
|
68
|
+
assert "nickname" not in schema["required"]
|
|
69
|
+
assert "name" in schema["required"]
|
|
70
|
+
|
|
71
|
+
def test_missing_type_hint_raises(self):
|
|
72
|
+
def fn(a):
|
|
73
|
+
...
|
|
74
|
+
with pytest.raises(ToolSchemaError):
|
|
75
|
+
build_schema_from_function(fn)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TestToolsResourceLocal:
|
|
79
|
+
def test_local_registers_and_lists_specs(self):
|
|
80
|
+
client = _FakeClient()
|
|
81
|
+
|
|
82
|
+
@client.tools.local()
|
|
83
|
+
def get_weather(city: str) -> dict:
|
|
84
|
+
"""Gets the weather."""
|
|
85
|
+
return {"city": city, "temp": 72}
|
|
86
|
+
|
|
87
|
+
specs = client.tools._local_specs()
|
|
88
|
+
assert len(specs) == 1
|
|
89
|
+
assert specs[0]["name"] == "get_weather"
|
|
90
|
+
assert specs[0]["description"] == "Gets the weather."
|
|
91
|
+
assert specs[0]["parameters"]["properties"]["city"]["type"] == "string"
|
|
92
|
+
|
|
93
|
+
def test_execute_local_happy_path_dict_arguments(self):
|
|
94
|
+
client = _FakeClient()
|
|
95
|
+
|
|
96
|
+
@client.tools.local(name="add")
|
|
97
|
+
def add(a: int, b: int) -> int:
|
|
98
|
+
return a + b
|
|
99
|
+
|
|
100
|
+
assert client.tools._execute_local("add", {"a": 2, "b": 3}) == 5
|
|
101
|
+
|
|
102
|
+
def test_execute_local_defensively_parses_json_string_arguments(self):
|
|
103
|
+
client = _FakeClient()
|
|
104
|
+
|
|
105
|
+
@client.tools.local(name="add")
|
|
106
|
+
def add(a: int, b: int) -> int:
|
|
107
|
+
return a + b
|
|
108
|
+
|
|
109
|
+
assert client.tools._execute_local("add", json.dumps({"a": 2, "b": 3})) == 5
|
|
110
|
+
|
|
111
|
+
def test_execute_local_falls_back_to_empty_dict_for_garbage_input(self):
|
|
112
|
+
client = _FakeClient()
|
|
113
|
+
|
|
114
|
+
@client.tools.local(name="noop")
|
|
115
|
+
def noop() -> str:
|
|
116
|
+
return "ok"
|
|
117
|
+
|
|
118
|
+
assert client.tools._execute_local("noop", "not json") == "ok"
|
|
119
|
+
assert client.tools._execute_local("noop", None) == "ok"
|
|
120
|
+
|
|
121
|
+
def test_execute_local_raises_for_unregistered_tool(self):
|
|
122
|
+
client = _FakeClient()
|
|
123
|
+
with pytest.raises(ZonradToolNotRegisteredError):
|
|
124
|
+
client.tools._execute_local("nonexistent", {})
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class TestRunStreamAutoLoop:
|
|
128
|
+
def _run_with_events(self, lines, client=None):
|
|
129
|
+
client = client or _FakeClient()
|
|
130
|
+
resp = _FakeStreamResponse(lines)
|
|
131
|
+
return Run._from_stream(client, resp), client
|
|
132
|
+
|
|
133
|
+
def test_tool_execution_requested_is_handled_transparently(self):
|
|
134
|
+
client = _FakeClient()
|
|
135
|
+
|
|
136
|
+
@client.tools.local(name="get_local_file")
|
|
137
|
+
def get_local_file(path: str) -> dict:
|
|
138
|
+
return {"content": f"contents of {path}"}
|
|
139
|
+
|
|
140
|
+
first_stream_lines = [
|
|
141
|
+
_sse_line("agent.status", {"agent_run_id": "run_1", "status": "thinking"}),
|
|
142
|
+
"",
|
|
143
|
+
_sse_line("tool.execution_requested", {
|
|
144
|
+
"agent_run_id": "run_1", "tool": "get_local_file",
|
|
145
|
+
"tool_call_id": "call_1", "input": {"path": "/tmp/x"}, "batch_size": 1,
|
|
146
|
+
}),
|
|
147
|
+
"",
|
|
148
|
+
]
|
|
149
|
+
continuation_lines = [
|
|
150
|
+
_sse_line("response.completed", {"agent_run_id": "run_1", "content": "Done."}),
|
|
151
|
+
"",
|
|
152
|
+
]
|
|
153
|
+
client._next_continuation_lines = continuation_lines
|
|
154
|
+
|
|
155
|
+
run, _ = self._run_with_events(first_stream_lines, client=client)
|
|
156
|
+
events = list(run.stream())
|
|
157
|
+
|
|
158
|
+
assert [e["event"] for e in events] == ["agent.status", "response.completed"]
|
|
159
|
+
# Never surfaced tool.execution_requested to the caller.
|
|
160
|
+
assert not any(e["event"] == "tool.execution_requested" for e in events)
|
|
161
|
+
# Submitted exactly the local function's real return value.
|
|
162
|
+
assert client.submitted["json"]["results"] == [
|
|
163
|
+
{"tool_call_id": "call_1", "output": {"content": "contents of /tmp/x"}, "error": None},
|
|
164
|
+
]
|
|
165
|
+
assert client.submitted["path"].endswith("/runs/run_1/submit-tool-results")
|
|
166
|
+
|
|
167
|
+
def test_batch_split_across_buffered_and_live_events_is_unified(self):
|
|
168
|
+
"""Regression: the first draft ran _drain(buffered) then separately
|
|
169
|
+
_drain(events_iter), which would try to read the dead stream a
|
|
170
|
+
second time once a batch executed mid-buffer. itertools.chain fixes
|
|
171
|
+
this — this test exercises a batch_size=2 split across both sources."""
|
|
172
|
+
client = _FakeClient()
|
|
173
|
+
|
|
174
|
+
@client.tools.local(name="tool_a")
|
|
175
|
+
def tool_a() -> str:
|
|
176
|
+
return "a"
|
|
177
|
+
|
|
178
|
+
@client.tools.local(name="tool_b")
|
|
179
|
+
def tool_b() -> str:
|
|
180
|
+
return "b"
|
|
181
|
+
|
|
182
|
+
# Only ONE of the two batch events is on the initial (buffered) stream;
|
|
183
|
+
# Run._from_stream's own buffering (Phase 5) stops once agent_run_id
|
|
184
|
+
# resolves, so the second call ends up in _events_iter, not buffered.
|
|
185
|
+
lines = [
|
|
186
|
+
_sse_line("tool.execution_requested", {
|
|
187
|
+
"agent_run_id": "run_1", "tool": "tool_a", "tool_call_id": "call_a", "input": {}, "batch_size": 2,
|
|
188
|
+
}),
|
|
189
|
+
"",
|
|
190
|
+
_sse_line("tool.execution_requested", {
|
|
191
|
+
"agent_run_id": "run_1", "tool": "tool_b", "tool_call_id": "call_b", "input": {}, "batch_size": 2,
|
|
192
|
+
}),
|
|
193
|
+
"",
|
|
194
|
+
]
|
|
195
|
+
client._next_continuation_lines = [
|
|
196
|
+
_sse_line("response.completed", {"agent_run_id": "run_1", "content": "Done."}), "",
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
run, _ = self._run_with_events(lines, client=client)
|
|
200
|
+
# Simulate the split: force exactly one event into "buffered" and
|
|
201
|
+
# leave the rest for the live iterator, matching what _from_stream's
|
|
202
|
+
# early-break-on-agent_run_id actually produces in practice.
|
|
203
|
+
assert len(run._buffered_events) == 1
|
|
204
|
+
events = list(run.stream())
|
|
205
|
+
|
|
206
|
+
assert [e["event"] for e in events] == ["response.completed"]
|
|
207
|
+
submitted_ids = {r["tool_call_id"] for r in client.submitted["json"]["results"]}
|
|
208
|
+
assert submitted_ids == {"call_a", "call_b"}
|
|
209
|
+
|
|
210
|
+
def test_unregistered_client_tool_submits_a_clear_error_instead_of_raising(self):
|
|
211
|
+
client = _FakeClient() # no local tools registered at all
|
|
212
|
+
lines = [
|
|
213
|
+
_sse_line("tool.execution_requested", {
|
|
214
|
+
"agent_run_id": "run_1", "tool": "mystery_tool", "tool_call_id": "call_1", "input": {}, "batch_size": 1,
|
|
215
|
+
}),
|
|
216
|
+
"",
|
|
217
|
+
]
|
|
218
|
+
client._next_continuation_lines = [
|
|
219
|
+
_sse_line("response.completed", {"agent_run_id": "run_1", "content": "Done."}), "",
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
run, _ = self._run_with_events(lines, client=client)
|
|
223
|
+
events = list(run.stream()) # must not raise
|
|
224
|
+
|
|
225
|
+
assert [e["event"] for e in events] == ["response.completed"]
|
|
226
|
+
result = client.submitted["json"]["results"][0]
|
|
227
|
+
assert result["output"] is None
|
|
228
|
+
assert "mystery_tool" in result["error"]
|
|
229
|
+
|
|
230
|
+
def test_approval_required_is_not_auto_handled(self):
|
|
231
|
+
client = _FakeClient()
|
|
232
|
+
lines = [
|
|
233
|
+
_sse_line("approval.required", {"agent_run_id": "run_1", "tool": "send_slack", "tool_call_id": "call_1"}),
|
|
234
|
+
"",
|
|
235
|
+
]
|
|
236
|
+
run, _ = self._run_with_events(lines, client=client)
|
|
237
|
+
events = list(run.stream())
|
|
238
|
+
assert [e["event"] for e in events] == ["approval.required"]
|
|
239
|
+
assert client.submitted is None # nothing auto-submitted
|