mandala-computer 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mandala_computer/__init__.py +279 -0
- mandala_computer/_agent.py +248 -0
- mandala_computer/_api.py +1573 -0
- mandala_computer/_async_computer.py +1776 -0
- mandala_computer/_async_resources.py +650 -0
- mandala_computer/_cli.py +994 -0
- mandala_computer/_client.py +1312 -0
- mandala_computer/_computer.py +2813 -0
- mandala_computer/_events.py +2267 -0
- mandala_computer/_exceptions.py +749 -0
- mandala_computer/_models.py +2365 -0
- mandala_computer/_resources.py +1000 -0
- mandala_computer/_sse.py +182 -0
- mandala_computer/_webhooks.py +193 -0
- mandala_computer/py.typed +1 -0
- mandala_computer-0.1.0.dist-info/METADATA +1904 -0
- mandala_computer-0.1.0.dist-info/RECORD +20 -0
- mandala_computer-0.1.0.dist-info/WHEEL +4 -0
- mandala_computer-0.1.0.dist-info/entry_points.txt +2 -0
- mandala_computer-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Python SDK for Mandala Computer — cloud desktops for AI agents.
|
|
2
|
+
|
|
3
|
+
from mandala_computer import Client
|
|
4
|
+
|
|
5
|
+
client = Client() # MANDALA_API_KEY
|
|
6
|
+
with client.computers.ephemeral(template="base") as c:
|
|
7
|
+
c.wait_for_guest()
|
|
8
|
+
c.open("https://example.com") # on the screen, not as root
|
|
9
|
+
png = c.screenshot()
|
|
10
|
+
c.click(640, 400)
|
|
11
|
+
c.type("hello")
|
|
12
|
+
|
|
13
|
+
``AsyncClient`` mirrors it method for method:
|
|
14
|
+
|
|
15
|
+
from mandala_computer import AsyncClient
|
|
16
|
+
|
|
17
|
+
async with AsyncClient() as client:
|
|
18
|
+
async with client.computers.ephemeral(template="base") as c:
|
|
19
|
+
await c.wait_for_guest()
|
|
20
|
+
png = await c.screenshot()
|
|
21
|
+
|
|
22
|
+
This binds only to the platform's curated ``/api/v1`` surface, never to the
|
|
23
|
+
hypervisor daemon's own routes — see the README for why that boundary exists.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
from ._agent import (
|
|
31
|
+
AgentDone,
|
|
32
|
+
AgentEvent,
|
|
33
|
+
AgentFailed,
|
|
34
|
+
AgentResult,
|
|
35
|
+
AgentStep,
|
|
36
|
+
AgentStepEvent,
|
|
37
|
+
AgentText,
|
|
38
|
+
AgentUsage,
|
|
39
|
+
)
|
|
40
|
+
from ._async_computer import AsyncBackgroundCommand, AsyncComputer
|
|
41
|
+
from ._async_resources import (
|
|
42
|
+
AsyncBuilds,
|
|
43
|
+
AsyncComputers,
|
|
44
|
+
AsyncMoves,
|
|
45
|
+
AsyncSizes,
|
|
46
|
+
AsyncSnapshots,
|
|
47
|
+
AsyncTemplates,
|
|
48
|
+
AsyncUsage,
|
|
49
|
+
AsyncWebhooks,
|
|
50
|
+
)
|
|
51
|
+
from ._client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, AsyncTransport, Transport
|
|
52
|
+
from ._computer import SCREEN_HEIGHT, SCREEN_WIDTH, BackgroundCommand, Computer
|
|
53
|
+
from ._events import (
|
|
54
|
+
CHANNEL_EVENT_TYPES,
|
|
55
|
+
DESKTOP_EVENT_TYPES,
|
|
56
|
+
GUEST_EVENT_TYPES,
|
|
57
|
+
STREAM_FRAME_TYPES,
|
|
58
|
+
WATCH_EVENT_TYPE,
|
|
59
|
+
AsyncEventStream,
|
|
60
|
+
ComputerEvent,
|
|
61
|
+
EventStream,
|
|
62
|
+
Hello,
|
|
63
|
+
WatchedTree,
|
|
64
|
+
)
|
|
65
|
+
from ._exceptions import (
|
|
66
|
+
APIError,
|
|
67
|
+
AuthenticationError,
|
|
68
|
+
ConflictError,
|
|
69
|
+
ConnectionError,
|
|
70
|
+
ConnectionInterruptedError,
|
|
71
|
+
FileTooLargeError,
|
|
72
|
+
GatewayTimeoutError,
|
|
73
|
+
MandalaError,
|
|
74
|
+
MoveRequiredError,
|
|
75
|
+
NotFoundError,
|
|
76
|
+
OriginResponseError,
|
|
77
|
+
OriginTLSError,
|
|
78
|
+
OriginUnreachableError,
|
|
79
|
+
PermissionDeniedError,
|
|
80
|
+
PlanLimitError,
|
|
81
|
+
RangeNotSatisfiableError,
|
|
82
|
+
RateLimitError,
|
|
83
|
+
TimeoutError,
|
|
84
|
+
UnavailableError,
|
|
85
|
+
is_transient,
|
|
86
|
+
)
|
|
87
|
+
from ._models import (
|
|
88
|
+
BuildProgress,
|
|
89
|
+
BuildStep,
|
|
90
|
+
ComputerUsage,
|
|
91
|
+
ExecResult,
|
|
92
|
+
ExecStatus,
|
|
93
|
+
FilePart,
|
|
94
|
+
Listing,
|
|
95
|
+
Move,
|
|
96
|
+
PublishedTemplate,
|
|
97
|
+
Retention,
|
|
98
|
+
RetiredTemplates,
|
|
99
|
+
Size,
|
|
100
|
+
Snapshot,
|
|
101
|
+
SnapshotHoldings,
|
|
102
|
+
Template,
|
|
103
|
+
TemplateBuild,
|
|
104
|
+
TemplateCheck,
|
|
105
|
+
UsagePeriod,
|
|
106
|
+
UsageReport,
|
|
107
|
+
UsageTotals,
|
|
108
|
+
VncConnect,
|
|
109
|
+
Webhook,
|
|
110
|
+
WebhookCreated,
|
|
111
|
+
WebhookDelivery,
|
|
112
|
+
Window,
|
|
113
|
+
WindowResult,
|
|
114
|
+
)
|
|
115
|
+
from ._resources import Builds, Computers, Moves, Sizes, Snapshots, Templates, Usage, Webhooks
|
|
116
|
+
from ._webhooks import REPLAY_WINDOW_S, verify
|
|
117
|
+
|
|
118
|
+
__version__ = "0.1.0"
|
|
119
|
+
|
|
120
|
+
__all__ = [
|
|
121
|
+
"CHANNEL_EVENT_TYPES",
|
|
122
|
+
"DEFAULT_BASE_URL",
|
|
123
|
+
"DESKTOP_EVENT_TYPES",
|
|
124
|
+
"GUEST_EVENT_TYPES",
|
|
125
|
+
"REPLAY_WINDOW_S",
|
|
126
|
+
"SCREEN_HEIGHT",
|
|
127
|
+
"SCREEN_WIDTH",
|
|
128
|
+
"STREAM_FRAME_TYPES",
|
|
129
|
+
"WATCH_EVENT_TYPE",
|
|
130
|
+
"APIError",
|
|
131
|
+
"AgentDone",
|
|
132
|
+
"AgentEvent",
|
|
133
|
+
"AgentFailed",
|
|
134
|
+
"AgentResult",
|
|
135
|
+
"AgentStep",
|
|
136
|
+
"AgentStepEvent",
|
|
137
|
+
"AgentText",
|
|
138
|
+
"AgentUsage",
|
|
139
|
+
"AsyncBackgroundCommand",
|
|
140
|
+
"AsyncClient",
|
|
141
|
+
"AsyncComputer",
|
|
142
|
+
"AsyncEventStream",
|
|
143
|
+
"AuthenticationError",
|
|
144
|
+
"BackgroundCommand",
|
|
145
|
+
"BuildProgress",
|
|
146
|
+
"BuildStep",
|
|
147
|
+
"Client",
|
|
148
|
+
"Computer",
|
|
149
|
+
"ComputerEvent",
|
|
150
|
+
"ComputerUsage",
|
|
151
|
+
"ConflictError",
|
|
152
|
+
"ConnectionError",
|
|
153
|
+
"ConnectionInterruptedError",
|
|
154
|
+
"EventStream",
|
|
155
|
+
"ExecResult",
|
|
156
|
+
"ExecStatus",
|
|
157
|
+
"FilePart",
|
|
158
|
+
"FileTooLargeError",
|
|
159
|
+
"GatewayTimeoutError",
|
|
160
|
+
"Hello",
|
|
161
|
+
"Listing",
|
|
162
|
+
"MandalaError",
|
|
163
|
+
"Move",
|
|
164
|
+
"MoveRequiredError",
|
|
165
|
+
"NotFoundError",
|
|
166
|
+
"OriginResponseError",
|
|
167
|
+
"OriginTLSError",
|
|
168
|
+
"OriginUnreachableError",
|
|
169
|
+
"PermissionDeniedError",
|
|
170
|
+
"PlanLimitError",
|
|
171
|
+
"PublishedTemplate",
|
|
172
|
+
"RangeNotSatisfiableError",
|
|
173
|
+
"RateLimitError",
|
|
174
|
+
"Retention",
|
|
175
|
+
"RetiredTemplates",
|
|
176
|
+
"Size",
|
|
177
|
+
"Snapshot",
|
|
178
|
+
"SnapshotHoldings",
|
|
179
|
+
"Template",
|
|
180
|
+
"TemplateBuild",
|
|
181
|
+
"TemplateCheck",
|
|
182
|
+
"TimeoutError",
|
|
183
|
+
"UnavailableError",
|
|
184
|
+
"UsagePeriod",
|
|
185
|
+
"UsageReport",
|
|
186
|
+
"UsageTotals",
|
|
187
|
+
"VncConnect",
|
|
188
|
+
"WatchedTree",
|
|
189
|
+
"Webhook",
|
|
190
|
+
"WebhookCreated",
|
|
191
|
+
"WebhookDelivery",
|
|
192
|
+
"Window",
|
|
193
|
+
"WindowResult",
|
|
194
|
+
"__version__",
|
|
195
|
+
"is_transient",
|
|
196
|
+
"verify",
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class Client:
|
|
201
|
+
"""Entry point to the Mandala Computer API.
|
|
202
|
+
|
|
203
|
+
:param api_key: defaults to ``MANDALA_API_KEY``.
|
|
204
|
+
:param base_url: defaults to ``MANDALA_BASE_URL``, then the public API.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
def __init__(
|
|
208
|
+
self,
|
|
209
|
+
api_key: str | None = None,
|
|
210
|
+
*,
|
|
211
|
+
base_url: str | None = None,
|
|
212
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
213
|
+
http_client: httpx.Client | None = None,
|
|
214
|
+
) -> None:
|
|
215
|
+
self._t = Transport(api_key, base_url=base_url, timeout=timeout, client=http_client)
|
|
216
|
+
self.builds = Builds(self._t)
|
|
217
|
+
self.computers = Computers(self._t)
|
|
218
|
+
self.moves = Moves(self._t)
|
|
219
|
+
self.snapshots = Snapshots(self._t)
|
|
220
|
+
self.templates = Templates(self._t)
|
|
221
|
+
self.sizes = Sizes(self._t)
|
|
222
|
+
self.usage = Usage(self._t)
|
|
223
|
+
self.webhooks = Webhooks(self._t)
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def base_url(self) -> str:
|
|
227
|
+
return self._t.base_url
|
|
228
|
+
|
|
229
|
+
def close(self) -> None:
|
|
230
|
+
self._t.close()
|
|
231
|
+
|
|
232
|
+
# typing.Self is 3.11+; the floor here is 3.10, so name the class instead.
|
|
233
|
+
def __enter__(self) -> Client: # noqa: PYI034
|
|
234
|
+
return self
|
|
235
|
+
|
|
236
|
+
def __exit__(self, *exc: object) -> None:
|
|
237
|
+
self.close()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class AsyncClient:
|
|
241
|
+
"""Entry point to the Mandala Computer API, driven with ``await``.
|
|
242
|
+
|
|
243
|
+
Same arguments and behaviour as :class:`Client`; every method that performs
|
|
244
|
+
IO is a coroutine.
|
|
245
|
+
|
|
246
|
+
:param api_key: defaults to ``MANDALA_API_KEY``.
|
|
247
|
+
:param base_url: defaults to ``MANDALA_BASE_URL``, then the public API.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
def __init__(
|
|
251
|
+
self,
|
|
252
|
+
api_key: str | None = None,
|
|
253
|
+
*,
|
|
254
|
+
base_url: str | None = None,
|
|
255
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
256
|
+
http_client: httpx.AsyncClient | None = None,
|
|
257
|
+
) -> None:
|
|
258
|
+
self._t = AsyncTransport(api_key, base_url=base_url, timeout=timeout, client=http_client)
|
|
259
|
+
self.builds = AsyncBuilds(self._t)
|
|
260
|
+
self.computers = AsyncComputers(self._t)
|
|
261
|
+
self.moves = AsyncMoves(self._t)
|
|
262
|
+
self.snapshots = AsyncSnapshots(self._t)
|
|
263
|
+
self.templates = AsyncTemplates(self._t)
|
|
264
|
+
self.sizes = AsyncSizes(self._t)
|
|
265
|
+
self.usage = AsyncUsage(self._t)
|
|
266
|
+
self.webhooks = AsyncWebhooks(self._t)
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def base_url(self) -> str:
|
|
270
|
+
return self._t.base_url
|
|
271
|
+
|
|
272
|
+
async def aclose(self) -> None:
|
|
273
|
+
await self._t.aclose()
|
|
274
|
+
|
|
275
|
+
async def __aenter__(self) -> AsyncClient: # noqa: PYI034
|
|
276
|
+
return self
|
|
277
|
+
|
|
278
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
279
|
+
await self.aclose()
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""The platform's own agent loop, as types.
|
|
2
|
+
|
|
3
|
+
``POST computers/:id/agent`` is not a call to a hypervisor — it is many of them,
|
|
4
|
+
interleaved with calls to a model API, running for minutes. So it answers with a
|
|
5
|
+
stream of steps rather than a result, and this file is the shape of that stream.
|
|
6
|
+
|
|
7
|
+
It runs on **your** Anthropic key, which the platform never stores: pass it as
|
|
8
|
+
``model_key`` and it travels on that one request as ``X-Model-Key``. Every step
|
|
9
|
+
is a model call plus a screenshot billed to that key, which is why ``max_steps``
|
|
10
|
+
bounds spending as much as it bounds the loop.
|
|
11
|
+
|
|
12
|
+
Everything here is built by :func:`to_agent_event` out of whatever a frame
|
|
13
|
+
carried, and never by asserting a shape. A frame this SDK does not model is
|
|
14
|
+
skipped rather than raised on: the platform is free to add event types, and a
|
|
15
|
+
client that fell over on the first unrecognised one would turn a
|
|
16
|
+
forward-compatible addition into an outage.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
from collections.abc import Mapping
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AgentDone",
|
|
28
|
+
"AgentEvent",
|
|
29
|
+
"AgentFailed",
|
|
30
|
+
"AgentResult",
|
|
31
|
+
"AgentStep",
|
|
32
|
+
"AgentStepEvent",
|
|
33
|
+
"AgentText",
|
|
34
|
+
"AgentUsage",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _num(value: Any) -> int:
|
|
39
|
+
"""A count off the wire, or ``0``.
|
|
40
|
+
|
|
41
|
+
Never raises, and the word is load-bearing: a malformed number in one field
|
|
42
|
+
must not lose the run's result along with it — a step count that arrived as
|
|
43
|
+
``null`` is worth reporting as zero, and is not worth discarding the model's
|
|
44
|
+
answer over.
|
|
45
|
+
|
|
46
|
+
``OverflowError`` is caught alongside the two obvious ones because JSON has
|
|
47
|
+
no integer ceiling and Python's ``int`` has none either, so a 400-digit
|
|
48
|
+
literal parses fine and only fails on the way to a ``float``. It is a
|
|
49
|
+
malformed number like any other here.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
n = float(value)
|
|
53
|
+
except (OverflowError, TypeError, ValueError):
|
|
54
|
+
return 0
|
|
55
|
+
# NaN and the infinities parse as floats and are not counts. int(nan) raises
|
|
56
|
+
# and int(inf) raises too, so this is the guard as much as the filter.
|
|
57
|
+
return int(n) if math.isfinite(n) else 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _text(value: Any) -> str:
|
|
61
|
+
return "" if value is None else str(value)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class AgentStep:
|
|
66
|
+
"""One action the loop took."""
|
|
67
|
+
|
|
68
|
+
#: Which step this was, counting from 1.
|
|
69
|
+
n: int
|
|
70
|
+
#: The tool the model reached for, e.g. ``"computer"`` or ``"bash"``.
|
|
71
|
+
tool: str = ""
|
|
72
|
+
#: The action within it, e.g. ``"left_click"``. Empty for bash.
|
|
73
|
+
action: str = ""
|
|
74
|
+
#: What the platform did with it, in one line.
|
|
75
|
+
detail: str = ""
|
|
76
|
+
#: Set when the action was refused. The loop continues and the model adapts,
|
|
77
|
+
#: so this is a step that did not work rather than a run that failed.
|
|
78
|
+
error: str = ""
|
|
79
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_api(cls, d: Any, fallback_n: int) -> AgentStep:
|
|
83
|
+
"""This step, with ``fallback_n`` standing in for a number it did not give.
|
|
84
|
+
|
|
85
|
+
"Did not give" is any unusable number, not only a missing key: steps
|
|
86
|
+
count from 1, so a ``null``, a string, or a zero all mean the same thing
|
|
87
|
+
— nothing to number this step by — and reading one as ``0`` would put
|
|
88
|
+
the "0." in a caller's progress line that the fallback exists to
|
|
89
|
+
prevent.
|
|
90
|
+
"""
|
|
91
|
+
r = d if isinstance(d, Mapping) else {}
|
|
92
|
+
n = _num(r.get("n"))
|
|
93
|
+
return cls(
|
|
94
|
+
n=n if n > 0 else fallback_n,
|
|
95
|
+
tool=_text(r.get("tool")),
|
|
96
|
+
action=_text(r.get("action")),
|
|
97
|
+
detail=_text(r.get("detail")),
|
|
98
|
+
error=_text(r.get("error")),
|
|
99
|
+
raw=dict(r),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class AgentUsage:
|
|
105
|
+
"""What a run cost on your key.
|
|
106
|
+
|
|
107
|
+
:attr:`input_tokens` includes the two cache halves, which are most of a long
|
|
108
|
+
run — the rolling breakpoint means step ten's prompt is almost entirely
|
|
109
|
+
cache reads. They are broken out as well, because they are priced
|
|
110
|
+
differently and reconciling against an Anthropic bill needs to see them.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
input_tokens: int = 0
|
|
114
|
+
output_tokens: int = 0
|
|
115
|
+
cache_read_tokens: int = 0
|
|
116
|
+
cache_write_tokens: int = 0
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def from_api(cls, d: Any) -> AgentUsage:
|
|
120
|
+
r = d if isinstance(d, Mapping) else {}
|
|
121
|
+
return cls(
|
|
122
|
+
input_tokens=_num(r.get("input_tokens")),
|
|
123
|
+
output_tokens=_num(r.get("output_tokens")),
|
|
124
|
+
cache_read_tokens=_num(r.get("cache_read_tokens")),
|
|
125
|
+
cache_write_tokens=_num(r.get("cache_write_tokens")),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class AgentResult:
|
|
131
|
+
"""How a run ended, and what it did."""
|
|
132
|
+
|
|
133
|
+
#: How many steps it took.
|
|
134
|
+
steps: int = 0
|
|
135
|
+
#: Why it ended: ``end_turn``, ``max_steps``, ``rate_limited``, ``refusal``
|
|
136
|
+
#: — or something added since, which is why this is a string and not an
|
|
137
|
+
#: enum.
|
|
138
|
+
stop: str = ""
|
|
139
|
+
#: The model's closing text — its answer, or why it could not get there.
|
|
140
|
+
text: str = ""
|
|
141
|
+
usage: AgentUsage = field(default_factory=AgentUsage)
|
|
142
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False)
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def finished(self) -> bool:
|
|
146
|
+
"""True only for ``end_turn`` — the model deciding it was done.
|
|
147
|
+
|
|
148
|
+
The check most callers actually want, and the reason it is here rather
|
|
149
|
+
than left to everyone to write: treating every ending as success reports
|
|
150
|
+
a run that hit its step cap as one that completed the task.
|
|
151
|
+
"""
|
|
152
|
+
return self.stop == "end_turn"
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def from_api(cls, d: Any) -> AgentResult:
|
|
156
|
+
r = d if isinstance(d, Mapping) else {}
|
|
157
|
+
return cls(
|
|
158
|
+
steps=_num(r.get("steps")),
|
|
159
|
+
stop=_text(r.get("stop")) or "unknown",
|
|
160
|
+
text=_text(r.get("text")),
|
|
161
|
+
usage=AgentUsage.from_api(r.get("usage")),
|
|
162
|
+
raw=dict(r),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True)
|
|
167
|
+
class AgentStepEvent:
|
|
168
|
+
"""The loop did something. Tell your user about it."""
|
|
169
|
+
|
|
170
|
+
step: AgentStep
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True)
|
|
174
|
+
class AgentText:
|
|
175
|
+
"""The model said something on its way through."""
|
|
176
|
+
|
|
177
|
+
text: str
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass(frozen=True)
|
|
181
|
+
class AgentDone:
|
|
182
|
+
"""The run ended, however it ended. Carries the result."""
|
|
183
|
+
|
|
184
|
+
result: AgentResult
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True)
|
|
188
|
+
class AgentFailed:
|
|
189
|
+
"""The run went wrong, mid-stream.
|
|
190
|
+
|
|
191
|
+
Distinct from a run that ended unfinished: ``max_steps`` and
|
|
192
|
+
``rate_limited`` arrive as an :class:`AgentDone` carrying a result, because
|
|
193
|
+
the steps already taken are real and what they did to the desktop stands.
|
|
194
|
+
This is the platform saying the run itself could not continue.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
error: str
|
|
198
|
+
#: The HTTP status the failure would have had, or ``0`` where the platform
|
|
199
|
+
#: did not name one. Carried so a 401 arriving mid-run can be raised as the
|
|
200
|
+
#: same :class:`~mandala_computer.AuthenticationError` a 401 anywhere else
|
|
201
|
+
#: raises, rather than as something a caller's handler cannot classify.
|
|
202
|
+
status: int = 0
|
|
203
|
+
#: What the run had already spent on your key when it went wrong. A failure
|
|
204
|
+
#: at step eight has been billed for eight steps whether or not anything is
|
|
205
|
+
#: told about it, and this is the only place that number is ever reported —
|
|
206
|
+
#: the platform meters nothing on your model key, so there is no invoice to
|
|
207
|
+
#: reconcile it against later.
|
|
208
|
+
usage: AgentUsage = field(default_factory=AgentUsage)
|
|
209
|
+
#: What the run had already done to the desktop. Those actions stand: the
|
|
210
|
+
#: failure stopped the loop, it did not undo the clicks. Left empty where
|
|
211
|
+
#: the platform could not say — its own last-resort handler reports the
|
|
212
|
+
#: error and the status alone — so this being empty means "not reported"
|
|
213
|
+
#: rather than "nothing happened".
|
|
214
|
+
steps: tuple[AgentStep, ...] = ()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
#: One event out of :meth:`~mandala_computer.Computer.agent_stream`. Match on it
|
|
218
|
+
#: with ``isinstance`` — the four are a closed set as far as this SDK models the
|
|
219
|
+
#: stream, and anything else the platform sends is skipped before it gets here.
|
|
220
|
+
AgentEvent = AgentStepEvent | AgentText | AgentDone | AgentFailed
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def to_agent_event(event: str, data: Any, step_count: int) -> AgentEvent | None:
|
|
224
|
+
"""One frame as an event, or ``None`` for a frame this SDK does not model."""
|
|
225
|
+
if event == "step":
|
|
226
|
+
return AgentStepEvent(AgentStep.from_api(data, step_count + 1))
|
|
227
|
+
if event == "text":
|
|
228
|
+
text = _text(data.get("text") if isinstance(data, Mapping) else data)
|
|
229
|
+
# A frame that said nothing is skipped like any other frame this SDK
|
|
230
|
+
# cannot read. The README's loop prints what it is handed, and an empty
|
|
231
|
+
# AgentText is a blank line in a caller's output standing for a payload
|
|
232
|
+
# whose shape we did not recognise.
|
|
233
|
+
return AgentText(text) if text else None
|
|
234
|
+
if event == "done":
|
|
235
|
+
return AgentDone(AgentResult.from_api(data))
|
|
236
|
+
if event == "error":
|
|
237
|
+
r = data if isinstance(data, Mapping) else {}
|
|
238
|
+
taken = r.get("steps")
|
|
239
|
+
return AgentFailed(
|
|
240
|
+
_text(r.get("error")) or "the run failed",
|
|
241
|
+
_num(r.get("status")),
|
|
242
|
+
AgentUsage.from_api(r.get("usage")),
|
|
243
|
+
tuple(
|
|
244
|
+
AgentStep.from_api(step, i + 1)
|
|
245
|
+
for i, step in enumerate(taken if isinstance(taken, list) else ())
|
|
246
|
+
),
|
|
247
|
+
)
|
|
248
|
+
return None
|