guidinghand 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.
@@ -0,0 +1,117 @@
1
+ """The official Python SDK for the GuidingHand API.
2
+
3
+ ```python
4
+ from guidinghand import GuidingHand
5
+
6
+ client = GuidingHand() # reads GUIDINGHAND_API_KEY
7
+ session = client.sessions.create()
8
+ print("Send this link:", session["invite_url"])
9
+ client.sessions.wait_for_connection(session["session_id"])
10
+ task = client.tasks.run(
11
+ session["session_id"],
12
+ "Turn on Dark Mode",
13
+ on_question=lambda q: input(q["question"] + " > "),
14
+ on_approval=lambda a: input(f"Approve {a['action']}? [y/N] ") == "y",
15
+ )
16
+ print(task["status"], task["result"])
17
+ ```
18
+ """
19
+
20
+ from ._async_client import AsyncGuidingHand
21
+ from ._base import DEFAULT_BASE_URL
22
+ from ._client import GuidingHand
23
+ from ._errors import (
24
+ APIConnectionError,
25
+ APIError,
26
+ AuthenticationError,
27
+ ConflictError,
28
+ GuidingHandError,
29
+ GuidingHandTimeout,
30
+ InvalidRequestError,
31
+ NeedsInput,
32
+ NotFoundError,
33
+ PaymentRequiredError,
34
+ PermissionDeniedError,
35
+ RateLimitError,
36
+ SessionExpired,
37
+ WebhookVerificationError,
38
+ )
39
+ from ._types import (
40
+ Agent,
41
+ AgentPage,
42
+ ApprovalDecision,
43
+ ApprovalPending,
44
+ Deleted,
45
+ Device,
46
+ Effort,
47
+ Event,
48
+ EventPage,
49
+ EventType,
50
+ Metadata,
51
+ Page,
52
+ Pending,
53
+ QuestionPending,
54
+ Recording,
55
+ RecordingFrame,
56
+ Session,
57
+ SessionPage,
58
+ SessionStatus,
59
+ Task,
60
+ TaskPage,
61
+ TaskStatus,
62
+ Webhook,
63
+ WebhookEvent,
64
+ WebhookEventType,
65
+ )
66
+ from ._version import __version__
67
+ from ._webhooks import SIGNATURE_HEADER, verify_webhook
68
+
69
+ __all__ = [
70
+ "__version__",
71
+ "GuidingHand",
72
+ "AsyncGuidingHand",
73
+ "DEFAULT_BASE_URL",
74
+ "verify_webhook",
75
+ "SIGNATURE_HEADER",
76
+ # errors
77
+ "GuidingHandError",
78
+ "InvalidRequestError",
79
+ "AuthenticationError",
80
+ "PaymentRequiredError",
81
+ "PermissionDeniedError",
82
+ "NotFoundError",
83
+ "ConflictError",
84
+ "RateLimitError",
85
+ "APIError",
86
+ "APIConnectionError",
87
+ "GuidingHandTimeout",
88
+ "NeedsInput",
89
+ "SessionExpired",
90
+ "WebhookVerificationError",
91
+ # objects
92
+ "Agent",
93
+ "AgentPage",
94
+ "ApprovalDecision",
95
+ "ApprovalPending",
96
+ "Deleted",
97
+ "Device",
98
+ "Effort",
99
+ "Event",
100
+ "EventPage",
101
+ "EventType",
102
+ "Metadata",
103
+ "Page",
104
+ "Pending",
105
+ "QuestionPending",
106
+ "Recording",
107
+ "RecordingFrame",
108
+ "Session",
109
+ "SessionPage",
110
+ "SessionStatus",
111
+ "Task",
112
+ "TaskPage",
113
+ "TaskStatus",
114
+ "Webhook",
115
+ "WebhookEvent",
116
+ "WebhookEventType",
117
+ ]
@@ -0,0 +1,428 @@
1
+ """The asyncio client: ``AsyncGuidingHand``. Same surface as ``GuidingHand``, with ``await``."""
2
+
3
+ import asyncio
4
+ import inspect
5
+ import time
6
+ from typing import Any, AsyncIterator, Awaitable, Callable, Dict, List, Optional, Set, TypeVar, Union, cast
7
+
8
+ import httpx
9
+
10
+ from ._base import (
11
+ DEFAULT_BASE_URL,
12
+ DEFAULT_MAX_RETRIES,
13
+ DEFAULT_TIMEOUT,
14
+ LONG_POLL_MS,
15
+ Include,
16
+ answer_of,
17
+ api_path,
18
+ approval_reply,
19
+ compact,
20
+ connection_error,
21
+ decision_of,
22
+ default_headers,
23
+ deleted_before,
24
+ events_timeout,
25
+ include_param,
26
+ is_retryable,
27
+ is_retryable_status,
28
+ may_have_happened,
29
+ parse_response,
30
+ pending_key,
31
+ resolve_api_key,
32
+ retry_delay,
33
+ run_wait_ms,
34
+ webhook_body,
35
+ )
36
+ from ._errors import ConflictError, GuidingHandError, GuidingHandTimeout, NeedsInput, SessionExpired
37
+ from ._types import (
38
+ Agent,
39
+ AgentPage,
40
+ ApprovalDecision,
41
+ ApprovalPending,
42
+ Deleted,
43
+ Effort,
44
+ Event,
45
+ EventPage,
46
+ Metadata,
47
+ Pending,
48
+ QuestionPending,
49
+ Recording,
50
+ Session,
51
+ SessionPage,
52
+ Task,
53
+ TaskPage,
54
+ TaskStatus,
55
+ Webhook,
56
+ )
57
+
58
+ __all__ = ["AsyncGuidingHand"]
59
+
60
+ T = TypeVar("T")
61
+ MaybeAwaitable = Union[T, Awaitable[T]]
62
+
63
+
64
+ async def _resolve(value: MaybeAwaitable[T]) -> T:
65
+ if inspect.isawaitable(value):
66
+ return await value
67
+ return value
68
+
69
+
70
+ class AsyncGuidingHand:
71
+ """An asyncio client for the GuidingHand API, with the same resources and methods as ``GuidingHand``.
72
+
73
+ ```python
74
+ async with AsyncGuidingHand() as client:
75
+ session = await client.sessions.create()
76
+ ```
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ api_key: Optional[str] = None,
82
+ *,
83
+ base_url: str = DEFAULT_BASE_URL,
84
+ timeout: Optional[float] = DEFAULT_TIMEOUT,
85
+ max_retries: int = DEFAULT_MAX_RETRIES,
86
+ ) -> None:
87
+ self.api_key = resolve_api_key(api_key)
88
+ self.base_url = base_url.rstrip("/")
89
+ self.timeout = timeout
90
+ self.max_retries = max(0, int(max_retries))
91
+ self._http = httpx.AsyncClient(base_url=self.base_url, headers=default_headers(self.api_key), timeout=timeout)
92
+ self.agents = AsyncAgents(self)
93
+ self.sessions = AsyncSessions(self)
94
+ self.tasks = AsyncTasks(self)
95
+ self.webhook = AsyncWebhookEndpoint(self)
96
+
97
+ async def _request(
98
+ self,
99
+ method: str,
100
+ path: str,
101
+ *,
102
+ params: Optional[Dict[str, Any]] = None,
103
+ body: Any = None,
104
+ timeout: Optional[float] = None,
105
+ raw: bool = False,
106
+ deleted_result: Optional[Dict[str, Any]] = None,
107
+ ) -> Any:
108
+ """``deleted_result``: for a DELETE, what to return when a retry gets 404 because an earlier attempt
109
+ (whose response was lost) already deleted it."""
110
+ retryable = is_retryable(method, body)
111
+ attempt = 0
112
+ earlier_may_have_happened = False
113
+ while True:
114
+ try:
115
+ response = await self._http.request(
116
+ method,
117
+ path,
118
+ params=compact(params) or None,
119
+ json=body,
120
+ timeout=timeout if timeout is not None else self.timeout,
121
+ )
122
+ except httpx.TransportError as e:
123
+ if retryable and attempt < self.max_retries:
124
+ earlier_may_have_happened = True
125
+ await asyncio.sleep(retry_delay(attempt))
126
+ attempt += 1
127
+ continue
128
+ raise connection_error(method, path, e) from e
129
+ if retryable and is_retryable_status(response.status_code) and attempt < self.max_retries:
130
+ earlier_may_have_happened = earlier_may_have_happened or may_have_happened(response)
131
+ delay = retry_delay(attempt, response)
132
+ await response.aclose()
133
+ await asyncio.sleep(delay)
134
+ attempt += 1
135
+ continue
136
+ if deleted_result is not None and deleted_before(method, response, earlier_may_have_happened):
137
+ return deleted_result
138
+ return parse_response(method, path, response, raw)
139
+
140
+ async def close(self) -> None:
141
+ """Closes the HTTP connections. The client can't be used afterwards."""
142
+ await self._http.aclose()
143
+
144
+ aclose = close
145
+
146
+ async def __aenter__(self) -> "AsyncGuidingHand":
147
+ return self
148
+
149
+ async def __aexit__(self, *exc: Any) -> None:
150
+ await self.close()
151
+
152
+ def __repr__(self) -> str:
153
+ return f"AsyncGuidingHand(base_url={self.base_url!r})"
154
+
155
+
156
+ class AsyncAgents:
157
+ """``client.agents``: see ``guidinghand.GuidingHand.agents``."""
158
+
159
+ def __init__(self, client: AsyncGuidingHand) -> None:
160
+ self._client = client
161
+
162
+ async def list(self) -> AgentPage:
163
+ return cast(AgentPage, await self._client._request("GET", api_path("agents")))
164
+
165
+ async def create(
166
+ self,
167
+ name: str,
168
+ agent_id: Optional[str] = None,
169
+ instructions: Optional[str] = None,
170
+ effort: Optional[Effort] = None,
171
+ greeting: Optional[str] = None,
172
+ ) -> Agent:
173
+ body = compact(
174
+ {"name": name, "agent_id": agent_id, "instructions": instructions, "effort": effort, "greeting": greeting}
175
+ )
176
+ return cast(Agent, await self._client._request("POST", api_path("agents"), body=body))
177
+
178
+ async def retrieve(self, agent_id: str) -> Agent:
179
+ return cast(Agent, await self._client._request("GET", api_path("agents", agent_id)))
180
+
181
+ async def update(self, agent_id: str, **fields: Any) -> Agent:
182
+ return cast(Agent, await self._client._request("PATCH", api_path("agents", agent_id), body=fields))
183
+
184
+ async def delete(self, agent_id: str) -> Deleted:
185
+ gone = {"object": "agent", "agent_id": agent_id, "deleted": True}
186
+ return cast(Deleted, await self._client._request("DELETE", api_path("agents", agent_id), deleted_result=gone))
187
+
188
+
189
+ class AsyncSessions:
190
+ """``client.sessions``: see ``guidinghand.GuidingHand.sessions``."""
191
+
192
+ def __init__(self, client: AsyncGuidingHand) -> None:
193
+ self._client = client
194
+
195
+ async def create(self, agent_id: Optional[str] = None, metadata: Optional[Metadata] = None) -> Session:
196
+ body = compact({"agent_id": agent_id, "metadata": metadata})
197
+ return cast(Session, await self._client._request("POST", api_path("sessions"), body=body))
198
+
199
+ async def list(
200
+ self, agent_id: Optional[str] = None, limit: Optional[int] = None, cursor: Optional[str] = None
201
+ ) -> SessionPage:
202
+ params = {"agent_id": agent_id, "limit": limit, "cursor": cursor}
203
+ return cast(SessionPage, await self._client._request("GET", api_path("sessions"), params=params))
204
+
205
+ async def list_all(self, agent_id: Optional[str] = None, *, limit: Optional[int] = None) -> AsyncIterator[Session]:
206
+ cursor: Optional[str] = None
207
+ while True:
208
+ page = await self.list(agent_id=agent_id, limit=limit, cursor=cursor)
209
+ for session in page["data"]:
210
+ yield session
211
+ if not page["has_more"] or not page["next_cursor"]:
212
+ return
213
+ cursor = page["next_cursor"]
214
+
215
+ async def retrieve(self, session_id: str) -> Session:
216
+ return cast(Session, await self._client._request("GET", api_path("sessions", session_id)))
217
+
218
+ async def delete(self, session_id: str) -> Deleted:
219
+ gone = {"object": "session", "session_id": session_id, "deleted": True}
220
+ return cast(
221
+ Deleted, await self._client._request("DELETE", api_path("sessions", session_id), deleted_result=gone)
222
+ )
223
+
224
+ async def wait_for_connection(
225
+ self, session_id: str, timeout: Optional[float] = 600, poll_interval: float = 3
226
+ ) -> Session:
227
+ deadline = None if timeout is None else time.monotonic() + timeout
228
+ while True:
229
+ session = await self.retrieve(session_id)
230
+ if session["status"] == "connected":
231
+ return session
232
+ if session["status"] == "expired":
233
+ raise SessionExpired(session)
234
+ remaining = None if deadline is None else deadline - time.monotonic()
235
+ if remaining is not None and remaining <= 0:
236
+ raise GuidingHandTimeout(
237
+ f"No computer connected to session {session_id} within {timeout:g} s.", session=session
238
+ )
239
+ await asyncio.sleep(poll_interval if remaining is None else max(0.0, min(poll_interval, remaining)))
240
+
241
+
242
+ class AsyncTasks:
243
+ """``client.tasks``: see ``guidinghand.GuidingHand.tasks``."""
244
+
245
+ def __init__(self, client: AsyncGuidingHand) -> None:
246
+ self._client = client
247
+
248
+ async def create(
249
+ self,
250
+ session_id: str,
251
+ prompt: str,
252
+ agent_id: Optional[str] = None,
253
+ request_id: Optional[str] = None,
254
+ metadata: Optional[Metadata] = None,
255
+ ) -> Task:
256
+ body = compact({"prompt": prompt, "agent_id": agent_id, "request_id": request_id, "metadata": metadata})
257
+ return cast(Task, await self._client._request("POST", api_path("sessions", session_id, "tasks"), body=body))
258
+
259
+ async def list(
260
+ self,
261
+ session_id: Optional[str] = None,
262
+ agent_id: Optional[str] = None,
263
+ status: Optional[TaskStatus] = None,
264
+ limit: Optional[int] = None,
265
+ cursor: Optional[str] = None,
266
+ ) -> TaskPage:
267
+ params = {"session_id": session_id, "agent_id": agent_id, "status": status, "limit": limit, "cursor": cursor}
268
+ return cast(TaskPage, await self._client._request("GET", api_path("tasks"), params=params))
269
+
270
+ async def list_all(
271
+ self,
272
+ session_id: Optional[str] = None,
273
+ agent_id: Optional[str] = None,
274
+ status: Optional[TaskStatus] = None,
275
+ *,
276
+ limit: Optional[int] = None,
277
+ ) -> AsyncIterator[Task]:
278
+ cursor: Optional[str] = None
279
+ while True:
280
+ page = await self.list(session_id=session_id, agent_id=agent_id, status=status, limit=limit, cursor=cursor)
281
+ for task in page["data"]:
282
+ yield task
283
+ if not page["has_more"] or not page["next_cursor"]:
284
+ return
285
+ cursor = page["next_cursor"]
286
+
287
+ async def retrieve(self, task_id: str, include: Include = None) -> Task:
288
+ params = {"include": include_param(include)}
289
+ return cast(Task, await self._client._request("GET", api_path("tasks", task_id), params=params))
290
+
291
+ async def events(self, task_id: str, after: int = 0, wait_ms: int = 0) -> EventPage:
292
+ params = {"after": after, "wait_ms": wait_ms}
293
+ timeout = events_timeout(self._client.timeout, wait_ms)
294
+ return cast(
295
+ EventPage,
296
+ await self._client._request("GET", api_path("tasks", task_id, "events"), params=params, timeout=timeout),
297
+ )
298
+
299
+ async def respond(
300
+ self,
301
+ task_id: str,
302
+ question_id: Optional[str] = None,
303
+ answer: Optional[str] = None,
304
+ approval_id: Optional[str] = None,
305
+ decision: Optional[Union[str, bool]] = None,
306
+ note: Optional[str] = None,
307
+ ) -> Task:
308
+ body = compact(
309
+ {
310
+ "question_id": question_id,
311
+ "answer": answer,
312
+ "approval_id": approval_id,
313
+ "decision": decision_of(decision) if isinstance(decision, bool) else decision,
314
+ "note": note,
315
+ }
316
+ )
317
+ return cast(Task, await self._client._request("POST", api_path("tasks", task_id, "respond"), body=body))
318
+
319
+ async def stop(self, task_id: str) -> Task:
320
+ return cast(Task, await self._client._request("POST", api_path("tasks", task_id, "stop"), body={}))
321
+
322
+ async def recording(self, task_id: str) -> Recording:
323
+ return cast(Recording, await self._client._request("GET", api_path("tasks", task_id, "recording")))
324
+
325
+ async def recording_frame(self, task_id: str, seq: int) -> bytes:
326
+ return cast(bytes, await self._client._request("GET", api_path("tasks", task_id, "recording", seq), raw=True))
327
+
328
+ async def stream(self, task_id: str, after: int = 0) -> AsyncIterator[Event]:
329
+ """Yields the task's events as they happen, and ends when the task is done (``async for``)."""
330
+ while True:
331
+ page = await self.events(task_id, after=after, wait_ms=LONG_POLL_MS)
332
+ for event in page["data"]:
333
+ yield event
334
+ after = page["cursor"]
335
+ if page["task"]["done"]:
336
+ return
337
+
338
+ async def run(
339
+ self,
340
+ session_id: str,
341
+ prompt: str,
342
+ *,
343
+ agent_id: Optional[str] = None,
344
+ on_event: Optional[Callable[[Event], MaybeAwaitable[Any]]] = None,
345
+ on_question: Optional[Callable[[QuestionPending], MaybeAwaitable[str]]] = None,
346
+ on_approval: Optional[Callable[[ApprovalPending], MaybeAwaitable[Union[bool, str, ApprovalDecision]]]] = None,
347
+ metadata: Optional[Metadata] = None,
348
+ request_id: Optional[str] = None,
349
+ timeout: Optional[float] = None,
350
+ ) -> Task:
351
+ """Starts a task and drives it to the end: see ``GuidingHand.tasks.run``. Handlers may be async."""
352
+ deadline = None if timeout is None else time.monotonic() + timeout
353
+ task = await self.create(session_id, prompt, agent_id=agent_id, request_id=request_id, metadata=metadata)
354
+ task_id = task["task_id"]
355
+ after = 0
356
+ answered: Set[str] = set()
357
+ while True:
358
+ wait_ms = run_wait_ms(deadline)
359
+ if wait_ms is None:
360
+ try:
361
+ task = await self.stop(task_id)
362
+ except GuidingHandError as e:
363
+ raise GuidingHandTimeout(
364
+ f"Task {task_id} wasn't done within {timeout:g} s, and stopping it failed: "
365
+ "it may still be running.",
366
+ task=task,
367
+ ) from e
368
+ if task["done"] and not task.get("interrupted"):
369
+ # It finished before the stop reached it: pass on the events not seen yet, and return it.
370
+ if on_event is not None:
371
+ for event in (await self.events(task_id, after=after))["data"]:
372
+ await _resolve(on_event(event))
373
+ return task
374
+ raise GuidingHandTimeout(f"Task {task_id} wasn't done within {timeout:g} s, so it was stopped.", task=task)
375
+ page = await self.events(task_id, after=after, wait_ms=wait_ms)
376
+ for event in page["data"]:
377
+ if on_event is not None:
378
+ await _resolve(on_event(event))
379
+ after = page["cursor"]
380
+ task = page["task"]
381
+ if task["done"]:
382
+ return task
383
+ pending = task.get("pending")
384
+ if pending and pending_key(pending) not in answered:
385
+ await self._answer(task, pending, on_question, on_approval)
386
+ answered.add(pending_key(pending))
387
+
388
+ async def _answer(
389
+ self,
390
+ task: Task,
391
+ pending: Pending,
392
+ on_question: Optional[Callable[[QuestionPending], MaybeAwaitable[str]]],
393
+ on_approval: Optional[Callable[[ApprovalPending], MaybeAwaitable[Union[bool, str, ApprovalDecision]]]],
394
+ ) -> None:
395
+ reply: Dict[str, Any]
396
+ if pending["type"] == "question":
397
+ if on_question is None:
398
+ raise NeedsInput(task)
399
+ reply = {"question_id": pending["question_id"], "answer": answer_of(await _resolve(on_question(pending)))}
400
+ else:
401
+ if on_approval is None:
402
+ raise NeedsInput(task)
403
+ reply = {"approval_id": pending["approval_id"], **approval_reply(await _resolve(on_approval(pending)))}
404
+ try:
405
+ await self.respond(task["task_id"], **reply)
406
+ except ConflictError:
407
+ pass # no longer pending (answered elsewhere, or the task ended): the next events call shows where it is
408
+
409
+
410
+ class AsyncWebhookEndpoint:
411
+ """``client.webhook``: see ``guidinghand.GuidingHand.webhook``."""
412
+
413
+ def __init__(self, client: AsyncGuidingHand) -> None:
414
+ self._client = client
415
+
416
+ async def retrieve(self) -> Webhook:
417
+ return cast(Webhook, await self._client._request("GET", "/v1/webhook"))
418
+
419
+ async def update(
420
+ self, url: Optional[str] = None, events: Optional[List[str]] = None, rotate_secret: bool = False
421
+ ) -> Webhook:
422
+ """Changes only what you pass (leave ``url`` or ``events`` out to keep them): see
423
+ ``GuidingHand.webhook.update``. To remove the endpoint, use ``delete()``."""
424
+ body = webhook_body(url, events, rotate_secret)
425
+ return cast(Webhook, await self._client._request("PUT", "/v1/webhook", body=body))
426
+
427
+ async def delete(self) -> Webhook:
428
+ return cast(Webhook, await self._client._request("PUT", "/v1/webhook", body={"url": None}))