a12s 0.2.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.
a12s/__init__.py ADDED
@@ -0,0 +1,64 @@
1
+ """Python client for a12s: send work to agent Computers, stream their events and Turns."""
2
+
3
+ from ._async import AsyncClient, AsyncEventStream, AsyncTurnStream
4
+ from ._client import Client, EventStream, TurnStream
5
+ from ._connect import connect, connect_async
6
+ from ._errors import A12sError, A12sGapError, A12sStreamError
7
+ from ._types import (
8
+ ComputerStatus,
9
+ DriveRow,
10
+ EnqueueResponse,
11
+ Event,
12
+ Failure,
13
+ Harness,
14
+ InboxEnded,
15
+ InboxLimits,
16
+ InboxList,
17
+ InboxSummary,
18
+ InboxUsage,
19
+ LimitValue,
20
+ Message,
21
+ PowerStatus,
22
+ PromptResult,
23
+ ServiceStatus,
24
+ Status,
25
+ Token,
26
+ ToolCall,
27
+ Turn,
28
+ TurnInboxMessage,
29
+ )
30
+
31
+ __all__ = [
32
+ "A12sError",
33
+ "A12sGapError",
34
+ "A12sStreamError",
35
+ "AsyncClient",
36
+ "AsyncEventStream",
37
+ "AsyncTurnStream",
38
+ "Client",
39
+ "ComputerStatus",
40
+ "DriveRow",
41
+ "EnqueueResponse",
42
+ "Event",
43
+ "EventStream",
44
+ "Failure",
45
+ "Harness",
46
+ "InboxEnded",
47
+ "InboxLimits",
48
+ "InboxList",
49
+ "InboxSummary",
50
+ "InboxUsage",
51
+ "LimitValue",
52
+ "Message",
53
+ "PowerStatus",
54
+ "PromptResult",
55
+ "ServiceStatus",
56
+ "Status",
57
+ "Token",
58
+ "ToolCall",
59
+ "Turn",
60
+ "TurnInboxMessage",
61
+ "TurnStream",
62
+ "connect",
63
+ "connect_async",
64
+ ]
a12s/_async.py ADDED
@@ -0,0 +1,404 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import AsyncGenerator, Awaitable, Callable, Sequence
5
+ from contextlib import aclosing
6
+ from types import TracebackType
7
+ from typing import Any, Literal, Union
8
+
9
+ import httpx
10
+
11
+ from . import _core as core
12
+ from ._errors import A12sError, A12sStreamError
13
+ from ._parse import from_json
14
+ from ._sse import SSEDecoder
15
+ from ._types import (
16
+ ComputerStatus,
17
+ EnqueueResponse,
18
+ Event,
19
+ InboxList,
20
+ Message,
21
+ PromptResult,
22
+ ServiceStatus,
23
+ Status,
24
+ Token,
25
+ Turn,
26
+ )
27
+
28
+
29
+ class AsyncClient:
30
+ """The asyncio twin of Client: the same methods, awaited."""
31
+
32
+ def __init__(self, url: str, token: str | None = None, *, http: httpx.AsyncClient | None = None) -> None:
33
+ self.url = url.rstrip("/")
34
+ self._token = token
35
+ self._owns_http = http is None
36
+ self._http = http if http is not None else httpx.AsyncClient(timeout=core.TIMEOUT)
37
+
38
+ async def aclose(self) -> None:
39
+ if self._owns_http:
40
+ await self._http.aclose()
41
+
42
+ async def __aenter__(self) -> AsyncClient:
43
+ return self
44
+
45
+ async def __aexit__(self, *exc: object) -> None:
46
+ await self.aclose()
47
+
48
+ async def _request(self, method: str, path: str, body: Any = None) -> Any:
49
+ res = await self._http.request(method, self.url + path, **core.json_request(self._token, body))
50
+ return core.decode(res)
51
+
52
+ async def status(self) -> Status:
53
+ """Every Computer and Service: phase, session, active Turn, inbox, power."""
54
+ return from_json(Status, await self._request("GET", "/v1/status"))
55
+
56
+ async def computer(self, name: str) -> ComputerStatus:
57
+ for c in (await self.status()).computers:
58
+ if c.name == name:
59
+ return c
60
+ raise A12sError(404, "not_found", f"no Computer {name}")
61
+
62
+ async def send(
63
+ self,
64
+ computer: str,
65
+ body: str,
66
+ *,
67
+ sender: str | None = None,
68
+ metadata: dict[str, str] | None = None,
69
+ fresh: bool = False,
70
+ idempotency_key: str | None = None,
71
+ expires: int | None = None,
72
+ replace_key: str | None = None,
73
+ ) -> EnqueueResponse:
74
+ """See Client.send."""
75
+ m = core.enqueue_body(body, sender, metadata, fresh, idempotency_key, expires, replace_key)
76
+ return from_json(EnqueueResponse, await self._request("POST", f"/v1/computers/{core.enc(computer)}/inbox", m))
77
+
78
+ async def prompt(
79
+ self,
80
+ computer: str,
81
+ body: str,
82
+ *,
83
+ on_event: Callable[[Event], None] | None = None,
84
+ sender: str | None = None,
85
+ metadata: dict[str, str] | None = None,
86
+ fresh: bool = False,
87
+ idempotency_key: str | None = None,
88
+ expires: int | None = None,
89
+ replace_key: str | None = None,
90
+ ) -> PromptResult:
91
+ """See Client.prompt."""
92
+ message = await self.send(
93
+ computer,
94
+ body,
95
+ sender=sender,
96
+ metadata=metadata,
97
+ fresh=fresh,
98
+ idempotency_key=idempotency_key,
99
+ expires=expires,
100
+ replace_key=replace_key,
101
+ )
102
+ turn = await self._delivered_in(computer, message.id)
103
+ builder = core.TurnBuilder()
104
+ # A gap mid-Turn would silently drop text: fail loudly instead.
105
+ state = core.StreamState(computer, None, "throw")
106
+ async with aclosing(self._stream(core.events_path(computer, turn), state, 1.0)) as events:
107
+ async for ev in events:
108
+ if on_event is not None:
109
+ on_event(ev)
110
+ done = builder.add(ev)
111
+ if done is not None and done.id == turn:
112
+ return core.prompt_result(done, message)
113
+ raise A12sStreamError(f"event stream for turn {turn} ended early")
114
+
115
+ def events(
116
+ self,
117
+ computers: str | Sequence[str] | None = None,
118
+ *,
119
+ turn: str | None = None,
120
+ after: int | None = None,
121
+ reconnect: float = 1.0,
122
+ on_gap: core.OnGap = "yield",
123
+ ) -> AsyncEventStream:
124
+ """See Client.events."""
125
+ names = core.stream_names(computers, turn, after)
126
+
127
+ def source(name: str, live: Callable[[], None]) -> AsyncGenerator[Event, None]:
128
+ state = core.StreamState(name, after, on_gap, live)
129
+ return self._stream(core.events_path(name, turn), state, reconnect)
130
+
131
+ async def all_names() -> list[str]:
132
+ return [c.name for c in (await self.status()).computers]
133
+
134
+ return AsyncEventStream(source, names if names is not None else all_names)
135
+
136
+ def turns(
137
+ self,
138
+ computers: str | Sequence[str] | None = None,
139
+ *,
140
+ reconnect: float = 1.0,
141
+ on_gap: core.OnGap = "yield",
142
+ ) -> AsyncTurnStream:
143
+ """See Client.turns."""
144
+ return AsyncTurnStream(self.events(computers, reconnect=reconnect, on_gap=on_gap))
145
+
146
+ async def inbox(
147
+ self, computer: str, *, state: str | None = None, bodies: bool = False, after: int | None = None
148
+ ) -> InboxList:
149
+ return from_json(InboxList, await self._request("GET", core.inbox_path(computer, state, bodies, after)))
150
+
151
+ async def message(self, computer: str, id: str) -> Message:
152
+ path = f"/v1/computers/{core.enc(computer)}/inbox/{core.enc(id)}"
153
+ return from_json(Message, await self._request("GET", path))
154
+
155
+ async def withdraw(self, computer: str, id: str) -> Message:
156
+ """Withdraws a pending Message; a 409 A12sError names its state if it is too late."""
157
+ path = f"/v1/computers/{core.enc(computer)}/inbox/{core.enc(id)}"
158
+ return from_json(Message, await self._request("DELETE", path))
159
+
160
+ async def stop(self, computer: str, turn: str | None = None) -> None:
161
+ """Interrupts a Turn (default: the active one). The Turn ends ``interrupted``; nothing is replayed."""
162
+ id = turn if turn is not None else (await self.computer(computer)).active_turn
163
+ if id:
164
+ await self._request("POST", f"/v1/computers/{core.enc(computer)}/turns/{core.enc(id)}/stop", {})
165
+
166
+ async def sleep(self, computer: str) -> None:
167
+ """Stops the Computer's processes and keeps its disks, session and inbox."""
168
+ await self._request("POST", f"/v1/computers/{core.enc(computer)}/power", {"state": "asleep"})
169
+
170
+ async def wake(self, computer: str) -> None:
171
+ """Starts a sleeping Computer; its session resumes on the next Turn."""
172
+ await self._request("POST", f"/v1/computers/{core.enc(computer)}/power", {"state": "awake"})
173
+
174
+ async def services(self) -> list[ServiceStatus]:
175
+ res = await self._request("GET", "/v1/services")
176
+ return [from_json(ServiceStatus, s) for s in (res or {}).get("services") or []]
177
+
178
+ async def service(self, name: str) -> ServiceStatus:
179
+ return from_json(ServiceStatus, await self._request("GET", f"/v1/services/{core.enc(name)}"))
180
+
181
+ async def create_token(self, name: str, actions: Sequence[str], computers: Sequence[str]) -> Token:
182
+ """A scoped token for another program; owner only. The secret is in the response once."""
183
+ req = {"name": name, "actions": list(actions), "computers": list(computers)}
184
+ return from_json(Token, await self._request("POST", "/v1/tokens", req))
185
+
186
+ async def put_fleet(self, request: dict[str, Any]) -> dict[str, Any]:
187
+ """What ``a12s up`` sends: validate and apply a whole Fleet. Most apps leave this to the CLI."""
188
+ res: dict[str, Any] = await self._request("PUT", "/v1/fleet", request)
189
+ return res
190
+
191
+ async def _delivered_in(self, computer: str, id: str) -> str:
192
+ # Every subscription starts with a snapshot, so resubscribing after a drop never misses the delivery.
193
+ while True:
194
+ state = core.StreamState(computer, None)
195
+ async with aclosing(self._stream(core.message_events_path(computer, id), state, 1.0, once=True)) as s:
196
+ async for ev in s:
197
+ turn = core.delivered_turn(ev, computer, id)
198
+ if turn is not None:
199
+ return turn
200
+ await asyncio.sleep(1.0)
201
+
202
+ async def _stream(
203
+ self, path: str, state: core.StreamState, reconnect: float, *, once: bool = False
204
+ ) -> AsyncGenerator[Event, None]:
205
+ while True:
206
+ try:
207
+ async with self._http.stream("GET", self.url + path, headers=state.headers(self._token)) as res:
208
+ if not res.is_success:
209
+ await res.aread()
210
+ state.refused(res.status_code, res.text)
211
+ else:
212
+ # Only reading the body is retried: a read that fails is the connection dropping. A
213
+ # malformed frame or an exception thrown into the generator ends the stream instead.
214
+ decoder = SSEDecoder()
215
+ try:
216
+ async for chunk in res.aiter_bytes():
217
+ for frame in decoder.feed(chunk):
218
+ ev = state.handle(frame)
219
+ if ev is not None:
220
+ yield ev
221
+ except httpx.TransportError:
222
+ pass
223
+ if once:
224
+ return
225
+ except httpx.TransportError:
226
+ pass
227
+ await asyncio.sleep(reconnect)
228
+
229
+
230
+ _Item = Union[
231
+ tuple[Literal["event"], Event],
232
+ tuple[Literal["error"], BaseException],
233
+ tuple[Literal["done", "wake"], None],
234
+ ]
235
+ _Source = Callable[[str, Callable[[], None]], AsyncGenerator[Event, None]]
236
+
237
+
238
+ class AsyncEventStream:
239
+ """Events of one or more Computers, merged in arrival order: one task per Computer feeds the iterator.
240
+
241
+ ``async with client.events() as events:`` awaits ``ready()`` on entry and closes the stream on exit.
242
+ """
243
+
244
+ def __init__(self, source: _Source, names: list[str] | Callable[[], Awaitable[list[str]]]) -> None:
245
+ self._source = source
246
+ self._names = names
247
+ self._queue: asyncio.Queue[_Item] = asyncio.Queue()
248
+ self._tasks: set[asyncio.Task[None]] = set()
249
+ self._started = False
250
+ self._closed = False
251
+ self._remaining = -1
252
+ self._live = 0
253
+ self._failed = False
254
+ self._ready = asyncio.Event()
255
+ self._ready_error: BaseException | None = None
256
+
257
+ async def ready(self) -> None:
258
+ """See EventStream.ready."""
259
+ self._start()
260
+ await self._ready.wait()
261
+ if self._ready_error is not None:
262
+ raise self._ready_error
263
+
264
+ def close(self) -> None:
265
+ self._closed = True
266
+ for t in self._tasks:
267
+ t.cancel()
268
+ self._ready.set()
269
+ self._queue.put_nowait(("wake", None))
270
+
271
+ async def aclose(self) -> None:
272
+ """close(), then waits for the streams' connections to be released."""
273
+ self.close()
274
+ await asyncio.gather(*self._tasks, return_exceptions=True)
275
+
276
+ def __aiter__(self) -> AsyncEventStream:
277
+ return self
278
+
279
+ async def __anext__(self) -> Event:
280
+ self._start()
281
+ while True:
282
+ if self._closed or self._remaining == 0:
283
+ try:
284
+ item = self._queue.get_nowait()
285
+ except asyncio.QueueEmpty:
286
+ raise StopAsyncIteration from None
287
+ else:
288
+ item = await self._queue.get()
289
+ if item[0] == "event":
290
+ return item[1]
291
+ if item[0] == "error":
292
+ self.close()
293
+ raise item[1]
294
+ if item[0] == "done":
295
+ self._remaining -= 1
296
+
297
+ async def __aenter__(self) -> AsyncEventStream:
298
+ try:
299
+ await self.ready()
300
+ except BaseException:
301
+ await self.aclose()
302
+ raise
303
+ return self
304
+
305
+ async def __aexit__(
306
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
307
+ ) -> None:
308
+ await self.aclose()
309
+
310
+ def _start(self) -> None:
311
+ if self._started:
312
+ return
313
+ self._started = True
314
+ self._spawn(self._run())
315
+
316
+ def _spawn(self, coro: Awaitable[None]) -> None:
317
+ task: asyncio.Task[None] = asyncio.ensure_future(coro)
318
+ self._tasks.add(task)
319
+ task.add_done_callback(self._tasks.discard)
320
+
321
+ async def _run(self) -> None:
322
+ try:
323
+ names = await self._names() if callable(self._names) else self._names
324
+ except Exception as err:
325
+ self._remaining = 0
326
+ self._fail(err)
327
+ return
328
+ self._remaining = len(names)
329
+ if not names:
330
+ self._ready.set()
331
+ for name in names:
332
+ self._spawn(self._pump(name, self._live_once(len(names))))
333
+ self._queue.put_nowait(("wake", None))
334
+
335
+ def _live_once(self, total: int) -> Callable[[], None]:
336
+ counted = False
337
+
338
+ def on_live() -> None:
339
+ nonlocal counted
340
+ if counted:
341
+ return
342
+ counted = True
343
+ self._live += 1
344
+ if self._live == total:
345
+ self._ready.set()
346
+
347
+ return on_live
348
+
349
+ async def _pump(self, name: str, on_live: Callable[[], None]) -> None:
350
+ try:
351
+ async with aclosing(self._source(name, on_live)) as events:
352
+ async for ev in events:
353
+ self._queue.put_nowait(("event", ev))
354
+ except Exception as err:
355
+ if not self._closed:
356
+ self._fail(err)
357
+ finally:
358
+ on_live()
359
+ self._queue.put_nowait(("done", None))
360
+
361
+ def _fail(self, err: BaseException) -> None:
362
+ if self._failed:
363
+ return
364
+ self._failed = True
365
+ if not self._ready.is_set():
366
+ self._ready_error = err
367
+ self._ready.set()
368
+ self._queue.put_nowait(("error", err))
369
+
370
+
371
+ class AsyncTurnStream:
372
+ """Finished Turns of one or more Computers. Same ``ready()``, ``close()`` and ``async with`` as AsyncEventStream."""
373
+
374
+ def __init__(self, events: AsyncEventStream) -> None:
375
+ self._events = events
376
+ self._builder = core.TurnBuilder()
377
+
378
+ async def ready(self) -> None:
379
+ await self._events.ready()
380
+
381
+ def close(self) -> None:
382
+ self._events.close()
383
+
384
+ async def aclose(self) -> None:
385
+ await self._events.aclose()
386
+
387
+ def __aiter__(self) -> AsyncTurnStream:
388
+ return self
389
+
390
+ async def __anext__(self) -> Turn:
391
+ async for ev in self._events:
392
+ turn = self._builder.add(ev)
393
+ if turn is not None:
394
+ return turn
395
+ raise StopAsyncIteration
396
+
397
+ async def __aenter__(self) -> AsyncTurnStream:
398
+ await self._events.__aenter__()
399
+ return self
400
+
401
+ async def __aexit__(
402
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
403
+ ) -> None:
404
+ await self.aclose()