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/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()