zonrad-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zonrad/__init__.py +4 -0
- zonrad/_http.py +23 -0
- zonrad/_schema.py +62 -0
- zonrad/_sse.py +18 -0
- zonrad/client.py +32 -0
- zonrad/exceptions.py +34 -0
- zonrad/resources/__init__.py +0 -0
- zonrad/resources/agents.py +20 -0
- zonrad/resources/api_keys.py +15 -0
- zonrad/resources/connections.py +6 -0
- zonrad/resources/models.py +6 -0
- zonrad/resources/policies.py +9 -0
- zonrad/resources/runs.py +125 -0
- zonrad/resources/sessions.py +49 -0
- zonrad/resources/tools.py +63 -0
- zonrad_sdk-0.1.0.dist-info/METADATA +217 -0
- zonrad_sdk-0.1.0.dist-info/RECORD +19 -0
- zonrad_sdk-0.1.0.dist-info/WHEEL +5 -0
- zonrad_sdk-0.1.0.dist-info/top_level.txt +1 -0
zonrad/__init__.py
ADDED
zonrad/_http.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
|
|
3
|
+
from .exceptions import ZonradAPIError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HTTPClient:
|
|
7
|
+
def __init__(self, base_url: str, api_key: str, timeout: float = 30):
|
|
8
|
+
self.base_url = base_url.rstrip("/")
|
|
9
|
+
self.timeout = timeout
|
|
10
|
+
self.session = requests.Session()
|
|
11
|
+
self.session.headers["Authorization"] = f"Bearer {api_key}"
|
|
12
|
+
|
|
13
|
+
def request(self, method: str, path: str, **kwargs):
|
|
14
|
+
resp = self.session.request(method, f"{self.base_url}{path}", timeout=self.timeout, **kwargs)
|
|
15
|
+
if not resp.ok:
|
|
16
|
+
raise ZonradAPIError.from_response(resp)
|
|
17
|
+
return resp.json() if resp.content else None
|
|
18
|
+
|
|
19
|
+
def stream(self, method: str, path: str, **kwargs):
|
|
20
|
+
resp = self.session.request(method, f"{self.base_url}{path}", stream=True, timeout=None, **kwargs)
|
|
21
|
+
if not resp.ok:
|
|
22
|
+
raise ZonradAPIError.from_response(resp)
|
|
23
|
+
return resp
|
zonrad/_schema.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ported from apps.custom_agents.tool_registry's build_schema_from_function /
|
|
3
|
+
_python_type_to_json_schema (backend_django) — same conversion rules, so a
|
|
4
|
+
function looks identical to the model whether registered server-side via
|
|
5
|
+
@agent_tool_registry.register_tool(...) or client-side via @z.tools.local(...).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import inspect
|
|
9
|
+
import typing
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ToolSchemaError(Exception):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _python_type_to_json_schema(annotation):
|
|
17
|
+
origin = typing.get_origin(annotation)
|
|
18
|
+
|
|
19
|
+
if origin is typing.Union:
|
|
20
|
+
args = [a for a in typing.get_args(annotation) if a is not type(None)]
|
|
21
|
+
if len(args) == 1:
|
|
22
|
+
return _python_type_to_json_schema(args[0]) # Optional[X]
|
|
23
|
+
raise ToolSchemaError(f"Unsupported Union type: {annotation!r}. Only Optional[X] is supported.")
|
|
24
|
+
|
|
25
|
+
if origin in (list, typing.List):
|
|
26
|
+
item_args = typing.get_args(annotation)
|
|
27
|
+
item_schema = _python_type_to_json_schema(item_args[0]) if item_args else {"type": "string"}
|
|
28
|
+
return {"type": "array", "items": item_schema}
|
|
29
|
+
|
|
30
|
+
if origin in (dict, typing.Dict) or annotation is dict:
|
|
31
|
+
return {"type": "object"}
|
|
32
|
+
|
|
33
|
+
simple = {str: "string", int: "integer", float: "number", bool: "boolean"}
|
|
34
|
+
if annotation in simple:
|
|
35
|
+
return {"type": simple[annotation]}
|
|
36
|
+
if annotation is list:
|
|
37
|
+
return {"type": "array", "items": {"type": "string"}}
|
|
38
|
+
|
|
39
|
+
raise ToolSchemaError(f"Unsupported parameter type: {annotation!r}. Supports str, int, float, bool, list, dict, and Optional[...] of these.")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _is_optional(annotation):
|
|
43
|
+
return typing.get_origin(annotation) is typing.Union and type(None) in typing.get_args(annotation)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_schema_from_function(fn):
|
|
47
|
+
"""Derives an OpenAI-compliant JSON schema from a function's type hints."""
|
|
48
|
+
sig = inspect.signature(fn)
|
|
49
|
+
properties = {}
|
|
50
|
+
required = []
|
|
51
|
+
for name, param in sig.parameters.items():
|
|
52
|
+
if param.annotation is inspect.Parameter.empty:
|
|
53
|
+
raise ToolSchemaError(
|
|
54
|
+
f"Tool function '{fn.__name__}' parameter '{name}' has no type hint — "
|
|
55
|
+
f"all parameters must be annotated for schema derivation."
|
|
56
|
+
)
|
|
57
|
+
schema = _python_type_to_json_schema(param.annotation)
|
|
58
|
+
schema["description"] = f"Parameter '{name}'"
|
|
59
|
+
properties[name] = schema
|
|
60
|
+
if param.default is inspect.Parameter.empty and not _is_optional(param.annotation):
|
|
61
|
+
required.append(name)
|
|
62
|
+
return {"type": "object", "properties": properties, "required": required}
|
zonrad/_sse.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def iter_sse_events(response):
|
|
5
|
+
"""Parses a streaming requests.Response as SSE.
|
|
6
|
+
|
|
7
|
+
Matches the server's exact wire format (apps.custom_agents.runtime.sse_format
|
|
8
|
+
in backend_django): one single-line `data: {...}` per event, with a blank
|
|
9
|
+
line between events — the server never emits multi-line SSE data blocks,
|
|
10
|
+
so this doesn't need a general-purpose SSE parser, just line splitting.
|
|
11
|
+
"""
|
|
12
|
+
for raw_line in response.iter_lines(decode_unicode=True):
|
|
13
|
+
if not raw_line or not raw_line.startswith("data: "):
|
|
14
|
+
continue
|
|
15
|
+
try:
|
|
16
|
+
yield json.loads(raw_line[len("data: "):])
|
|
17
|
+
except json.JSONDecodeError:
|
|
18
|
+
continue
|
zonrad/client.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from ._http import HTTPClient
|
|
2
|
+
from .resources.agents import AgentsResource
|
|
3
|
+
from .resources.api_keys import ApiKeysResource
|
|
4
|
+
from .resources.connections import ConnectionsResource
|
|
5
|
+
from .resources.models import ModelsResource
|
|
6
|
+
from .resources.policies import PoliciesResource
|
|
7
|
+
from .resources.runs import RunsResource
|
|
8
|
+
from .resources.sessions import SessionsResource
|
|
9
|
+
from .resources.tools import ToolkitsResource, ToolsResource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Zonrad:
|
|
13
|
+
"""
|
|
14
|
+
workspace_id is captured once here, not passed per-call — a
|
|
15
|
+
PlatformAPIKey is already scoped to exactly one workspace server-side
|
|
16
|
+
(docs/PLATFORM_PHASE_5_SPEC.md §4.3), so requiring it again on every
|
|
17
|
+
resource call would be redundant. Every key-minting response
|
|
18
|
+
(z.api_keys.create()) includes the workspace_id to pair with it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, api_key: str, workspace_id, base_url: str = "http://localhost:8000/platform/v1"):
|
|
22
|
+
self._http = HTTPClient(base_url, api_key)
|
|
23
|
+
self.workspace_id = workspace_id
|
|
24
|
+
self.agents = AgentsResource(self)
|
|
25
|
+
self.sessions = SessionsResource(self)
|
|
26
|
+
self.runs = RunsResource(self)
|
|
27
|
+
self.tools = ToolsResource(self)
|
|
28
|
+
self.toolkits = ToolkitsResource(self)
|
|
29
|
+
self.connections = ConnectionsResource(self)
|
|
30
|
+
self.models = ModelsResource(self)
|
|
31
|
+
self.policies = PoliciesResource(self)
|
|
32
|
+
self.api_keys = ApiKeysResource(self)
|
zonrad/exceptions.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
class ZonradError(Exception):
|
|
2
|
+
"""Base class for all errors raised by this SDK."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ZonradAPIError(ZonradError):
|
|
6
|
+
"""Raised for any non-2xx response from the Zonrad platform API."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, status_code, message, error_code=None, body=None):
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
self.error_code = error_code
|
|
11
|
+
self.body = body
|
|
12
|
+
super().__init__(f"[{status_code}] {message}")
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def from_response(cls, resp):
|
|
16
|
+
try:
|
|
17
|
+
body = resp.json()
|
|
18
|
+
except ValueError:
|
|
19
|
+
body = {"message": resp.text}
|
|
20
|
+
message = body.get("message") or body.get("error") or "Request failed."
|
|
21
|
+
error_code = body.get("error_code")
|
|
22
|
+
return cls(resp.status_code, message, error_code=error_code, body=body)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ZonradToolNotRegisteredError(ZonradError):
|
|
26
|
+
"""Raised when the server asks the SDK to execute a client tool
|
|
27
|
+
(docs/PLATFORM_PHASE_6_SPEC.md) that has no @z.tools.local(...) handler
|
|
28
|
+
registered in this process."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, name):
|
|
31
|
+
self.name = name
|
|
32
|
+
super().__init__(
|
|
33
|
+
f"Server requested client tool '{name}', but no @z.tools.local(...) handler is registered for it."
|
|
34
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
class AgentsResource:
|
|
2
|
+
"""Plain dict in, plain dict out — CRUD has no behavior worth a wrapper object."""
|
|
3
|
+
|
|
4
|
+
def __init__(self, client):
|
|
5
|
+
self._c = client
|
|
6
|
+
|
|
7
|
+
def list(self):
|
|
8
|
+
return self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/agents")
|
|
9
|
+
|
|
10
|
+
def create(self, **fields):
|
|
11
|
+
return self._c._http.request("POST", f"/workspaces/{self._c.workspace_id}/agents", json=fields)
|
|
12
|
+
|
|
13
|
+
def get(self, agent_id):
|
|
14
|
+
return self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/agents/{agent_id}")
|
|
15
|
+
|
|
16
|
+
def update(self, agent_id, **fields):
|
|
17
|
+
return self._c._http.request("PATCH", f"/workspaces/{self._c.workspace_id}/agents/{agent_id}", json=fields)
|
|
18
|
+
|
|
19
|
+
def delete(self, agent_id):
|
|
20
|
+
return self._c._http.request("DELETE", f"/workspaces/{self._c.workspace_id}/agents/{agent_id}")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class ApiKeysResource:
|
|
2
|
+
"""Note: the `key` field in create()'s response is shown exactly once —
|
|
3
|
+
it is never retrievable again after this call returns."""
|
|
4
|
+
|
|
5
|
+
def __init__(self, client):
|
|
6
|
+
self._c = client
|
|
7
|
+
|
|
8
|
+
def list(self):
|
|
9
|
+
return self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/api-keys")
|
|
10
|
+
|
|
11
|
+
def create(self, name: str = ""):
|
|
12
|
+
return self._c._http.request("POST", f"/workspaces/{self._c.workspace_id}/api-keys", json={"name": name})
|
|
13
|
+
|
|
14
|
+
def revoke(self, key_id):
|
|
15
|
+
return self._c._http.request("DELETE", f"/workspaces/{self._c.workspace_id}/api-keys/{key_id}")
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
class PoliciesResource:
|
|
2
|
+
def __init__(self, client):
|
|
3
|
+
self._c = client
|
|
4
|
+
|
|
5
|
+
def get(self):
|
|
6
|
+
return self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/policies")
|
|
7
|
+
|
|
8
|
+
def update(self, **fields):
|
|
9
|
+
return self._c._http.request("PATCH", f"/workspaces/{self._c.workspace_id}/policies", json=fields)
|
zonrad/resources/runs.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
|
|
3
|
+
from .._sse import iter_sse_events
|
|
4
|
+
|
|
5
|
+
# Safety cap: if a stream never carries an agent_run_id (a malformed or
|
|
6
|
+
# very early error case), stop eagerly buffering after this many events
|
|
7
|
+
# rather than draining a whole broken stream into memory.
|
|
8
|
+
_MAX_BUFFER_WHILE_RESOLVING_RUN_ID = 5
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Run:
|
|
12
|
+
"""
|
|
13
|
+
Wraps one agent execution's SSE stream. For a brand-new conversation,
|
|
14
|
+
CustomAgentChatView emits `conversation.assigned` first (conversation_id,
|
|
15
|
+
no agent_run_id yet), then the runtime's own first event carries
|
|
16
|
+
agent_run_id — so resolving the run's id may take up to two events. For
|
|
17
|
+
an existing conversation, the first event already carries agent_run_id.
|
|
18
|
+
Buffered events are re-yielded, in order, the first time stream() runs.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, client, id, session=None):
|
|
22
|
+
self._c = client
|
|
23
|
+
self.id = id
|
|
24
|
+
self.session = session
|
|
25
|
+
self._buffered_events = []
|
|
26
|
+
self._events_iter = None
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def _from_stream(cls, client, response, session=None):
|
|
30
|
+
events_iter = iter_sse_events(response)
|
|
31
|
+
buffered = []
|
|
32
|
+
run_id = None
|
|
33
|
+
conversation_id = None
|
|
34
|
+
|
|
35
|
+
for event in events_iter:
|
|
36
|
+
buffered.append(event)
|
|
37
|
+
data = event.get("data") or {}
|
|
38
|
+
run_id = data.get("agent_run_id") or run_id
|
|
39
|
+
conversation_id = data.get("conversation_id") or conversation_id
|
|
40
|
+
if run_id is not None or len(buffered) >= _MAX_BUFFER_WHILE_RESOLVING_RUN_ID:
|
|
41
|
+
break
|
|
42
|
+
|
|
43
|
+
run = cls(client, id=run_id, session=session)
|
|
44
|
+
run._buffered_events = buffered
|
|
45
|
+
run._events_iter = events_iter
|
|
46
|
+
|
|
47
|
+
if session is not None and session.id is None and conversation_id is not None:
|
|
48
|
+
session.id = conversation_id # lazy Session resolves its id from the first stream
|
|
49
|
+
|
|
50
|
+
return run
|
|
51
|
+
|
|
52
|
+
def stream(self):
|
|
53
|
+
"""
|
|
54
|
+
Yields events as they arrive. On `tool.execution_requested` (Phase
|
|
55
|
+
6, docs/PLATFORM_PHASE_6_SPEC.md §3.3), transparently executes the
|
|
56
|
+
matching @z.tools.local(...) handler(s), submits the results, and
|
|
57
|
+
continues yielding from the continuation stream — the caller never
|
|
58
|
+
sees the pause. `approval.required` is NOT auto-handled; that still
|
|
59
|
+
needs a human decision via run.resume(...).
|
|
60
|
+
|
|
61
|
+
buffered and self._events_iter are chained into a SINGLE generator
|
|
62
|
+
before draining — a pause resolved entirely from buffered events
|
|
63
|
+
(the very first turn) must not leave a second, separate drain of
|
|
64
|
+
self._events_iter trying to read the now-exhausted original stream.
|
|
65
|
+
"""
|
|
66
|
+
buffered, self._buffered_events = self._buffered_events, []
|
|
67
|
+
all_events = itertools.chain(buffered, self._events_iter if self._events_iter is not None else ())
|
|
68
|
+
yield from self._drain(all_events)
|
|
69
|
+
|
|
70
|
+
def _drain(self, events):
|
|
71
|
+
pending_batch = []
|
|
72
|
+
expected_batch_size = None
|
|
73
|
+
for event in events:
|
|
74
|
+
if event.get("event") == "tool.execution_requested":
|
|
75
|
+
pending_batch.append(event["data"])
|
|
76
|
+
expected_batch_size = event["data"].get("batch_size", 1)
|
|
77
|
+
if len(pending_batch) < expected_batch_size:
|
|
78
|
+
continue # wait for the rest of the batch before executing anything
|
|
79
|
+
yield from self._execute_and_continue(pending_batch)
|
|
80
|
+
return # the continuation's own stream()/_drain() has taken over completely
|
|
81
|
+
else:
|
|
82
|
+
yield event
|
|
83
|
+
|
|
84
|
+
def _execute_and_continue(self, pending_batch):
|
|
85
|
+
results = []
|
|
86
|
+
for call in pending_batch:
|
|
87
|
+
try:
|
|
88
|
+
output = self._c.tools._execute_local(call["tool"], call["input"])
|
|
89
|
+
results.append({"tool_call_id": call["tool_call_id"], "output": output, "error": None})
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
results.append({"tool_call_id": call["tool_call_id"], "output": None, "error": str(exc)})
|
|
92
|
+
|
|
93
|
+
resp = self._c._http.stream(
|
|
94
|
+
"POST", f"/workspaces/{self._c.workspace_id}/runs/{self.id}/submit-tool-results",
|
|
95
|
+
json={"results": results},
|
|
96
|
+
)
|
|
97
|
+
continuation = Run._from_stream(self._c, resp, session=self.session)
|
|
98
|
+
yield from continuation.stream()
|
|
99
|
+
|
|
100
|
+
def get(self):
|
|
101
|
+
return self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/runs/{self.id}")
|
|
102
|
+
|
|
103
|
+
def get_trace(self):
|
|
104
|
+
return self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/runs/{self.id}/trace")
|
|
105
|
+
|
|
106
|
+
def resume(self, decision: str, reason: str = ""):
|
|
107
|
+
resp = self._c._http.stream(
|
|
108
|
+
"POST", f"/workspaces/{self._c.workspace_id}/runs/{self.id}/resume",
|
|
109
|
+
json={"decision": decision, "reason": reason},
|
|
110
|
+
)
|
|
111
|
+
return Run._from_stream(self._c, resp, session=self.session)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class RunsResource:
|
|
115
|
+
def __init__(self, client):
|
|
116
|
+
self._c = client
|
|
117
|
+
|
|
118
|
+
def get(self, run_id):
|
|
119
|
+
return self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/runs/{run_id}")
|
|
120
|
+
|
|
121
|
+
def get_trace(self, run_id):
|
|
122
|
+
return self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/runs/{run_id}/trace")
|
|
123
|
+
|
|
124
|
+
def resume(self, run_id, decision: str, reason: str = ""):
|
|
125
|
+
return Run(self._c, id=run_id).resume(decision, reason)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from .runs import Run
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Session:
|
|
5
|
+
"""A Session is a Conversation (docs/PLATFORM_PHASE_5_SPEC.md decision 3).
|
|
6
|
+
`id` is None for a lazily-created session until its first run() resolves
|
|
7
|
+
a server-assigned conversation id from the stream."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, client, id, agent_id):
|
|
10
|
+
self._c = client
|
|
11
|
+
self.id = id
|
|
12
|
+
self.agent_id = agent_id
|
|
13
|
+
|
|
14
|
+
def run(self, prompt: str) -> Run:
|
|
15
|
+
payload = {"message": prompt}
|
|
16
|
+
if self.id is not None:
|
|
17
|
+
payload["conversation_id"] = self.id
|
|
18
|
+
# Phase 6 (docs/PLATFORM_PHASE_6_SPEC.md §3.2): every locally
|
|
19
|
+
# registered tool's schema rides along on this one request — never
|
|
20
|
+
# persisted server-side, only meaningful for this run.
|
|
21
|
+
local_specs = self._c.tools._local_specs()
|
|
22
|
+
if local_specs:
|
|
23
|
+
payload["client_tools"] = local_specs
|
|
24
|
+
resp = self._c._http.stream(
|
|
25
|
+
"POST", f"/workspaces/{self._c.workspace_id}/agents/{self.agent_id}/chat", json=payload,
|
|
26
|
+
)
|
|
27
|
+
return Run._from_stream(self._c, resp, session=self)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SessionsResource:
|
|
31
|
+
def __init__(self, client):
|
|
32
|
+
self._c = client
|
|
33
|
+
|
|
34
|
+
def create(self, agent_id) -> Session:
|
|
35
|
+
"""Explicit creation — POSTs to conversations immediately."""
|
|
36
|
+
data = self._c._http.request(
|
|
37
|
+
"POST", f"/workspaces/{self._c.workspace_id}/conversations", json={"agent_id": agent_id},
|
|
38
|
+
)
|
|
39
|
+
return Session(self._c, id=data["id"], agent_id=agent_id)
|
|
40
|
+
|
|
41
|
+
def new(self, agent_id) -> Session:
|
|
42
|
+
"""Lazy creation — no HTTP call yet; the first run() call creates
|
|
43
|
+
the conversation server-side and this Session captures its id from
|
|
44
|
+
the stream's conversation.assigned event."""
|
|
45
|
+
return Session(self._c, id=None, agent_id=agent_id)
|
|
46
|
+
|
|
47
|
+
def get(self, session_id) -> Session:
|
|
48
|
+
data = self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/conversations/{session_id}")
|
|
49
|
+
return Session(self._c, id=data["id"], agent_id=data.get("agent_id"))
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from .._schema import build_schema_from_function
|
|
4
|
+
from ..exceptions import ZonradToolNotRegisteredError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ToolsResource:
|
|
8
|
+
"""Read-only listing (Phase 5) plus local tool registration (Phase 6,
|
|
9
|
+
docs/PLATFORM_PHASE_6_SPEC.md §3.1) — one z.tools namespace for
|
|
10
|
+
everything tool-related."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, client):
|
|
13
|
+
self._c = client
|
|
14
|
+
self._local = {} # name -> {"spec": {...}, "fn": callable}
|
|
15
|
+
|
|
16
|
+
def list(self):
|
|
17
|
+
return self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/tools")
|
|
18
|
+
|
|
19
|
+
def local(self, name=None, description=None, parameters=None):
|
|
20
|
+
"""Registers a plain Python function as a client-side tool. Its
|
|
21
|
+
schema travels with every session.run() call this process makes —
|
|
22
|
+
it is never sent, stored, or visible anywhere else."""
|
|
23
|
+
def decorator(fn):
|
|
24
|
+
resolved_name = name or fn.__name__
|
|
25
|
+
schema = parameters or build_schema_from_function(fn)
|
|
26
|
+
spec = {
|
|
27
|
+
"name": resolved_name,
|
|
28
|
+
"description": description or (fn.__doc__ or "").strip() or f"Local tool '{resolved_name}'.",
|
|
29
|
+
"parameters": schema,
|
|
30
|
+
}
|
|
31
|
+
self._local[resolved_name] = {"spec": spec, "fn": fn}
|
|
32
|
+
return fn
|
|
33
|
+
return decorator
|
|
34
|
+
|
|
35
|
+
def _local_specs(self):
|
|
36
|
+
return [entry["spec"] for entry in self._local.values()]
|
|
37
|
+
|
|
38
|
+
def _execute_local(self, name, arguments):
|
|
39
|
+
entry = self._local.get(name)
|
|
40
|
+
if not entry:
|
|
41
|
+
raise ZonradToolNotRegisteredError(name)
|
|
42
|
+
# Defensive: the wire contract expects `arguments` already
|
|
43
|
+
# JSON-decoded, but a proxy or future server change could hand this
|
|
44
|
+
# back as a raw JSON string — **arguments would then raise TypeError
|
|
45
|
+
# instead of a clear, catchable error.
|
|
46
|
+
if isinstance(arguments, str):
|
|
47
|
+
try:
|
|
48
|
+
arguments = json.loads(arguments)
|
|
49
|
+
except (ValueError, TypeError):
|
|
50
|
+
arguments = {}
|
|
51
|
+
elif not isinstance(arguments, dict):
|
|
52
|
+
arguments = {}
|
|
53
|
+
return entry["fn"](**arguments)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ToolkitsResource:
|
|
57
|
+
"""Grouped view of tools by toolkit — distinct shape from ToolsResource."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, client):
|
|
60
|
+
self._c = client
|
|
61
|
+
|
|
62
|
+
def list(self):
|
|
63
|
+
return self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/toolkits")
|
|
@@ -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,19 @@
|
|
|
1
|
+
zonrad/__init__.py,sha256=6cOzJaRedb855ouQiYKt8R8gTpwUrWSUkMxJYYUJb1o,196
|
|
2
|
+
zonrad/_http.py,sha256=SnhKQumO7A9Av5s0rHQzuJ87MzxHFVjzJptnnUo3s38,887
|
|
3
|
+
zonrad/_schema.py,sha256=aOfjbNxLPgCBCfHMaABVIpOzvQV2v3C-rB--egprMB4,2515
|
|
4
|
+
zonrad/_sse.py,sha256=8aS6Ft2kXbd1pfvrjUe_S_R6lD_hb97FCXUZ0aIY05U,692
|
|
5
|
+
zonrad/client.py,sha256=QWRgHULlR_DprnJY6cnftYVf_QFIT5Z_OIc7O0cXscQ,1407
|
|
6
|
+
zonrad/exceptions.py,sha256=BFMPOR-U8IYQYaR7kExCMSrwhorhizsoGVt7bL81vz4,1228
|
|
7
|
+
zonrad/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
zonrad/resources/agents.py,sha256=iKMKa8pN2gLQ70UsnqEeQGADxMiqx7lhMuV8kCfyXvg,844
|
|
9
|
+
zonrad/resources/api_keys.py,sha256=VpgQYxgiTF6niaysgO70KKrcDmR2y7euFibvKchNMik,622
|
|
10
|
+
zonrad/resources/connections.py,sha256=gh_rJJRBiqS6-okdOhK59HvWS02gYWQnV1cMhN-e-Fw,215
|
|
11
|
+
zonrad/resources/models.py,sha256=LWa15gQjgpAbzzuXpZDznY7uVqDNQ0jSia4Ds2tHlbQ,190
|
|
12
|
+
zonrad/resources/policies.py,sha256=dvkfZ1KS-rLii91fdc-qJUnBtcVv6iVynk2TnYD1kSk,333
|
|
13
|
+
zonrad/resources/runs.py,sha256=3kirVDDdnArkrK2k_BbVCFd_1URrfHy0fk19oHgwyn4,5315
|
|
14
|
+
zonrad/resources/sessions.py,sha256=LgfGDNH43U6r_l3dDB9zbfKS_UtoSqZba6iXOP2A-z4,2034
|
|
15
|
+
zonrad/resources/tools.py,sha256=B5gyiPQI68zRrPzyZKDBoyI2oIgNwvsRbDeAV1ql6r0,2404
|
|
16
|
+
zonrad_sdk-0.1.0.dist-info/METADATA,sha256=0zIMqaWxkb21M9FZcegSU7nLr36kJkrzVPFnk2RmYAI,7730
|
|
17
|
+
zonrad_sdk-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
18
|
+
zonrad_sdk-0.1.0.dist-info/top_level.txt,sha256=MBtWQ16cbC8MZtWc1Xm2MI33qkJ84yWEysEk3byC898,7
|
|
19
|
+
zonrad_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
zonrad
|