terrarium-python 0.1.1__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.
- terrarium/__init__.py +86 -0
- terrarium/cli.py +178 -0
- terrarium/client.py +893 -0
- terrarium/errors.py +66 -0
- terrarium/memory.py +171 -0
- terrarium/messages.py +137 -0
- terrarium/options.py +212 -0
- terrarium/py.typed +0 -0
- terrarium_python-0.1.1.dist-info/METADATA +397 -0
- terrarium_python-0.1.1.dist-info/RECORD +13 -0
- terrarium_python-0.1.1.dist-info/WHEEL +4 -0
- terrarium_python-0.1.1.dist-info/entry_points.txt +2 -0
- terrarium_python-0.1.1.dist-info/licenses/LICENSE +202 -0
terrarium/client.py
ADDED
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
"""Async client for the terrarium orchestrator API.
|
|
2
|
+
|
|
3
|
+
The SDK is **async-only**, mirroring the Claude Agent SDK (``async with``, ``async for``,
|
|
4
|
+
awaitable ``can_use_tool``). The synchronous CLI bridges to it with ``asyncio.run`` at its
|
|
5
|
+
own entry point.
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from terrarium import TerrariumClient, TerrariumOptions
|
|
9
|
+
|
|
10
|
+
async def main():
|
|
11
|
+
async with TerrariumClient("http://127.0.0.1:8900") as client:
|
|
12
|
+
async with client.session(options=TerrariumOptions(model="sonnet")) as s:
|
|
13
|
+
async for msg in s.receive_response("What is 12 * 9?"):
|
|
14
|
+
print(msg)
|
|
15
|
+
|
|
16
|
+
asyncio.run(main())
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import inspect
|
|
23
|
+
import json
|
|
24
|
+
from typing import Any, AsyncIterator, Callable, Literal
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
from .errors import NotFoundError, TerrariumError, TransportError, from_status, is_transient
|
|
29
|
+
from .messages import AssistantMessage, Message, parse_message
|
|
30
|
+
from .options import (
|
|
31
|
+
_HARNESS_FIELDS, CanUseTool, PermissionResultAllow, PermissionResultDeny,
|
|
32
|
+
TerrariumOptions, ToolPermissionContext,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
from . import __version__
|
|
36
|
+
|
|
37
|
+
# Closed enums — typed so a caller typo ("acceptedits", "allowed") is caught by the type
|
|
38
|
+
# checker at the call site instead of failing server-side (or silently mis-applying).
|
|
39
|
+
Decision = Literal["allow", "always", "deny"]
|
|
40
|
+
PermissionMode = Literal["default", "acceptEdits", "plan", "bypassPermissions"]
|
|
41
|
+
RewindMode = Literal["files", "conversation", "both"]
|
|
42
|
+
|
|
43
|
+
_DEFAULT_TIMEOUT = 30.0 # a hung orchestrator must not hang the caller forever
|
|
44
|
+
_MAX_RETRIES = 3 # transient (connection / 429 / 5xx) retries with backoff
|
|
45
|
+
_USER_AGENT = f"terrarium/{__version__}"
|
|
46
|
+
# Wire-protocol the SDK speaks. Sent on every request; the orchestrator echoes its own
|
|
47
|
+
# on each response (``X-Terrarium-Protocol``). A mismatch is surfaced once as a warning
|
|
48
|
+
# rather than a cryptic KeyError downstream — see ``_Http._check_protocol``.
|
|
49
|
+
PROTOCOL_VERSION = 1
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _backoff(attempt: int) -> float:
|
|
53
|
+
return min(0.25 * (2 ** attempt), 5.0)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _turn_ended(ev: dict[str, Any]) -> bool:
|
|
57
|
+
"""Does this event end the current turn? (the result, an idle status, or session end)"""
|
|
58
|
+
t = ev.get("type")
|
|
59
|
+
return t == "result" or (t == "status" and ev.get("status") == "idle") or t == "session_end"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _resolve(value: Any) -> Any:
|
|
63
|
+
"""Await ``value`` if it's awaitable, else return it — lets ``can_use_tool`` be either a
|
|
64
|
+
plain function or a coroutine function (the Claude SDK requires async; we accept both)."""
|
|
65
|
+
return await value if inspect.isawaitable(value) else value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _Http:
|
|
69
|
+
def __init__(self, base_url: str, token: str | None, timeout: float | None) -> None:
|
|
70
|
+
headers = {"User-Agent": _USER_AGENT, "X-Terrarium-Protocol": str(PROTOCOL_VERSION)}
|
|
71
|
+
if token:
|
|
72
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
73
|
+
self.c = httpx.AsyncClient(
|
|
74
|
+
base_url=base_url.rstrip("/"),
|
|
75
|
+
headers=headers,
|
|
76
|
+
timeout=_DEFAULT_TIMEOUT if timeout is None else timeout,
|
|
77
|
+
)
|
|
78
|
+
self._proto_warned = False
|
|
79
|
+
|
|
80
|
+
def _check_protocol(self, r: httpx.Response) -> None:
|
|
81
|
+
"""Warn once if the orchestrator speaks a different wire protocol — a clearer
|
|
82
|
+
signal than the KeyError/NotFoundError a shape mismatch would otherwise raise."""
|
|
83
|
+
srv = r.headers.get("X-Terrarium-Protocol")
|
|
84
|
+
if srv and srv != str(PROTOCOL_VERSION) and not self._proto_warned:
|
|
85
|
+
self._proto_warned = True
|
|
86
|
+
import warnings
|
|
87
|
+
warnings.warn(
|
|
88
|
+
f"orchestrator protocol v{srv} != SDK v{PROTOCOL_VERSION} — "
|
|
89
|
+
"upgrade the SDK or orchestrator if you hit shape errors.",
|
|
90
|
+
stacklevel=2,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
async def json(self, method: str, path: str, **kw) -> Any:
|
|
94
|
+
# Only auto-retry IDEMPOTENT methods. A transient failure on a POST (e.g. a
|
|
95
|
+
# ReadTimeout after the request reached the server but the response was lost)
|
|
96
|
+
# could otherwise spawn a duplicate session or double-deliver a prompt. GET/
|
|
97
|
+
# DELETE are safe to repeat; the streaming GET has its own resume loop.
|
|
98
|
+
idempotent = method.upper() in ("GET", "DELETE", "HEAD", "OPTIONS")
|
|
99
|
+
last: Exception | None = None
|
|
100
|
+
for attempt in range(_MAX_RETRIES + 1):
|
|
101
|
+
try:
|
|
102
|
+
r = await self.c.request(method, path, **kw)
|
|
103
|
+
self._check_protocol(r)
|
|
104
|
+
r.raise_for_status()
|
|
105
|
+
return r.json() if r.content else None
|
|
106
|
+
except (httpx.TransportError, httpx.HTTPStatusError) as e:
|
|
107
|
+
last = e
|
|
108
|
+
if attempt < _MAX_RETRIES and idempotent and is_transient(e):
|
|
109
|
+
await asyncio.sleep(_backoff(attempt))
|
|
110
|
+
continue
|
|
111
|
+
if isinstance(e, httpx.HTTPStatusError):
|
|
112
|
+
raise from_status(e) from e
|
|
113
|
+
raise TransportError(str(e)) from e
|
|
114
|
+
raise TransportError(str(last)) # unreachable, satisfies type checkers
|
|
115
|
+
|
|
116
|
+
async def raw(self, method: str, path: str, **kw) -> bytes:
|
|
117
|
+
"""Same request/retry/error mapping as :meth:`json`, but returns the body bytes —
|
|
118
|
+
for endpoints that answer with a file rather than JSON."""
|
|
119
|
+
idempotent = method.upper() in ("GET", "DELETE", "HEAD", "OPTIONS")
|
|
120
|
+
last: Exception | None = None
|
|
121
|
+
for attempt in range(_MAX_RETRIES + 1):
|
|
122
|
+
try:
|
|
123
|
+
r = await self.c.request(method, path, **kw)
|
|
124
|
+
self._check_protocol(r)
|
|
125
|
+
r.raise_for_status()
|
|
126
|
+
return r.content
|
|
127
|
+
except (httpx.TransportError, httpx.HTTPStatusError) as e:
|
|
128
|
+
last = e
|
|
129
|
+
if attempt < _MAX_RETRIES and idempotent and is_transient(e):
|
|
130
|
+
await asyncio.sleep(_backoff(attempt))
|
|
131
|
+
continue
|
|
132
|
+
if isinstance(e, httpx.HTTPStatusError):
|
|
133
|
+
raise from_status(e) from e
|
|
134
|
+
raise TransportError(str(e)) from e
|
|
135
|
+
raise TransportError(str(last)) # unreachable, satisfies type checkers
|
|
136
|
+
|
|
137
|
+
async def aclose(self) -> None:
|
|
138
|
+
await self.c.aclose()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class AgentsResource:
|
|
142
|
+
def __init__(self, http: _Http) -> None:
|
|
143
|
+
self._h = http
|
|
144
|
+
|
|
145
|
+
async def create(self, name: str, *, memory_scope: str | None = None,
|
|
146
|
+
template: str | None = None, **harness: Any) -> dict[str, Any]:
|
|
147
|
+
body: dict[str, Any] = {"name": name}
|
|
148
|
+
if memory_scope:
|
|
149
|
+
body["memory_scope"] = memory_scope
|
|
150
|
+
if template:
|
|
151
|
+
body["template"] = template
|
|
152
|
+
body.update(_harness_body(harness))
|
|
153
|
+
return await self._h.json("POST", "/v1/agents", json=body)
|
|
154
|
+
|
|
155
|
+
async def list(self) -> list[dict[str, Any]]:
|
|
156
|
+
return (await self._h.json("GET", "/v1/agents"))["agents"]
|
|
157
|
+
|
|
158
|
+
async def get(self, agent_id: str) -> dict[str, Any]:
|
|
159
|
+
return await self._h.json("GET", f"/v1/agents/{agent_id}")
|
|
160
|
+
|
|
161
|
+
async def spend(self, agent_id: str) -> dict[str, Any]:
|
|
162
|
+
"""Cumulative budget ledger for the agent — total spend across ALL its sessions
|
|
163
|
+
(all_time + last_24h + last_30d), each ``{sessions, total_cost_usd}``. Poll this to
|
|
164
|
+
enforce a cumulative cap, beyond the per-session ``max_budget_usd``."""
|
|
165
|
+
return await self._h.json("GET", f"/v1/agents/{agent_id}/spend")
|
|
166
|
+
|
|
167
|
+
async def update(self, agent_id: str, **fields: Any) -> dict[str, Any]:
|
|
168
|
+
return await self._h.json("PATCH", f"/v1/agents/{agent_id}", json=fields)
|
|
169
|
+
|
|
170
|
+
async def delete(self, agent_id: str, *, purge_memory: bool = False) -> dict[str, Any]:
|
|
171
|
+
return await self._h.json("DELETE", f"/v1/agents/{agent_id}", params={"purge_memory": purge_memory})
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class SessionsResource:
|
|
175
|
+
def __init__(self, http: _Http) -> None:
|
|
176
|
+
self._h = http
|
|
177
|
+
|
|
178
|
+
async def create(self, *, agent_id: str | None = None, title: str | None = None,
|
|
179
|
+
memory_scope: str | None = None, **harness: Any) -> dict[str, Any]:
|
|
180
|
+
body: dict[str, Any] = {}
|
|
181
|
+
if agent_id:
|
|
182
|
+
body["agent_id"] = agent_id
|
|
183
|
+
if title:
|
|
184
|
+
body["title"] = title
|
|
185
|
+
if memory_scope:
|
|
186
|
+
body["memory_scope"] = memory_scope # isolate/share the per-session memory volume
|
|
187
|
+
body.update(_harness_body(harness))
|
|
188
|
+
return await self._h.json("POST", "/v1/sessions", json=body)
|
|
189
|
+
|
|
190
|
+
async def get(self, session_id: str) -> dict[str, Any]:
|
|
191
|
+
return await self._h.json("GET", f"/v1/sessions/{session_id}")
|
|
192
|
+
|
|
193
|
+
async def list(self) -> list[dict[str, Any]]:
|
|
194
|
+
"""Every session, newest first.
|
|
195
|
+
|
|
196
|
+
The endpoint is paged (sessions accumulate for the life of the deployment), but
|
|
197
|
+
that is an implementation detail here: this follows the cursor to the end so the
|
|
198
|
+
return value is what it has always been. Use :meth:`list_page` to page yourself.
|
|
199
|
+
"""
|
|
200
|
+
out: list[dict[str, Any]] = []
|
|
201
|
+
cursor: str | None = None
|
|
202
|
+
while True:
|
|
203
|
+
page = await self.list_page(limit=500, before=cursor)
|
|
204
|
+
out.extend(page["sessions"])
|
|
205
|
+
cursor = page.get("next_cursor")
|
|
206
|
+
if not cursor:
|
|
207
|
+
return out
|
|
208
|
+
|
|
209
|
+
async def list_page(self, limit: int = 100, before: str | None = None) -> dict[str, Any]:
|
|
210
|
+
"""One page of sessions: ``{sessions, next_cursor, total, running}``.
|
|
211
|
+
|
|
212
|
+
Pass a page's ``next_cursor`` back as ``before`` to continue. ``total``/``running``
|
|
213
|
+
count the whole fleet, not the page."""
|
|
214
|
+
params: dict[str, Any] = {"limit": limit}
|
|
215
|
+
if before:
|
|
216
|
+
params["before"] = before
|
|
217
|
+
return await self._h.json("GET", "/v1/sessions", params=params)
|
|
218
|
+
|
|
219
|
+
async def delete(self, session_id: str) -> None:
|
|
220
|
+
await self._h.json("DELETE", f"/v1/sessions/{session_id}")
|
|
221
|
+
|
|
222
|
+
async def send(self, session_id: str, content: "str | list[dict[str, Any]]") -> None:
|
|
223
|
+
# text → {"text": ...}; a list of Anthropic content blocks (incl. image) → {"content": ...}
|
|
224
|
+
body = {"content": content} if isinstance(content, list) else {"text": content}
|
|
225
|
+
await self._h.json("POST", f"/v1/sessions/{session_id}/messages", json=body)
|
|
226
|
+
|
|
227
|
+
async def interrupt(self, session_id: str) -> None:
|
|
228
|
+
await self._h.json("POST", f"/v1/sessions/{session_id}/interrupt")
|
|
229
|
+
|
|
230
|
+
async def answer(self, session_id: str, question_id: str, answers: dict[str, Any]) -> None:
|
|
231
|
+
"""Answer a pending AskUserQuestion (a ``question`` event in the stream). ``answers``
|
|
232
|
+
maps each question's text to the chosen option label, a list of labels (multi-select),
|
|
233
|
+
or free text. Build it from the event's ``questions`` array."""
|
|
234
|
+
await self._h.json("POST", f"/v1/sessions/{session_id}/answer",
|
|
235
|
+
json={"question_id": question_id, "answers": answers})
|
|
236
|
+
|
|
237
|
+
async def decide(self, session_id: str, request_id: str, decision: Decision) -> None:
|
|
238
|
+
"""Approve/deny a pending tool-permission request (a ``permission`` event).
|
|
239
|
+
``decision``: "allow" (once) | "always" (allow + remember this session) | "deny"."""
|
|
240
|
+
await self._h.json("POST", f"/v1/sessions/{session_id}/permission",
|
|
241
|
+
json={"request_id": request_id, "decision": decision})
|
|
242
|
+
|
|
243
|
+
async def client_tool_result(self, session_id: str, call_id: str,
|
|
244
|
+
content: "str | list[dict[str, Any]]", is_error: bool = False) -> None:
|
|
245
|
+
"""Return the result of a client-bridged tool call (a ``client_tool_call`` event) so the
|
|
246
|
+
blocked agent can continue. ``content`` is a string OR a list of Anthropic content blocks
|
|
247
|
+
(so a tool can return an image: ``[{"type":"image","source":{...}}]``). Normally handled
|
|
248
|
+
for you by ``tools=`` on the session."""
|
|
249
|
+
await self._h.json("POST", f"/v1/sessions/{session_id}/tool_result",
|
|
250
|
+
json={"call_id": call_id, "content": content, "is_error": is_error})
|
|
251
|
+
|
|
252
|
+
async def set_model(self, session_id: str, model: str) -> None:
|
|
253
|
+
"""Switch a running session's model live (the conversation continues; the next turn
|
|
254
|
+
re-reads context uncached → input-cache penalty). No restart."""
|
|
255
|
+
await self._h.json("POST", f"/v1/sessions/{session_id}/config", json={"model": model})
|
|
256
|
+
|
|
257
|
+
async def set_permission_mode(self, session_id: str, mode: PermissionMode) -> None:
|
|
258
|
+
"""Switch a running session's permission mode live (default | acceptEdits | plan |
|
|
259
|
+
bypassPermissions). Mirrors the Claude Agent SDK's ``set_permission_mode``."""
|
|
260
|
+
await self._h.json("POST", f"/v1/sessions/{session_id}/config", json={"permission_mode": mode})
|
|
261
|
+
|
|
262
|
+
async def verify_egress(self, session_id: str) -> dict[str, Any]:
|
|
263
|
+
"""Recompute Warden's tamper-evident egress-audit hash chain for this
|
|
264
|
+
session; returns {ok, checked, first_break_seq, gap_before_seq, reason}."""
|
|
265
|
+
return await self._h.json("GET", f"/v1/sessions/{session_id}/egress/verify")
|
|
266
|
+
|
|
267
|
+
async def rewind(self, session_id: str, message_id: str, mode: RewindMode = "files") -> None:
|
|
268
|
+
"""Rewind a live session to the turn anchored by `message_id` (the uuid carried
|
|
269
|
+
by a `rewind_point` event). mode: "files" restores the workspace, "conversation"
|
|
270
|
+
truncates the transcript + resumes, "both" does both."""
|
|
271
|
+
await self._h.json("POST", f"/v1/sessions/{session_id}/rewind",
|
|
272
|
+
json={"message_id": message_id, "mode": mode})
|
|
273
|
+
|
|
274
|
+
async def upload_file(self, session_id: str, path: str, dest: str | None = None) -> dict[str, Any]:
|
|
275
|
+
"""Upload a local file into the live session's /workspace. Returns {name, size}."""
|
|
276
|
+
import os
|
|
277
|
+
name = dest or os.path.basename(path)
|
|
278
|
+
with open(path, "rb") as fh:
|
|
279
|
+
content = fh.read()
|
|
280
|
+
return await self._h.json("POST", f"/v1/sessions/{session_id}/files/upload",
|
|
281
|
+
files={"file": (name, content)}, data={"name": name})
|
|
282
|
+
|
|
283
|
+
async def download_file(self, session_id: str, name: str, dest: str | None = None) -> bytes:
|
|
284
|
+
"""Read a file back out of the session's /workspace and return its bytes.
|
|
285
|
+
|
|
286
|
+
The counterpart to :meth:`upload_file`, and how you collect what an agent
|
|
287
|
+
produced. ``dest`` also writes the bytes to that local path.
|
|
288
|
+
|
|
289
|
+
Names are restricted to ``[A-Za-z0-9._-]`` with no path separators, symlinks are
|
|
290
|
+
refused, and the file must be under 25 MiB — the sandbox is untrusted, so both the
|
|
291
|
+
name and its target are attacker-chosen.
|
|
292
|
+
"""
|
|
293
|
+
from urllib.parse import quote
|
|
294
|
+
|
|
295
|
+
data = await self._h.raw("GET", f"/v1/sessions/{session_id}/files/{quote(name, safe='')}")
|
|
296
|
+
if dest:
|
|
297
|
+
with open(dest, "wb") as fh:
|
|
298
|
+
fh.write(data)
|
|
299
|
+
return data
|
|
300
|
+
|
|
301
|
+
async def stream(self, session_id: str, after: int = -1) -> AsyncIterator[dict[str, Any]]:
|
|
302
|
+
"""Stream session events, resuming transparently across drops.
|
|
303
|
+
|
|
304
|
+
SSE has no native replay, but the orchestrator log does (``after=seq``).
|
|
305
|
+
On a connection drop, an overflow resync, or a clean server close without
|
|
306
|
+
``session_end``, this reconnects from the last seq seen and dedupes — so a
|
|
307
|
+
proxy idle-timeout mid-turn no longer silently truncates the turn. Returns
|
|
308
|
+
on ``session_end``; raises a typed error on auth/not-found or after the
|
|
309
|
+
reconnect budget is exhausted.
|
|
310
|
+
"""
|
|
311
|
+
last = after
|
|
312
|
+
failures = 0
|
|
313
|
+
path = f"/v1/sessions/{session_id}/events"
|
|
314
|
+
while True:
|
|
315
|
+
progressed = False # did THIS connection deliver a new event?
|
|
316
|
+
try:
|
|
317
|
+
# no read timeout on the stream itself (long-lived); resync handles stalls
|
|
318
|
+
async with self._h.c.stream("GET", path, params={"after": last},
|
|
319
|
+
timeout=httpx.Timeout(self._h.c.timeout.connect, read=None)) as r:
|
|
320
|
+
r.raise_for_status()
|
|
321
|
+
async for line in r.aiter_lines():
|
|
322
|
+
if not line.startswith("data: "):
|
|
323
|
+
continue
|
|
324
|
+
ev = json.loads(line[6:])
|
|
325
|
+
if ev.get("type") == "_overflow":
|
|
326
|
+
break # fell behind → reconnect with after=last and replay
|
|
327
|
+
seq = ev.get("seq")
|
|
328
|
+
if isinstance(seq, int):
|
|
329
|
+
if seq <= last:
|
|
330
|
+
continue # dedupe across the reconnect boundary
|
|
331
|
+
last = seq
|
|
332
|
+
progressed = True
|
|
333
|
+
failures = 0
|
|
334
|
+
yield ev
|
|
335
|
+
if ev.get("type") == "session_end":
|
|
336
|
+
return
|
|
337
|
+
# Server closed without session_end. If it replayed nothing new, treat
|
|
338
|
+
# it like a transient drop and count it toward the budget — otherwise a
|
|
339
|
+
# server that keeps cleanly closing an ended-but-unterminated stream
|
|
340
|
+
# would spin us in a tight ~0.25s reconnect loop forever.
|
|
341
|
+
if not progressed:
|
|
342
|
+
failures += 1
|
|
343
|
+
if failures > _MAX_RETRIES:
|
|
344
|
+
raise TransportError(
|
|
345
|
+
f"event stream for {session_id} closed {failures} times "
|
|
346
|
+
"without new events or session_end"
|
|
347
|
+
)
|
|
348
|
+
except httpx.HTTPStatusError as e:
|
|
349
|
+
if e.response.status_code in (401, 403, 404):
|
|
350
|
+
raise from_status(e) from e # terminal: don't reconnect
|
|
351
|
+
failures += 1
|
|
352
|
+
if failures > _MAX_RETRIES:
|
|
353
|
+
raise from_status(e) from e
|
|
354
|
+
except httpx.TransportError as e:
|
|
355
|
+
failures += 1
|
|
356
|
+
if failures > _MAX_RETRIES:
|
|
357
|
+
raise TransportError(str(e)) from e
|
|
358
|
+
await asyncio.sleep(_backoff(failures))
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class SchedulesResource:
|
|
362
|
+
def __init__(self, http: _Http) -> None:
|
|
363
|
+
self._h = http
|
|
364
|
+
|
|
365
|
+
async def create(self, *, name: str, agent_id: str, prompt: str, cron: str,
|
|
366
|
+
enabled: bool = True, max_budget_usd: float | None = None) -> dict[str, Any]:
|
|
367
|
+
return await self._h.json("POST", "/v1/schedules", json={
|
|
368
|
+
"name": name, "agent_id": agent_id, "prompt": prompt, "cron": cron,
|
|
369
|
+
"enabled": enabled, "max_budget_usd": max_budget_usd,
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
async def list(self) -> list[dict[str, Any]]:
|
|
373
|
+
return (await self._h.json("GET", "/v1/schedules"))["schedules"]
|
|
374
|
+
|
|
375
|
+
async def update(self, schedule_id: str, **fields: Any) -> dict[str, Any]:
|
|
376
|
+
return await self._h.json("PATCH", f"/v1/schedules/{schedule_id}", json=fields)
|
|
377
|
+
|
|
378
|
+
async def delete(self, schedule_id: str) -> None:
|
|
379
|
+
await self._h.json("DELETE", f"/v1/schedules/{schedule_id}")
|
|
380
|
+
|
|
381
|
+
async def run(self, schedule_id: str) -> dict[str, Any]:
|
|
382
|
+
return await self._h.json("POST", f"/v1/schedules/{schedule_id}/run")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class TokensResource:
|
|
386
|
+
def __init__(self, http: _Http) -> None:
|
|
387
|
+
self._h = http
|
|
388
|
+
|
|
389
|
+
async def create(self, name: str, scopes: list[str] | tuple[str, ...] = ("run",)) -> dict[str, Any]:
|
|
390
|
+
return await self._h.json("POST", "/v1/tokens", json={"name": name, "scopes": list(scopes)})
|
|
391
|
+
|
|
392
|
+
async def list(self) -> list[dict[str, Any]]:
|
|
393
|
+
return (await self._h.json("GET", "/v1/tokens"))["tokens"]
|
|
394
|
+
|
|
395
|
+
async def delete(self, token_id: str) -> None:
|
|
396
|
+
await self._h.json("DELETE", f"/v1/tokens/{token_id}")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class EgressProfilesResource:
|
|
400
|
+
"""Named firewall-rule bundles. Applied to an agent by attaching an ENVIRONMENT that
|
|
401
|
+
references the profile (``client.environments``) — there is no direct per-agent pin.
|
|
402
|
+
|
|
403
|
+
A profile is a list of ``rules`` — each ``{"action", "dest", "ports", "enabled", "note"}``
|
|
404
|
+
where action is ``allow`` / ``deny`` / ``inspect``, dest is a domain, IP, or CIDR, and
|
|
405
|
+
``ports`` (allow/inspect) lifts Warden's default 80/443 wall for that destination — plus
|
|
406
|
+
optional ``hosts`` overrides (``{"host", "ip"}``) that resolve an internal name to a fixed
|
|
407
|
+
address, bypassing DNS (for a name only your internal DNS knows)."""
|
|
408
|
+
|
|
409
|
+
def __init__(self, http: _Http) -> None:
|
|
410
|
+
self._h = http
|
|
411
|
+
|
|
412
|
+
async def list(self) -> list[dict[str, Any]]:
|
|
413
|
+
return (await self._h.json("GET", "/v1/egress/profiles"))["profiles"]
|
|
414
|
+
|
|
415
|
+
async def presets(self) -> list[dict[str, Any]]:
|
|
416
|
+
"""The built-in egress presets (developer / python / node / data-science /
|
|
417
|
+
anthropic-only / web-audit). Each carries a ``key`` usable with :meth:`create`."""
|
|
418
|
+
return (await self._h.json("GET", "/v1/egress/presets"))["presets"]
|
|
419
|
+
|
|
420
|
+
async def create(self, *, name: str | None = None, preset: str | None = None, mode: str = "enforce",
|
|
421
|
+
rules: list[dict[str, Any]] | None = None,
|
|
422
|
+
hosts: list[dict[str, str]] | None = None) -> dict[str, Any]:
|
|
423
|
+
"""Create a profile. Pass ``preset`` (e.g. "developer") to instantiate a built-in
|
|
424
|
+
bundle, optionally with a custom ``name``; otherwise pass ``name`` + ``rules`` (and
|
|
425
|
+
optional ``hosts`` overrides). See the class docstring for the rule/host shapes, e.g.::
|
|
426
|
+
|
|
427
|
+
rules=[{"action": "allow", "dest": "git.internal", "ports": [443]}],
|
|
428
|
+
hosts=[{"host": "git.internal", "ip": "10.1.20.50"}]
|
|
429
|
+
"""
|
|
430
|
+
if preset:
|
|
431
|
+
return await self._h.json("POST", "/v1/egress/profiles", json={"preset": preset, "name": name})
|
|
432
|
+
if not name:
|
|
433
|
+
raise ValueError("create() needs either a preset or a name")
|
|
434
|
+
return await self._h.json("POST", "/v1/egress/profiles", json={
|
|
435
|
+
"name": name, "mode": mode, "rules": rules or [], "hosts": hosts or []})
|
|
436
|
+
|
|
437
|
+
async def update(self, profile_id: str, **fields: Any) -> dict[str, Any]:
|
|
438
|
+
"""Patch ``name`` / ``mode`` / ``rules`` / ``hosts`` (unset fields are left unchanged)."""
|
|
439
|
+
return await self._h.json("PATCH", f"/v1/egress/profiles/{profile_id}", json=fields)
|
|
440
|
+
|
|
441
|
+
async def delete(self, profile_id: str) -> None:
|
|
442
|
+
await self._h.json("DELETE", f"/v1/egress/profiles/{profile_id}")
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
class SecretsResource:
|
|
446
|
+
"""Operator injection secrets — Warden injects the templated value into a header on
|
|
447
|
+
every request to a scoped host, so the value lives only in the vault + Warden, never in
|
|
448
|
+
the sandbox. Group them into environments (see :class:`EnvironmentsResource`) to scope
|
|
449
|
+
which agents receive which secrets. Admin scope required."""
|
|
450
|
+
|
|
451
|
+
def __init__(self, http: _Http) -> None:
|
|
452
|
+
self._h = http
|
|
453
|
+
|
|
454
|
+
async def list(self) -> list[dict[str, Any]]:
|
|
455
|
+
"""Metadata only — values are never returned (they leave only via Warden)."""
|
|
456
|
+
return (await self._h.json("GET", "/v1/secrets"))["secrets"]
|
|
457
|
+
|
|
458
|
+
async def put(self, name: str, *, scopes: list[str], value: str | None = None,
|
|
459
|
+
header: str = "Authorization", template: str = "Bearer {value}",
|
|
460
|
+
enabled: bool = True) -> dict[str, Any]:
|
|
461
|
+
"""Create or edit by name. ``value`` is required to create, optional to edit (keeps
|
|
462
|
+
the stored one). ``scopes`` are the hosts the secret is injected on; ``template``
|
|
463
|
+
must contain ``{value}``."""
|
|
464
|
+
return await self._h.json("POST", "/v1/secrets", json={
|
|
465
|
+
"name": name, "scopes": scopes, "value": value, "header": header,
|
|
466
|
+
"template": template, "enabled": enabled})
|
|
467
|
+
|
|
468
|
+
async def delete(self, name: str) -> None:
|
|
469
|
+
await self._h.json("DELETE", f"/v1/secrets/{name}")
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
class EnvironmentsResource:
|
|
473
|
+
"""Named bundles of {secrets, egress profile} an agent attaches to via harness
|
|
474
|
+
``environments`` for least-privilege scoping. An agent with no environments receives no
|
|
475
|
+
operator secrets; attached environments grant the union of their named secrets.
|
|
476
|
+
Admin scope required."""
|
|
477
|
+
|
|
478
|
+
def __init__(self, http: _Http) -> None:
|
|
479
|
+
self._h = http
|
|
480
|
+
|
|
481
|
+
async def list(self) -> list[dict[str, Any]]:
|
|
482
|
+
return (await self._h.json("GET", "/v1/environments"))["environments"]
|
|
483
|
+
|
|
484
|
+
async def create(self, *, name: str, secrets: list[str] | None = None,
|
|
485
|
+
egress_profile: str | None = None, description: str = "") -> dict[str, Any]:
|
|
486
|
+
return await self._h.json("POST", "/v1/environments", json={
|
|
487
|
+
"name": name, "description": description,
|
|
488
|
+
"secrets": secrets or [], "egress_profile": egress_profile})
|
|
489
|
+
|
|
490
|
+
async def update(self, environment_id: str, **fields: Any) -> dict[str, Any]:
|
|
491
|
+
return await self._h.json("PATCH", f"/v1/environments/{environment_id}", json=fields)
|
|
492
|
+
|
|
493
|
+
async def delete(self, environment_id: str) -> None:
|
|
494
|
+
await self._h.json("DELETE", f"/v1/environments/{environment_id}")
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
class Session:
|
|
498
|
+
"""An async handle to one session — an ``async with`` context manager (connects on enter).
|
|
499
|
+
By default the session is *ephemeral* and deleted on exit; pass ``ephemeral=False`` (or use
|
|
500
|
+
:meth:`TerrariumClient.attach`) to leave it running server-side for a later reattach — the
|
|
501
|
+
durable, long-running pattern. The session is created lazily on ``connect`` from the stored
|
|
502
|
+
args (or bound to an existing id via :meth:`TerrariumClient.attach`)."""
|
|
503
|
+
|
|
504
|
+
def __init__(self, client: "TerrariumClient", *, create_kw: dict[str, Any] | None = None,
|
|
505
|
+
session_id: str | None = None, agent_id: str | None = None, tools=None,
|
|
506
|
+
ephemeral: bool = True, resume: bool = False) -> None:
|
|
507
|
+
self._client = client
|
|
508
|
+
self._create_kw = create_kw or {}
|
|
509
|
+
self.id = session_id
|
|
510
|
+
self.agent_id = agent_id
|
|
511
|
+
self._ephemeral = ephemeral # delete on __aexit__? False = persist for client.attach(id)
|
|
512
|
+
self._resume = resume # attach(): seed the cursor instead of replaying history
|
|
513
|
+
self._last_seq = -1
|
|
514
|
+
# client-bridged tools (name -> ClientTool): their handlers run HERE when the agent
|
|
515
|
+
# calls them (see _iter_turn). The schemas already travelled to the worker via create_kw.
|
|
516
|
+
self._tools = {t.name: t for t in (tools or [])}
|
|
517
|
+
|
|
518
|
+
async def connect(self) -> "Session":
|
|
519
|
+
"""Create the session (if not bound to one already) and drain until the worker is
|
|
520
|
+
ready. Called automatically by ``async with``.
|
|
521
|
+
|
|
522
|
+
Raises :class:`TerrariumError` if the session ends before it becomes ready (e.g. an
|
|
523
|
+
invalid harness) instead of returning a dead session — a later ``receive_response``
|
|
524
|
+
would otherwise send into a worker that is already gone."""
|
|
525
|
+
if self.id is None:
|
|
526
|
+
created = await self._client.sessions.create(**self._create_kw)
|
|
527
|
+
self.id = created["id"]
|
|
528
|
+
self.agent_id = created.get("agent_id")
|
|
529
|
+
elif self._resume and self._last_seq < 0:
|
|
530
|
+
# Reattach: seed the cursor from the orchestrator's durable resume point instead of
|
|
531
|
+
# streaming from seq 0. Draining from the start would stop at the session's ORIGINAL
|
|
532
|
+
# `ready` and leave every later event to be replayed by the next turn — re-running
|
|
533
|
+
# completed client-tool handlers (real side effects, in this process) and posting
|
|
534
|
+
# stale results back. The cursor is registry/log-derived, so this survives an
|
|
535
|
+
# orchestrator restart.
|
|
536
|
+
summary = await self._client.sessions.get(self.id)
|
|
537
|
+
if summary.get("status") == "terminated":
|
|
538
|
+
raise TerrariumError(
|
|
539
|
+
f"session {self.id} is terminated — attach cannot drive new turns. "
|
|
540
|
+
"Use sessions.events()/stream() to read its log, or create a new session.")
|
|
541
|
+
self.agent_id = self.agent_id or summary.get("agent_id")
|
|
542
|
+
cursor = summary.get("resume_cursor")
|
|
543
|
+
# Clamp: a cursor past the tail can only mean the log was truncated behind us, and
|
|
544
|
+
# a missing key means an older orchestrator. Both fall back to -1 (full replay) —
|
|
545
|
+
# the safe direction, since over-seeking would silently swallow live events.
|
|
546
|
+
if isinstance(cursor, int) and cursor >= 0:
|
|
547
|
+
self._last_seq = cursor
|
|
548
|
+
return self # already past `ready`; nothing to drain
|
|
549
|
+
# cursor -1/absent → never reached a turn boundary (still starting): drain normally.
|
|
550
|
+
last_error: str | None = None
|
|
551
|
+
async for ev in self._client.sessions.stream(self.id, after=self._last_seq):
|
|
552
|
+
if isinstance(ev.get("seq"), int): # transient events (e.g. assistant_delta) carry no seq
|
|
553
|
+
self._last_seq = ev["seq"]
|
|
554
|
+
etype = ev.get("type")
|
|
555
|
+
if etype == "ready":
|
|
556
|
+
return self
|
|
557
|
+
if etype == "error":
|
|
558
|
+
# An error during startup MAY be non-fatal (e.g. "client tools disabled" still
|
|
559
|
+
# reaches ready), so don't fail yet — remember it and keep draining. If the session
|
|
560
|
+
# actually ends before ready we surface this; if ready follows, it was benign.
|
|
561
|
+
last_error = ev.get("message") or ev.get("error") or last_error
|
|
562
|
+
elif etype == "session_end":
|
|
563
|
+
reason = last_error or ev.get("reason") or "session ended before it became ready"
|
|
564
|
+
raise TerrariumError(f"session {self.id} failed to start: {reason}")
|
|
565
|
+
# Stream closed without ready or session_end (orchestrator dropped the SSE early).
|
|
566
|
+
raise TerrariumError(
|
|
567
|
+
f"session {self.id} failed to start: {last_error or 'stream ended before ready'}")
|
|
568
|
+
|
|
569
|
+
async def _iter_turn(
|
|
570
|
+
self,
|
|
571
|
+
*,
|
|
572
|
+
can_use_tool: "CanUseTool | None" = None,
|
|
573
|
+
on_question: "Callable[[dict[str, Any]], dict[str, Any] | None] | None" = None,
|
|
574
|
+
on_permission: "Callable[[dict[str, Any]], str | None] | None" = None,
|
|
575
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
576
|
+
"""Yield this turn's raw events until it completes, auto-handling human-in-the-loop
|
|
577
|
+
prompts via a Claude-SDK-style ``can_use_tool`` (preferred) or the older
|
|
578
|
+
``on_question`` / ``on_permission`` callbacks. ``can_use_tool`` may be sync or async."""
|
|
579
|
+
started = False # has THIS turn begun? A reused session's previous turn emits a trailing
|
|
580
|
+
# 'status: idle' just AFTER its result (higher seq), which can lead this
|
|
581
|
+
# turn's after= window — _turn_ended would then end turn 2 before it starts.
|
|
582
|
+
async for ev in self._client.sessions.stream(self.id, after=self._last_seq):
|
|
583
|
+
t = ev["type"]
|
|
584
|
+
# Transport control sentinels (_heartbeat keepalive, _overflow) carry no turn
|
|
585
|
+
# semantics — drop them so raw ask() consumers don't see them (receive_response
|
|
586
|
+
# already ignores them via parse_message → None).
|
|
587
|
+
if t.startswith("_"):
|
|
588
|
+
continue
|
|
589
|
+
if not started and t == "status" and ev.get("status") == "idle":
|
|
590
|
+
if isinstance(ev.get("seq"), int): # stale leading idle: advance the cursor and skip
|
|
591
|
+
self._last_seq = ev["seq"]
|
|
592
|
+
continue
|
|
593
|
+
if t in ("user", "assistant_text", "assistant_delta", "thinking", "tool_use") or \
|
|
594
|
+
(t == "status" and ev.get("status") in ("running", "requesting")):
|
|
595
|
+
started = True
|
|
596
|
+
if isinstance(ev.get("seq"), int): # transient events (e.g. assistant_delta) carry no seq
|
|
597
|
+
self._last_seq = ev["seq"]
|
|
598
|
+
yield ev
|
|
599
|
+
if t == "question":
|
|
600
|
+
answers = None
|
|
601
|
+
if can_use_tool is not None:
|
|
602
|
+
ctx = ToolPermissionContext(title=ev.get("title"), description=ev.get("description"), raw=ev)
|
|
603
|
+
res = await _resolve(can_use_tool("AskUserQuestion", {"questions": ev.get("questions") or []}, ctx))
|
|
604
|
+
if isinstance(res, PermissionResultAllow) and res.updated_input:
|
|
605
|
+
answers = res.updated_input.get("answers")
|
|
606
|
+
elif on_question is not None:
|
|
607
|
+
answers = on_question(ev)
|
|
608
|
+
if answers:
|
|
609
|
+
await self._client.sessions.answer(self.id, ev.get("question_id", ""), answers)
|
|
610
|
+
elif t == "permission":
|
|
611
|
+
decision = None
|
|
612
|
+
if can_use_tool is not None:
|
|
613
|
+
ctx = ToolPermissionContext(request_id=str(ev.get("request_id", "")), title=ev.get("title"), description=ev.get("description"), raw=ev)
|
|
614
|
+
res = await _resolve(can_use_tool(str(ev.get("tool_name", "")), ev.get("input") or {}, ctx))
|
|
615
|
+
if isinstance(res, PermissionResultAllow):
|
|
616
|
+
decision = "always" if res.always else "allow"
|
|
617
|
+
elif isinstance(res, PermissionResultDeny):
|
|
618
|
+
decision = "deny"
|
|
619
|
+
elif on_permission is not None:
|
|
620
|
+
decision = on_permission(ev)
|
|
621
|
+
if decision:
|
|
622
|
+
await self._client.sessions.decide(self.id, ev.get("request_id", ""), decision)
|
|
623
|
+
elif t == "client_tool_call":
|
|
624
|
+
# The agent invoked a tool whose handler lives HERE. Run it in this process
|
|
625
|
+
# (with the dev's app context), then hand the result back to the sandbox.
|
|
626
|
+
ctool = self._tools.get(str(ev.get("name", "")))
|
|
627
|
+
content, is_error = "", False
|
|
628
|
+
if ctool is None:
|
|
629
|
+
content, is_error = f"No client tool named {ev.get('name')!r} is registered.", True
|
|
630
|
+
else:
|
|
631
|
+
try:
|
|
632
|
+
res = await _resolve(ctool.handler(ev.get("input") or {}))
|
|
633
|
+
if isinstance(res, dict):
|
|
634
|
+
raw, is_error = res.get("content", ""), bool(res.get("is_error"))
|
|
635
|
+
# keep a list of Anthropic content blocks (text/image) intact so a tool
|
|
636
|
+
# can return a screenshot; a bare value collapses to a string.
|
|
637
|
+
content = raw if isinstance(raw, list) else str(raw)
|
|
638
|
+
elif isinstance(res, list):
|
|
639
|
+
content = res
|
|
640
|
+
else:
|
|
641
|
+
content = "" if res is None else str(res)
|
|
642
|
+
except Exception as exc: # noqa: BLE001 — surface the dev's error to the agent, don't wedge
|
|
643
|
+
content, is_error = f"client tool error: {exc}", True
|
|
644
|
+
await self._client.sessions.client_tool_result(self.id, str(ev.get("call_id", "")), content, is_error)
|
|
645
|
+
if _turn_ended(ev):
|
|
646
|
+
return
|
|
647
|
+
|
|
648
|
+
async def ask(
|
|
649
|
+
self,
|
|
650
|
+
text: "str | list[dict[str, Any]]",
|
|
651
|
+
on_question: "Callable[[dict[str, Any]], dict[str, Any] | None] | None" = None,
|
|
652
|
+
on_permission: "Callable[[dict[str, Any]], str | None] | None" = None,
|
|
653
|
+
*,
|
|
654
|
+
can_use_tool: "CanUseTool | None" = None,
|
|
655
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
656
|
+
"""Send a message and yield this turn's raw events until it completes. ``text`` may be a
|
|
657
|
+
string OR a list of Anthropic content blocks (text + image) for a vision turn. Prefer
|
|
658
|
+
``can_use_tool`` (Claude-SDK-style) for questions/permissions; ``on_question`` /
|
|
659
|
+
``on_permission`` remain for backward compatibility."""
|
|
660
|
+
await self._client.sessions.send(self.id, text)
|
|
661
|
+
async for ev in self._iter_turn(can_use_tool=can_use_tool, on_question=on_question, on_permission=on_permission):
|
|
662
|
+
yield ev
|
|
663
|
+
|
|
664
|
+
async def query(self, text: "str | list[dict[str, Any]]") -> None:
|
|
665
|
+
"""Send a message (no wait). ``text`` may be a string or a list of Anthropic content
|
|
666
|
+
blocks (incl. image). Mirrors ``ClaudeSDKClient.query``; iterate ``receive_response()``."""
|
|
667
|
+
await self._client.sessions.send(self.id, text)
|
|
668
|
+
|
|
669
|
+
async def receive_response(
|
|
670
|
+
self,
|
|
671
|
+
prompt: "str | list[dict[str, Any]] | None" = None,
|
|
672
|
+
*,
|
|
673
|
+
can_use_tool: "CanUseTool | None" = None,
|
|
674
|
+
) -> AsyncIterator[Message]:
|
|
675
|
+
"""Yield this turn's typed messages (AssistantMessage / ToolUse / ResultMessage / …),
|
|
676
|
+
the Claude-Agent-SDK shape. Optionally send ``prompt`` first. Pass ``can_use_tool``
|
|
677
|
+
to auto-answer AskUserQuestion / permission prompts.
|
|
678
|
+
|
|
679
|
+
A turn's consecutive assistant blocks (text, thinking, tool_use) are **coalesced into
|
|
680
|
+
one multi-block ``AssistantMessage``** — matching the Claude Agent SDK — and flushed
|
|
681
|
+
before any tool_result/result/system message and at turn end. (The coalesced message's
|
|
682
|
+
``raw`` is empty; iterate :meth:`ask` if you need the underlying per-event stream.)"""
|
|
683
|
+
if prompt is not None:
|
|
684
|
+
await self.query(prompt)
|
|
685
|
+
pending: list[Any] = [] # assistant blocks accumulating into the current message
|
|
686
|
+
pending_model: str | None = None # the responding model, carried onto the coalesced message
|
|
687
|
+
async for ev in self._iter_turn(can_use_tool=can_use_tool):
|
|
688
|
+
msg = parse_message(ev)
|
|
689
|
+
if msg is None:
|
|
690
|
+
continue
|
|
691
|
+
if isinstance(msg, AssistantMessage):
|
|
692
|
+
pending.extend(msg.content)
|
|
693
|
+
pending_model = pending_model or msg.model
|
|
694
|
+
continue
|
|
695
|
+
if pending: # an assistant message closes when a non-assistant message arrives
|
|
696
|
+
yield AssistantMessage(content=pending, model=pending_model)
|
|
697
|
+
pending, pending_model = [], None
|
|
698
|
+
yield msg
|
|
699
|
+
if pending:
|
|
700
|
+
yield AssistantMessage(content=pending, model=pending_model)
|
|
701
|
+
|
|
702
|
+
async def run(
|
|
703
|
+
self,
|
|
704
|
+
text: str,
|
|
705
|
+
on_question: "Callable[[dict[str, Any]], dict[str, Any] | None] | None" = None,
|
|
706
|
+
on_permission: "Callable[[dict[str, Any]], str | None] | None" = None,
|
|
707
|
+
*,
|
|
708
|
+
can_use_tool: "CanUseTool | None" = None,
|
|
709
|
+
) -> dict[str, Any]:
|
|
710
|
+
"""Send a message and return the collected reply (text + cost + events). Pass
|
|
711
|
+
``can_use_tool`` (or ``on_question`` / ``on_permission``) to auto-handle prompts."""
|
|
712
|
+
events = [e async for e in self.ask(text, on_question=on_question, on_permission=on_permission, can_use_tool=can_use_tool)]
|
|
713
|
+
texts = [e["text"] for e in events if e["type"] == "assistant_text"]
|
|
714
|
+
result = next((e for e in reversed(events) if e["type"] == "result"), {})
|
|
715
|
+
return {
|
|
716
|
+
"text": "\n".join(texts),
|
|
717
|
+
"cost_usd": result.get("total_cost_usd"),
|
|
718
|
+
"events": events,
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async def interrupt(self) -> None:
|
|
722
|
+
await self._client.sessions.interrupt(self.id)
|
|
723
|
+
|
|
724
|
+
async def answer(self, question_id: str, answers: dict[str, Any]) -> None:
|
|
725
|
+
"""Answer a pending AskUserQuestion seen in the stream."""
|
|
726
|
+
await self._client.sessions.answer(self.id, question_id, answers)
|
|
727
|
+
|
|
728
|
+
async def decide(self, request_id: str, decision: Decision) -> None:
|
|
729
|
+
"""Approve/deny a pending tool-permission request ("allow" | "always" | "deny")."""
|
|
730
|
+
await self._client.sessions.decide(self.id, request_id, decision)
|
|
731
|
+
|
|
732
|
+
async def set_model(self, model: str) -> None:
|
|
733
|
+
"""Switch this running session's model live (continues the conversation)."""
|
|
734
|
+
await self._client.sessions.set_model(self.id, model)
|
|
735
|
+
|
|
736
|
+
async def set_permission_mode(self, mode: PermissionMode) -> None:
|
|
737
|
+
"""Switch this running session's permission mode live (default | acceptEdits | plan |
|
|
738
|
+
bypassPermissions). Mirrors ``ClaudeSDKClient.set_permission_mode``."""
|
|
739
|
+
await self._client.sessions.set_permission_mode(self.id, mode)
|
|
740
|
+
|
|
741
|
+
async def rewind(self, message_id: str, mode: RewindMode = "files") -> None:
|
|
742
|
+
"""Rewind this session to the turn anchored by ``message_id`` (files | conversation | both)."""
|
|
743
|
+
await self._client.sessions.rewind(self.id, message_id, mode)
|
|
744
|
+
|
|
745
|
+
async def upload_file(self, path: str, dest: str | None = None) -> dict[str, Any]:
|
|
746
|
+
"""Upload a local file into this session's /workspace. Returns {name, size}."""
|
|
747
|
+
return await self._client.sessions.upload_file(self.id, path, dest)
|
|
748
|
+
|
|
749
|
+
async def verify_egress(self) -> dict[str, Any]:
|
|
750
|
+
"""Recompute this session's tamper-evident egress-audit hash chain."""
|
|
751
|
+
return await self._client.sessions.verify_egress(self.id)
|
|
752
|
+
|
|
753
|
+
async def summary(self) -> dict[str, Any]:
|
|
754
|
+
return await self._client.sessions.get(self.id)
|
|
755
|
+
|
|
756
|
+
async def close(self) -> None:
|
|
757
|
+
if self.id is None:
|
|
758
|
+
return
|
|
759
|
+
try:
|
|
760
|
+
await self._client.sessions.delete(self.id)
|
|
761
|
+
except NotFoundError:
|
|
762
|
+
pass # already gone — fine; any other failure (auth/server) surfaces
|
|
763
|
+
|
|
764
|
+
async def __aenter__(self) -> "Session":
|
|
765
|
+
return await self.connect()
|
|
766
|
+
|
|
767
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
768
|
+
if self._ephemeral:
|
|
769
|
+
await self.close()
|
|
770
|
+
# else: leave it running server-side — reattach later with client.attach(self.id)
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
class TerrariumClient:
|
|
774
|
+
def __init__(
|
|
775
|
+
self,
|
|
776
|
+
base_url: str = "http://127.0.0.1:8900",
|
|
777
|
+
token: str | None = None,
|
|
778
|
+
timeout: float | None = None,
|
|
779
|
+
) -> None:
|
|
780
|
+
self._http = _Http(base_url, token, timeout)
|
|
781
|
+
self.agents = AgentsResource(self._http)
|
|
782
|
+
self.sessions = SessionsResource(self._http)
|
|
783
|
+
self.schedules = SchedulesResource(self._http)
|
|
784
|
+
self.tokens = TokensResource(self._http)
|
|
785
|
+
self.egress_profiles = EgressProfilesResource(self._http)
|
|
786
|
+
self.secrets = SecretsResource(self._http)
|
|
787
|
+
self.environments = EnvironmentsResource(self._http)
|
|
788
|
+
|
|
789
|
+
async def health(self) -> dict[str, Any]:
|
|
790
|
+
return await self._http.json("GET", "/healthz")
|
|
791
|
+
|
|
792
|
+
async def fleet(self) -> dict[str, Any]:
|
|
793
|
+
return await self._http.json("GET", "/v1/fleet")
|
|
794
|
+
|
|
795
|
+
async def templates(self) -> list[dict[str, Any]]:
|
|
796
|
+
return (await self._http.json("GET", "/v1/templates"))["templates"]
|
|
797
|
+
|
|
798
|
+
def session(self, *, options: "TerrariumOptions | None" = None, agent_id: str | None = None,
|
|
799
|
+
title: str | None = None, memory_scope: str | None = None,
|
|
800
|
+
ephemeral: bool = True, **harness: Any) -> Session:
|
|
801
|
+
"""Build a session handle (no I/O yet). ``async with`` it to create + connect.
|
|
802
|
+
Pass a ``TerrariumOptions`` and/or loose harness kwargs; explicit ``agent_id`` /
|
|
803
|
+
``title`` / ``memory_scope`` override the ones on ``options``. ``ephemeral=False``
|
|
804
|
+
keeps the session alive on exit so you can ``client.attach(id)`` it later."""
|
|
805
|
+
if options is not None:
|
|
806
|
+
harness = {**options.to_harness(), **harness}
|
|
807
|
+
agent_id = agent_id or options.agent_id
|
|
808
|
+
title = title or options.title
|
|
809
|
+
memory_scope = memory_scope or options.memory_scope
|
|
810
|
+
create_kw = {"agent_id": agent_id, "title": title, "memory_scope": memory_scope, **harness}
|
|
811
|
+
return Session(self, create_kw=create_kw, tools=(options.tools if options else None),
|
|
812
|
+
ephemeral=ephemeral)
|
|
813
|
+
|
|
814
|
+
def attach(self, session_id: str, *, tools=None, replay: bool = False) -> Session:
|
|
815
|
+
"""Build a handle to an EXISTING session id (``async with`` to drain to ready). Never
|
|
816
|
+
deletes on exit (it's not ours to discard). Pass ``tools`` to re-register client-tool
|
|
817
|
+
handlers for a session whose schemas were sent at creation.
|
|
818
|
+
|
|
819
|
+
Resumes by default: the handle seeds its stream cursor from the session's durable
|
|
820
|
+
resume point, so the next turn yields only NEW events and already-completed
|
|
821
|
+
client-tool calls are not re-executed. This is what you want for reconnecting to a
|
|
822
|
+
live session after your own process restarts.
|
|
823
|
+
|
|
824
|
+
``replay=True`` restores the older full-replay behavior — stream from the beginning
|
|
825
|
+
of the log — for consumers that want to rebuild state from the whole history. Note
|
|
826
|
+
that a replayed ``client_tool_call`` WILL re-run its handler, so only use it with
|
|
827
|
+
idempotent tools (or none registered).
|
|
828
|
+
"""
|
|
829
|
+
return Session(self, session_id=session_id, tools=tools, ephemeral=False,
|
|
830
|
+
resume=not replay)
|
|
831
|
+
|
|
832
|
+
async def aclose(self) -> None:
|
|
833
|
+
await self._http.aclose()
|
|
834
|
+
|
|
835
|
+
async def __aenter__(self) -> "TerrariumClient":
|
|
836
|
+
return self
|
|
837
|
+
|
|
838
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
839
|
+
await self.aclose()
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
# Harness fields accepted by the create/session **kwargs helpers — one source of truth,
|
|
843
|
+
# shared with options.TerrariumOptions (which mirrors terrarium/harness.py).
|
|
844
|
+
# client_tools is a harness KEY (emitted by TerrariumOptions.to_harness from `tools=`) but
|
|
845
|
+
# has no TerrariumOptions attribute of that name, so it isn't in _HARNESS_FIELDS — add it here.
|
|
846
|
+
_HARNESS_KEYS = frozenset(_HARNESS_FIELDS) | {"client_tools"}
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _harness_body(harness: dict[str, Any]) -> dict[str, Any]:
|
|
850
|
+
"""Validate **harness kwargs against the known fields, raising on a typo rather than
|
|
851
|
+
silently dropping it — a typo like ``mdoel=`` must fail, not quietly no-op."""
|
|
852
|
+
unknown = set(harness) - _HARNESS_KEYS
|
|
853
|
+
if unknown:
|
|
854
|
+
raise TypeError(
|
|
855
|
+
f"unknown harness option(s): {', '.join(sorted(unknown))}. "
|
|
856
|
+
f"Valid: {', '.join(sorted(_HARNESS_KEYS))}"
|
|
857
|
+
)
|
|
858
|
+
return dict(harness)
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
async def query(
|
|
862
|
+
*,
|
|
863
|
+
prompt: str,
|
|
864
|
+
options: "TerrariumOptions | None" = None,
|
|
865
|
+
base_url: str = "http://127.0.0.1:8900",
|
|
866
|
+
token: str | None = None,
|
|
867
|
+
client: "TerrariumClient | None" = None,
|
|
868
|
+
keep_session: bool = False,
|
|
869
|
+
) -> AsyncIterator[Message]:
|
|
870
|
+
"""One-shot helper mirroring ``claude_agent_sdk.query``: open an ephemeral Terrarium
|
|
871
|
+
session from ``options``, send ``prompt``, and yield the turn's typed messages.
|
|
872
|
+
|
|
873
|
+
from terrarium import query, TerrariumOptions
|
|
874
|
+
|
|
875
|
+
async for msg in query(prompt="Summarise the repo", options=TerrariumOptions(model="opus")):
|
|
876
|
+
print(msg)
|
|
877
|
+
|
|
878
|
+
The session is created fresh (or attached to ``options.agent_id``) and deleted on exit
|
|
879
|
+
unless ``keep_session=True``. Pass an existing ``client`` to reuse a connection/token.
|
|
880
|
+
"""
|
|
881
|
+
opts = options or TerrariumOptions()
|
|
882
|
+
own_client = client is None
|
|
883
|
+
c = client or TerrariumClient(base_url=base_url, token=token)
|
|
884
|
+
sess = c.session(options=opts)
|
|
885
|
+
try:
|
|
886
|
+
await sess.connect()
|
|
887
|
+
async for msg in sess.receive_response(prompt, can_use_tool=opts.can_use_tool):
|
|
888
|
+
yield msg
|
|
889
|
+
finally:
|
|
890
|
+
if not keep_session:
|
|
891
|
+
await sess.close()
|
|
892
|
+
if own_client:
|
|
893
|
+
await c.aclose()
|