actionbox-sdk 0.1.6__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.
actionbox/__init__.py ADDED
@@ -0,0 +1,1061 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import random
5
+ import secrets
6
+ import threading
7
+ import time
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass
10
+ from datetime import UTC, datetime
11
+ from email.utils import parsedate_to_datetime
12
+ from typing import Any, Literal, NotRequired, Self, TypedDict, cast
13
+ from urllib.parse import quote, urlsplit
14
+
15
+ import httpx
16
+
17
+ __version__ = "0.1.6"
18
+ _MAX_RESPONSE_BYTES = 1_048_576
19
+ _MAX_RETRY_DELAY = 30.0
20
+
21
+ OptionStyle = Literal["default", "primary", "destructive"]
22
+
23
+
24
+ class ActionOption(TypedDict):
25
+ """An option used by a single-choice or multi-choice interaction."""
26
+
27
+ id: str
28
+ label: str
29
+ style: NotRequired[OptionStyle]
30
+ sort_order: NotRequired[int]
31
+
32
+
33
+ ActionOptionInput = ActionOption
34
+
35
+
36
+ class BooleanInteraction(TypedDict):
37
+ type: Literal["boolean"]
38
+ label: str
39
+ true_label: NotRequired[str]
40
+ false_label: NotRequired[str]
41
+
42
+
43
+ class SingleChoiceInteraction(TypedDict):
44
+ type: Literal["single_choice"]
45
+ label: str
46
+ options: list[ActionOption]
47
+
48
+
49
+ class MultiChoiceInteraction(TypedDict):
50
+ type: Literal["multi_choice"]
51
+ label: str
52
+ options: list[ActionOption]
53
+ min_selections: NotRequired[int]
54
+ max_selections: NotRequired[int | None]
55
+
56
+
57
+ class TextInteraction(TypedDict):
58
+ type: Literal["text"]
59
+ label: str
60
+ placeholder: NotRequired[str | None]
61
+ multiline: NotRequired[bool]
62
+ min_length: NotRequired[int]
63
+ max_length: NotRequired[int]
64
+
65
+
66
+ class IntegerInteraction(TypedDict):
67
+ type: Literal["integer"]
68
+ label: str
69
+ min: NotRequired[int | None]
70
+ max: NotRequired[int | None]
71
+ step: NotRequired[int]
72
+ unit: NotRequired[str | None]
73
+
74
+
75
+ class NumberInteraction(TypedDict):
76
+ type: Literal["number"]
77
+ label: str
78
+ min: NotRequired[int | float | None]
79
+ max: NotRequired[int | float | None]
80
+ step: NotRequired[int | float]
81
+ unit: NotRequired[str | None]
82
+
83
+
84
+ class RatingInteraction(TypedDict):
85
+ type: Literal["rating"]
86
+ label: str
87
+ min: NotRequired[int]
88
+ max: NotRequired[int]
89
+ low_label: NotRequired[str | None]
90
+ high_label: NotRequired[str | None]
91
+
92
+
93
+ class FormBooleanField(BooleanInteraction):
94
+ id: str
95
+ required: NotRequired[bool]
96
+
97
+
98
+ class FormSingleChoiceField(SingleChoiceInteraction):
99
+ id: str
100
+ required: NotRequired[bool]
101
+
102
+
103
+ class FormMultiChoiceField(MultiChoiceInteraction):
104
+ id: str
105
+ required: NotRequired[bool]
106
+
107
+
108
+ class FormTextField(TextInteraction):
109
+ id: str
110
+ required: NotRequired[bool]
111
+
112
+
113
+ class FormIntegerField(IntegerInteraction):
114
+ id: str
115
+ required: NotRequired[bool]
116
+
117
+
118
+ class FormNumberField(NumberInteraction):
119
+ id: str
120
+ required: NotRequired[bool]
121
+
122
+
123
+ class FormRatingField(RatingInteraction):
124
+ id: str
125
+ required: NotRequired[bool]
126
+
127
+
128
+ FormField = (
129
+ FormBooleanField
130
+ | FormSingleChoiceField
131
+ | FormMultiChoiceField
132
+ | FormTextField
133
+ | FormIntegerField
134
+ | FormNumberField
135
+ | FormRatingField
136
+ )
137
+
138
+
139
+ class FormInteraction(TypedDict):
140
+ type: Literal["form"]
141
+ label: str
142
+ fields: list[FormField]
143
+
144
+
145
+ TypedInteraction = (
146
+ BooleanInteraction
147
+ | SingleChoiceInteraction
148
+ | MultiChoiceInteraction
149
+ | TextInteraction
150
+ | IntegerInteraction
151
+ | NumberInteraction
152
+ | RatingInteraction
153
+ | FormInteraction
154
+ )
155
+ Interaction = TypedInteraction
156
+
157
+
158
+ class BooleanResponse(TypedDict):
159
+ type: Literal["boolean"]
160
+ value: bool
161
+
162
+
163
+ class TextResponse(TypedDict):
164
+ type: Literal["text"]
165
+ value: str
166
+
167
+
168
+ class IntegerResponse(TypedDict):
169
+ type: Literal["integer"]
170
+ value: int
171
+
172
+
173
+ class NumberResponse(TypedDict):
174
+ type: Literal["number"]
175
+ value: int | float
176
+
177
+
178
+ class RatingResponse(TypedDict):
179
+ type: Literal["rating"]
180
+ value: int
181
+
182
+
183
+ class SingleChoiceResponse(TypedDict):
184
+ type: Literal["single_choice"]
185
+ value: str
186
+
187
+
188
+ class MultiChoiceResponse(TypedDict):
189
+ type: Literal["multi_choice"]
190
+ value: list[str]
191
+
192
+
193
+ class FormResponse(TypedDict):
194
+ type: Literal["form"]
195
+ values: dict[str, Any]
196
+
197
+
198
+ TypedResponse = (
199
+ BooleanResponse
200
+ | TextResponse
201
+ | IntegerResponse
202
+ | NumberResponse
203
+ | RatingResponse
204
+ | SingleChoiceResponse
205
+ | MultiChoiceResponse
206
+ | FormResponse
207
+ )
208
+ InteractionResponse = TypedResponse
209
+ Response = TypedResponse
210
+
211
+
212
+ class ActionCreateInput(TypedDict, total=False):
213
+ """The JSON fields accepted by ``Actionbox.create``."""
214
+
215
+ title: str
216
+ description: str
217
+ priority: Literal["low", "normal", "high", "urgent"]
218
+ open_url: str
219
+ dedupe_key: str
220
+ callback_url: str
221
+ expires_at: str
222
+ on_expire: dict[str, Any]
223
+ context: list[dict[str, Any]]
224
+ options: list[ActionOption]
225
+ interaction: TypedInteraction
226
+ metadata: dict[str, Any]
227
+ run_id: str
228
+
229
+
230
+ class ResolveInput(TypedDict, total=False):
231
+ """The JSON fields accepted by the generic resolve endpoint."""
232
+
233
+ action_version: int
234
+ fingerprint: str
235
+ option_id: str
236
+ response: TypedResponse
237
+ reason: str
238
+
239
+
240
+ class ActionOutcome(TypedDict):
241
+ id: str
242
+ action_id: str
243
+ status: Literal["success", "failed"]
244
+ duration_ms: int | None
245
+ rollback: bool
246
+ reason_code: str | None
247
+ action_version: int
248
+ fingerprint: str
249
+ created_at: str
250
+
251
+
252
+ class ActionboxError(RuntimeError):
253
+ def __init__(self, message: str, code: str = "UNKNOWN", status: int = 500) -> None:
254
+ super().__init__(message)
255
+ self.code = code
256
+ self.status = status
257
+
258
+
259
+ RunStatus = Literal["queued", "running", "waiting", "succeeded", "failed", "cancelled"]
260
+
261
+
262
+ @dataclass
263
+ class AgentRun:
264
+ """One autonomous task; progress sequencing is handled for the caller."""
265
+
266
+ client: Actionbox
267
+ data: dict[str, Any]
268
+
269
+ @property
270
+ def id(self) -> str:
271
+ return self.data["id"]
272
+
273
+ @property
274
+ def status(self) -> RunStatus:
275
+ return cast(RunStatus, self.data["status"])
276
+
277
+ @property
278
+ def progress_sequence(self) -> int:
279
+ return int(self.data.get("progress_sequence", 0))
280
+
281
+ @property
282
+ def stalled(self) -> bool:
283
+ return bool(self.data.get("stalled", False))
284
+
285
+ @property
286
+ def stall_action_id(self) -> str | None:
287
+ value = self.data.get("stall_action_id")
288
+ return value if isinstance(value, str) else None
289
+
290
+ def progress(
291
+ self,
292
+ *,
293
+ status: Literal["running", "waiting"] | None = None,
294
+ stage: str | None = None,
295
+ checkpoint: str | None = None,
296
+ cancel_event: threading.Event | None = None,
297
+ ) -> AgentRun:
298
+ payload: dict[str, Any] = {"sequence": self.progress_sequence + 1}
299
+ if status is not None:
300
+ payload["status"] = status
301
+ if stage is not None:
302
+ payload["stage"] = stage
303
+ if checkpoint is not None:
304
+ payload["checkpoint"] = checkpoint
305
+ if len(payload) == 1:
306
+ raise ValueError("provide status, stage, or checkpoint")
307
+ self.data = cast(
308
+ dict[str, Any],
309
+ self.client._request(
310
+ "PATCH",
311
+ f"/v1/source/runs/{quote(self.id, safe='')}/progress",
312
+ json=payload,
313
+ headers={"Idempotency-Key": f"run-progress-{self.id}-{payload['sequence']}"},
314
+ cancel_event=cancel_event,
315
+ ),
316
+ )
317
+ return self
318
+
319
+ def complete(
320
+ self,
321
+ status: Literal["succeeded", "failed", "cancelled"] = "succeeded",
322
+ *,
323
+ reason_code: str | None = None,
324
+ cancel_event: threading.Event | None = None,
325
+ ) -> AgentRun:
326
+ self.data = cast(
327
+ dict[str, Any],
328
+ self.client._request(
329
+ "POST",
330
+ f"/v1/source/runs/{quote(self.id, safe='')}/complete",
331
+ json={
332
+ "status": status,
333
+ "progress_sequence": self.progress_sequence,
334
+ "reason_code": reason_code,
335
+ },
336
+ headers={"Idempotency-Key": f"run-complete-{self.id}"},
337
+ cancel_event=cancel_event,
338
+ ),
339
+ )
340
+ return self
341
+
342
+
343
+ class RunsResource:
344
+ def __init__(self, client: Actionbox) -> None:
345
+ self.client = client
346
+
347
+ def start(
348
+ self,
349
+ *,
350
+ external_id: str,
351
+ agent_name: str,
352
+ title: str,
353
+ cancel_event: threading.Event | None = None,
354
+ **payload: Any,
355
+ ) -> AgentRun:
356
+ data = self.client._request(
357
+ "POST",
358
+ "/v1/runs",
359
+ json={
360
+ "external_id": external_id,
361
+ "agent_name": agent_name,
362
+ "title": title,
363
+ **payload,
364
+ },
365
+ headers={"Idempotency-Key": f"run-start-{external_id}"},
366
+ cancel_event=cancel_event,
367
+ )
368
+ return AgentRun(self.client, cast(dict[str, Any], data))
369
+
370
+ def get(
371
+ self,
372
+ run_id: str,
373
+ *,
374
+ cancel_event: threading.Event | None = None,
375
+ ) -> AgentRun:
376
+ data = self.client._request(
377
+ "GET",
378
+ f"/v1/source/runs/{quote(run_id, safe='')}",
379
+ cancel_event=cancel_event,
380
+ )
381
+ return AgentRun(self.client, cast(dict[str, Any], data))
382
+
383
+
384
+ WatchStatus = Literal["new", "healthy", "down", "paused"]
385
+ WatchScheduleType = Literal["interval", "cron"]
386
+ WatchSignalMethod = Literal["any", "post"]
387
+
388
+
389
+ class WatchCreateInput(TypedDict, total=False):
390
+ source_id: str
391
+ name: str
392
+ schedule_type: WatchScheduleType
393
+ interval_seconds: int
394
+ cron_expression: str
395
+ timezone: str
396
+ grace_seconds: int
397
+ max_runtime_seconds: int
398
+ priority: Literal["low", "normal", "high", "urgent"]
399
+ runbook_url: str
400
+ signal_method: WatchSignalMethod
401
+
402
+
403
+ @dataclass
404
+ class Watch:
405
+ """Safe Watch state. The raw heartbeat URL is only present on creation."""
406
+
407
+ client: Actionbox
408
+ data: dict[str, Any]
409
+
410
+ @property
411
+ def id(self) -> str:
412
+ return self.data["id"]
413
+
414
+ @property
415
+ def name(self) -> str:
416
+ return self.data["name"]
417
+
418
+ @property
419
+ def status(self) -> str:
420
+ return self.data["status"]
421
+
422
+ @property
423
+ def heartbeat_url(self) -> str | None:
424
+ value = self.data.get("heartbeat_url")
425
+ return value if isinstance(value, str) else None
426
+
427
+
428
+ class WatchesResource:
429
+ def __init__(self, client: Actionbox) -> None:
430
+ self.client = client
431
+
432
+ def list(self, *, cancel_event: threading.Event | None = None) -> list[Watch]:
433
+ data = self.client._request("GET", "/v1/source/watches", cancel_event=cancel_event)
434
+ if not isinstance(data, list):
435
+ raise ActionboxError("Actionbox returned an invalid Watch list.", "INVALID_RESPONSE", 200)
436
+ return [Watch(self.client, item) for item in data if isinstance(item, dict)]
437
+
438
+ def create(self, *, cancel_event: threading.Event | None = None, **payload: Any) -> Watch:
439
+ data = self.client._request(
440
+ "POST",
441
+ "/v1/source/watches",
442
+ json=payload,
443
+ cancel_event=cancel_event,
444
+ )
445
+ return Watch(self.client, data)
446
+
447
+ def pause(self, watch_id: str, *, cancel_event: threading.Event | None = None) -> Watch:
448
+ data = self.client._request(
449
+ "POST",
450
+ f"/v1/source/watches/{quote(watch_id, safe='')}/pause",
451
+ cancel_event=cancel_event,
452
+ )
453
+ return Watch(self.client, data)
454
+
455
+ def resume(self, watch_id: str, *, cancel_event: threading.Event | None = None) -> Watch:
456
+ data = self.client._request(
457
+ "POST",
458
+ f"/v1/source/watches/{quote(watch_id, safe='')}/resume",
459
+ cancel_event=cancel_event,
460
+ )
461
+ return Watch(self.client, data)
462
+
463
+ def rotate_token(self, watch_id: str, *, cancel_event: threading.Event | None = None) -> Watch:
464
+ data = self.client._request(
465
+ "POST",
466
+ f"/v1/source/watches/{quote(watch_id, safe='')}/token/rotate",
467
+ cancel_event=cancel_event,
468
+ )
469
+ return Watch(self.client, data)
470
+
471
+ def archive(self, watch_id: str, *, cancel_event: threading.Event | None = None) -> None:
472
+ self.client._request(
473
+ "DELETE",
474
+ f"/v1/source/watches/{quote(watch_id, safe='')}",
475
+ cancel_event=cancel_event,
476
+ )
477
+
478
+
479
+ @dataclass
480
+ class Action:
481
+ client: Actionbox
482
+ data: dict[str, Any]
483
+
484
+ @property
485
+ def id(self) -> str:
486
+ return self.data["id"]
487
+
488
+ @property
489
+ def status(self) -> str:
490
+ return self.data["status"]
491
+
492
+ @property
493
+ def environment(self) -> str:
494
+ value = self.data.get("environment", "live")
495
+ return value if value in {"live", "test"} else "live"
496
+
497
+ @property
498
+ def action_version(self) -> int:
499
+ return int(self.data.get("action_version", 1))
500
+
501
+ @property
502
+ def fingerprint(self) -> str | None:
503
+ value = self.data.get("fingerprint")
504
+ return value if isinstance(value, str) else None
505
+
506
+ @property
507
+ def decision(self) -> str | None:
508
+ return self.data.get("resolution_option_id")
509
+
510
+ @property
511
+ def interaction(self) -> TypedInteraction | None:
512
+ return cast(TypedInteraction | None, self.data.get("interaction"))
513
+
514
+ @property
515
+ def response(self) -> TypedResponse | None:
516
+ return cast(TypedResponse | None, self.data.get("response"))
517
+
518
+ @property
519
+ def receipt(self) -> str | None:
520
+ value = self.data.get("receipt")
521
+ return value if isinstance(value, str) else None
522
+
523
+ @property
524
+ def outcome(self) -> ActionOutcome | None:
525
+ return cast(ActionOutcome | None, self.data.get("outcome"))
526
+
527
+ def report_outcome(
528
+ self,
529
+ status: Literal["success", "failed"],
530
+ *,
531
+ duration_ms: int | None = None,
532
+ rollback: bool = False,
533
+ reason_code: str | None = None,
534
+ cancel_event: threading.Event | None = None,
535
+ ) -> ActionOutcome:
536
+ fingerprint = self.fingerprint
537
+ if fingerprint is None:
538
+ raise ValueError("the Action has no fingerprint to bind the outcome")
539
+ return self.client.report_outcome(
540
+ self.id,
541
+ status,
542
+ action_version=self.action_version,
543
+ fingerprint=fingerprint,
544
+ duration_ms=duration_ms,
545
+ rollback=rollback,
546
+ reason_code=reason_code,
547
+ cancel_event=cancel_event,
548
+ )
549
+
550
+ def refresh(self, *, cancel_event: threading.Event | None = None) -> Action:
551
+ self.data = self.client.get(self.id, cancel_event=cancel_event).data
552
+ return self
553
+
554
+ def wait(
555
+ self,
556
+ timeout: float = 3600,
557
+ poll_interval: float = 2,
558
+ *,
559
+ cancel_event: threading.Event | None = None,
560
+ ) -> str | TypedResponse | None:
561
+ if timeout <= 0:
562
+ raise ValueError("timeout must be greater than zero")
563
+ if poll_interval <= 0:
564
+ raise ValueError("poll_interval must be greater than zero")
565
+ deadline = time.monotonic() + timeout
566
+ while self.status == "open" and time.monotonic() < deadline:
567
+ remaining = deadline - time.monotonic()
568
+ if remaining <= 0:
569
+ break
570
+ wait_sec = min(int(max(1, min(remaining, poll_interval * 5))), 30)
571
+ if cancel_event is not None and cancel_event.is_set():
572
+ raise ActionboxError("Actionbox wait was cancelled.", "ABORTED", 0)
573
+ self.data = self.client.get(self.id, wait_seconds=wait_sec, cancel_event=cancel_event).data
574
+ if self.status != "open" or time.monotonic() >= deadline:
575
+ break
576
+ if self.status == "open":
577
+ return None
578
+ # Return a concise string for single-choice Actions and the typed
579
+ # response for boolean, numeric, text, multi-choice, and form inputs.
580
+ return self.decision if self.decision is not None else self.response
581
+
582
+
583
+ class ActionsResource:
584
+ def __init__(self, client: Actionbox) -> None:
585
+ self.client = client
586
+
587
+ def create(self, **payload: Any) -> Action:
588
+ return self.client.create(**payload)
589
+
590
+ def resolve(
591
+ self,
592
+ action_id: str,
593
+ option_id: str | ResolveInput | TypedResponse | None = None,
594
+ reason: str | None = None,
595
+ *,
596
+ response: TypedResponse | None = None,
597
+ action_version: int | None = None,
598
+ fingerprint: str | None = None,
599
+ ) -> Action:
600
+ return self.client.resolve(
601
+ action_id,
602
+ option_id,
603
+ reason,
604
+ response=response,
605
+ action_version=action_version,
606
+ fingerprint=fingerprint,
607
+ )
608
+
609
+ def report_outcome(
610
+ self,
611
+ action_id: str,
612
+ status: Literal["success", "failed"],
613
+ *,
614
+ action_version: int,
615
+ fingerprint: str,
616
+ duration_ms: int | None = None,
617
+ rollback: bool = False,
618
+ reason_code: str | None = None,
619
+ ) -> ActionOutcome:
620
+ return self.client.report_outcome(
621
+ action_id,
622
+ status,
623
+ action_version=action_version,
624
+ fingerprint=fingerprint,
625
+ duration_ms=duration_ms,
626
+ rollback=rollback,
627
+ reason_code=reason_code,
628
+ )
629
+
630
+
631
+ class Actionbox:
632
+ """Small synchronous client for source-authenticated Actionbox APIs."""
633
+
634
+ def __init__(
635
+ self,
636
+ api_key: str,
637
+ base_url: str = "https://api.actionbox.cloud",
638
+ timeout: float = 10,
639
+ max_retries: int = 2,
640
+ *,
641
+ http_client: httpx.Client | None = None,
642
+ ) -> None:
643
+ if not api_key or any(character.isspace() for character in api_key):
644
+ raise ValueError("api_key must be a non-empty token without whitespace")
645
+ if timeout <= 0:
646
+ raise ValueError("timeout must be greater than zero")
647
+ if not isinstance(max_retries, int) or not 0 <= max_retries <= 5:
648
+ raise ValueError("max_retries must be an integer from 0 through 5")
649
+ self.base_url = _validate_base_url(base_url)
650
+ self.http = http_client or httpx.Client(timeout=timeout)
651
+ self._owns_http_client = http_client is None
652
+ self.timeout = timeout
653
+ self.max_retries = max_retries
654
+ self.headers = {
655
+ "Authorization": f"Bearer {api_key}",
656
+ "X-Actionbox-Client": f"python-sdk/{__version__}",
657
+ }
658
+ self.actions = ActionsResource(self)
659
+ self.watches = WatchesResource(self)
660
+ self.runs = RunsResource(self)
661
+
662
+ def close(self) -> None:
663
+ if self._owns_http_client:
664
+ self.http.close()
665
+
666
+ def __enter__(self) -> Self:
667
+ return self
668
+
669
+ def __exit__(self, *_: object) -> None:
670
+ self.close()
671
+
672
+ def _request(
673
+ self,
674
+ method: str,
675
+ path: str,
676
+ *,
677
+ json: dict[str, Any] | None = None,
678
+ headers: dict[str, str] | None = None,
679
+ cancel_event: threading.Event | None = None,
680
+ timeout: float | None = None,
681
+ ) -> dict[str, Any] | list[Any]:
682
+ request_headers = {**self.headers, **(headers or {})}
683
+ retryable = method.upper() in {"GET", "HEAD", "OPTIONS"} or "Idempotency-Key" in request_headers
684
+ attempts = self.max_retries + 1 if retryable else 1
685
+
686
+ for attempt in range(attempts):
687
+ _raise_if_cancelled(cancel_event)
688
+ request = self.http.build_request(
689
+ method,
690
+ f"{self.base_url}{path}",
691
+ json=json,
692
+ headers=request_headers,
693
+ timeout=timeout if timeout is not None else self.timeout,
694
+ )
695
+ try:
696
+ response = self.http.send(request, stream=True)
697
+ except httpx.TimeoutException as exc:
698
+ if retryable and attempt + 1 < attempts:
699
+ _wait_before_retry(attempt, None, cancel_event)
700
+ continue
701
+ raise ActionboxError("Actionbox request timed out.", "TIMEOUT", 0) from exc
702
+ except httpx.TransportError as exc:
703
+ if retryable and attempt + 1 < attempts:
704
+ _wait_before_retry(attempt, None, cancel_event)
705
+ continue
706
+ raise ActionboxError("Actionbox network request failed.", "NETWORK_ERROR", 0) from exc
707
+
708
+ try:
709
+ raw_body = _read_bounded_body(response)
710
+ finally:
711
+ response.close()
712
+
713
+ if retryable and attempt + 1 < attempts and (response.status_code == 429 or response.status_code >= 500):
714
+ _wait_before_retry(attempt, response.headers.get("Retry-After"), cancel_event)
715
+ continue
716
+
717
+ body = _parse_response_body(raw_body, response.status_code)
718
+ if not response.is_success:
719
+ error = body.get("error", {}) if isinstance(body.get("error"), dict) else {}
720
+ detail = body.get("detail") if isinstance(body.get("detail"), str) else None
721
+ raise ActionboxError(
722
+ error.get("message", detail or "Actionbox request failed."),
723
+ error.get("code", "UNKNOWN"),
724
+ response.status_code,
725
+ )
726
+ data = body.get("data", body)
727
+ if not isinstance(data, (dict, list)):
728
+ raise ActionboxError("Actionbox returned an invalid response envelope.", "INVALID_RESPONSE", response.status_code)
729
+ return data
730
+
731
+ raise ActionboxError("Actionbox request failed after retries.", "RETRY_EXHAUSTED", 0)
732
+
733
+ def create(
734
+ self,
735
+ *,
736
+ title: str,
737
+ description: str = "",
738
+ options: list[ActionOption] | None = None,
739
+ interaction: TypedInteraction | None = None,
740
+ idempotency_key: str | None = None,
741
+ **payload: Any,
742
+ ) -> Action:
743
+ if options is not None and interaction is not None:
744
+ raise ValueError("provide options or interaction, not both")
745
+ request_headers = {"Idempotency-Key": idempotency_key or f"sdk-{secrets.token_urlsafe(18)}"}
746
+ request_payload: dict[str, Any] = {"title": title, "description": description, **payload}
747
+ if interaction is not None:
748
+ request_payload["interaction"] = interaction
749
+ else:
750
+ request_payload["options"] = options or []
751
+ data = self._request(
752
+ "POST",
753
+ "/v1/actions",
754
+ json=request_payload,
755
+ headers=request_headers,
756
+ )
757
+ return Action(self, data)
758
+
759
+ def get(
760
+ self,
761
+ action_id: str,
762
+ *,
763
+ wait_seconds: int = 0,
764
+ cancel_event: threading.Event | None = None,
765
+ ) -> Action:
766
+ encoded_id = quote(action_id, safe="")
767
+ path = f"/v1/source/actions/{encoded_id}"
768
+ if wait_seconds > 0:
769
+ path = f"{path}?wait_seconds={min(int(wait_seconds), 30)}"
770
+ request_timeout = self.timeout
771
+ if wait_seconds > 0:
772
+ request_timeout = max(request_timeout, min(wait_seconds, 30) + 5)
773
+ return Action(
774
+ self,
775
+ self._request(
776
+ "GET",
777
+ path,
778
+ cancel_event=cancel_event,
779
+ timeout=request_timeout,
780
+ ),
781
+ )
782
+
783
+ def resolve(
784
+ self,
785
+ action_id: str,
786
+ option_id: str | ResolveInput | TypedResponse | None = None,
787
+ reason: str | None = None,
788
+ *,
789
+ response: TypedResponse | None = None,
790
+ action_version: int | None = None,
791
+ fingerprint: str | None = None,
792
+ ) -> Action:
793
+ if isinstance(option_id, Mapping):
794
+ generic_input = dict(option_id)
795
+ if "type" in generic_input and "response" not in generic_input:
796
+ response = cast(TypedResponse, generic_input)
797
+ option_id = None
798
+ else:
799
+ response = cast(TypedResponse | None, generic_input.get("response", response))
800
+ reason = cast(str | None, generic_input.get("reason", reason))
801
+ option_id = cast(str | None, generic_input.get("option_id"))
802
+ action_version = cast(int | None, generic_input.get("action_version", action_version))
803
+ fingerprint = cast(str | None, generic_input.get("fingerprint", fingerprint))
804
+
805
+ request_payload: dict[str, Any] = {"option_id": option_id, "reason": reason}
806
+ if action_version is not None:
807
+ request_payload["action_version"] = action_version
808
+ if fingerprint is not None:
809
+ request_payload["fingerprint"] = fingerprint
810
+ if response is not None:
811
+ request_payload["response"] = response
812
+ return Action(
813
+ self,
814
+ self._request(
815
+ "POST",
816
+ f"/v1/actions/{quote(action_id, safe='')}/resolve",
817
+ json=request_payload,
818
+ ),
819
+ )
820
+
821
+ def cancel(self, action_id: str, reason: str | None = None) -> Action:
822
+ return Action(self, self._request("POST", f"/v1/actions/{quote(action_id, safe='')}/cancel", json={"reason": reason}))
823
+
824
+ def report_outcome(
825
+ self,
826
+ action_id: str,
827
+ status: Literal["success", "failed"],
828
+ *,
829
+ action_version: int,
830
+ fingerprint: str,
831
+ duration_ms: int | None = None,
832
+ rollback: bool = False,
833
+ reason_code: str | None = None,
834
+ cancel_event: threading.Event | None = None,
835
+ ) -> ActionOutcome:
836
+ data = self._request(
837
+ "POST",
838
+ f"/v1/actions/{quote(action_id, safe='')}/outcome",
839
+ json={
840
+ "status": status,
841
+ "duration_ms": duration_ms,
842
+ "rollback": rollback,
843
+ "reason_code": reason_code,
844
+ "action_version": action_version,
845
+ "fingerprint": fingerprint,
846
+ },
847
+ # Exact outcome replays are idempotent server-side, so transport
848
+ # retries are safe even if the first response was lost.
849
+ headers={"Idempotency-Key": f"outcome-{action_id}"},
850
+ cancel_event=cancel_event,
851
+ )
852
+ return cast(ActionOutcome, data)
853
+
854
+ def ask(
855
+ self,
856
+ *,
857
+ title: str,
858
+ options: list[str] | None = None,
859
+ interaction: TypedInteraction | None = None,
860
+ wait: bool = True,
861
+ timeout: float = 3600,
862
+ poll_interval: float = 2,
863
+ cancel_event: threading.Event | None = None,
864
+ **payload: Any,
865
+ ) -> str | TypedResponse | Action | None:
866
+ if options is None and interaction is None:
867
+ raise ValueError("provide options or interaction")
868
+ if options is not None and interaction is not None:
869
+ raise ValueError("provide options or interaction, not both")
870
+ normalized = (
871
+ [{"id": option.lower().replace(" ", "-"), "label": option} for option in options]
872
+ if options is not None
873
+ else None
874
+ )
875
+ action = self.create(title=title, options=normalized, interaction=interaction, **payload)
876
+ return action.wait(timeout=timeout, poll_interval=poll_interval, cancel_event=cancel_event) if wait else action
877
+
878
+
879
+ def send_heartbeat(
880
+ heartbeat_url: str,
881
+ signal: Literal["ping", "start", "success", "fail"] = "ping",
882
+ *,
883
+ timeout: float = 10,
884
+ cancel_event: threading.Event | None = None,
885
+ ) -> None:
886
+ """Deliver one Watch heartbeat without placing its capability in errors."""
887
+ _raise_if_cancelled(cancel_event)
888
+ if timeout <= 0:
889
+ raise ValueError("timeout must be greater than zero")
890
+ if signal not in {"ping", "start", "success", "fail"}:
891
+ raise ValueError("signal must be ping, start, success, or fail")
892
+ parsed = urlsplit(heartbeat_url.strip())
893
+ segments = [part for part in parsed.path.split("/") if part]
894
+ if (
895
+ len(segments) != 2
896
+ or segments[0] != "hb"
897
+ or not segments[1].startswith("hb_")
898
+ or parsed.username
899
+ or parsed.password
900
+ or parsed.query
901
+ or parsed.fragment
902
+ or not parsed.hostname
903
+ ):
904
+ raise ValueError("heartbeat_url must be an Actionbox heartbeat URL")
905
+ try:
906
+ address = ipaddress.ip_address(parsed.hostname)
907
+ loopback = address.is_loopback
908
+ except ValueError:
909
+ loopback = parsed.hostname.casefold() == "localhost"
910
+ if parsed.scheme != "https" and not (parsed.scheme == "http" and loopback):
911
+ raise ValueError("heartbeat_url must use HTTPS unless it targets localhost")
912
+ path = parsed.path if signal == "ping" else f"{parsed.path.rstrip('/')}/{signal}"
913
+ url = f"{parsed.scheme}://{parsed.netloc}{path}"
914
+ try:
915
+ response = httpx.post(url, timeout=timeout)
916
+ except httpx.HTTPError as exc:
917
+ raise ActionboxError("Heartbeat delivery failed.", "HEARTBEAT_FAILED", 0) from exc
918
+ if response.status_code != 204:
919
+ raise ActionboxError(
920
+ f"Heartbeat delivery failed with HTTP {response.status_code}.",
921
+ "HEARTBEAT_FAILED",
922
+ response.status_code,
923
+ )
924
+
925
+
926
+ def _validate_base_url(value: str) -> str:
927
+ parsed = urlsplit(value.strip())
928
+ if not parsed.scheme or not parsed.hostname:
929
+ raise ValueError("base_url must be a full HTTPS URL")
930
+ try:
931
+ address = ipaddress.ip_address(parsed.hostname)
932
+ loopback = address.is_loopback
933
+ except ValueError:
934
+ loopback = parsed.hostname.lower() == "localhost"
935
+ if parsed.scheme != "https" and not (parsed.scheme == "http" and loopback):
936
+ raise ValueError("base_url must use HTTPS unless it targets localhost")
937
+ if parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in {"", "/"}:
938
+ raise ValueError("base_url must not contain credentials, a path, query parameters, or a fragment")
939
+ port = f":{parsed.port}" if parsed.port is not None else ""
940
+ host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname
941
+ return f"{parsed.scheme}://{host}{port}"
942
+
943
+
944
+ def _read_bounded_body(response: httpx.Response) -> bytes:
945
+ chunks: list[bytes] = []
946
+ size = 0
947
+ for chunk in response.iter_bytes():
948
+ size += len(chunk)
949
+ if size > _MAX_RESPONSE_BYTES:
950
+ raise ActionboxError(
951
+ f"Actionbox response exceeds {_MAX_RESPONSE_BYTES} bytes.",
952
+ "RESPONSE_TOO_LARGE",
953
+ response.status_code,
954
+ )
955
+ chunks.append(chunk)
956
+ return b"".join(chunks)
957
+
958
+
959
+ def _parse_response_body(raw: bytes, status: int) -> dict[str, Any]:
960
+ if not raw:
961
+ return {}
962
+ try:
963
+ body = httpx.Response(status, content=raw).json()
964
+ except ValueError as exc:
965
+ raise ActionboxError("Actionbox returned invalid JSON.", "INVALID_RESPONSE", status) from exc
966
+ if not isinstance(body, dict):
967
+ raise ActionboxError("Actionbox returned an invalid response envelope.", "INVALID_RESPONSE", status)
968
+ return cast(dict[str, Any], body)
969
+
970
+
971
+ def _retry_after_seconds(value: str | None) -> float | None:
972
+ if not value:
973
+ return None
974
+ try:
975
+ seconds = float(value)
976
+ return min(max(seconds, 0.0), _MAX_RETRY_DELAY)
977
+ except ValueError:
978
+ pass
979
+ try:
980
+ timestamp = parsedate_to_datetime(value)
981
+ except (TypeError, ValueError):
982
+ return None
983
+ if timestamp.tzinfo is None:
984
+ timestamp = timestamp.replace(tzinfo=UTC)
985
+ return min(max((timestamp - datetime.now(UTC)).total_seconds(), 0.0), _MAX_RETRY_DELAY)
986
+
987
+
988
+ def _raise_if_cancelled(cancel_event: threading.Event | None) -> None:
989
+ if cancel_event is not None and cancel_event.is_set():
990
+ raise ActionboxError("Actionbox request was cancelled.", "ABORTED", 0)
991
+
992
+
993
+ def _wait_before_retry(
994
+ attempt: int,
995
+ retry_after: str | None,
996
+ cancel_event: threading.Event | None,
997
+ ) -> None:
998
+ server_delay = _retry_after_seconds(retry_after)
999
+ base = 0.25 * (2**attempt)
1000
+ delay = server_delay if server_delay is not None else base + random.uniform(0, base / 2)
1001
+ if cancel_event is not None:
1002
+ if cancel_event.wait(delay):
1003
+ raise ActionboxError("Actionbox request was cancelled.", "ABORTED", 0)
1004
+ else:
1005
+ time.sleep(delay)
1006
+
1007
+
1008
+ __all__ = [
1009
+ "__version__",
1010
+ "Action",
1011
+ "ActionCreateInput",
1012
+ "ActionOption",
1013
+ "ActionOptionInput",
1014
+ "ActionOutcome",
1015
+ "AgentRun",
1016
+ "Actionbox",
1017
+ "ActionboxError",
1018
+ "ActionsResource",
1019
+ "Watch",
1020
+ "WatchCreateInput",
1021
+ "WatchScheduleType",
1022
+ "WatchStatus",
1023
+ "WatchesResource",
1024
+ "BooleanInteraction",
1025
+ "BooleanResponse",
1026
+ "FormBooleanField",
1027
+ "FormField",
1028
+ "FormIntegerField",
1029
+ "FormInteraction",
1030
+ "FormMultiChoiceField",
1031
+ "FormNumberField",
1032
+ "FormResponse",
1033
+ "FormSingleChoiceField",
1034
+ "FormTextField",
1035
+ "IntegerInteraction",
1036
+ "IntegerResponse",
1037
+ "Interaction",
1038
+ "InteractionResponse",
1039
+ "MultiChoiceInteraction",
1040
+ "MultiChoiceResponse",
1041
+ "NumberInteraction",
1042
+ "NumberResponse",
1043
+ "OptionStyle",
1044
+ "ResolveInput",
1045
+ "RunsResource",
1046
+ "RunStatus",
1047
+ "Response",
1048
+ "SingleChoiceInteraction",
1049
+ "SingleChoiceResponse",
1050
+ "TextInteraction",
1051
+ "TextResponse",
1052
+ "TypedInteraction",
1053
+ "TypedResponse",
1054
+ "Watch",
1055
+ "WatchCreateInput",
1056
+ "WatchScheduleType",
1057
+ "WatchStatus",
1058
+ "WatchesResource",
1059
+ "__version__",
1060
+ "send_heartbeat",
1061
+ ]
@@ -0,0 +1,167 @@
1
+ Metadata-Version: 2.4
2
+ Name: actionbox-sdk
3
+ Version: 0.1.6
4
+ Summary: Python client for Actionbox durable human decisions
5
+ License-Expression: MIT
6
+ Project-URL: Documentation, https://actionbox.cloud/docs
7
+ Project-URL: API, https://api.actionbox.cloud/docs
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: httpx>=0.27
12
+ Dynamic: license-file
13
+
14
+ <img src="https://actionbox.cloud/appbox.svg" width="64" alt="Actionbox logo">
15
+
16
+ # Actionbox Python SDK
17
+
18
+ Actionbox gives backend services a durable, server-authoritative way to ask a
19
+ human for a decision and continue when that decision is available. This package
20
+ is the typed Python client for creating, resolving, and waiting on Actions,
21
+ plus managing source-scoped heartbeat Watches.
22
+
23
+ ## Documentation
24
+
25
+ - [Actionbox documentation](https://actionbox.cloud/docs)
26
+
27
+ ## Requirements
28
+
29
+ - Python 3.11 or newer
30
+ - An Actionbox Source API key, supplied through `ACTIONBOX_API_KEY`
31
+
32
+ Keep API keys and Watch capability URLs on trusted servers, workers, or CI
33
+ jobs. Do not put this SDK or its credentials in browser code.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install actionbox-sdk
39
+ ```
40
+
41
+ The distribution is named `actionbox-sdk` so it does not conflict with the
42
+ Actionbox CLI distribution on PyPI. The Python import remains `actionbox`.
43
+
44
+ ```python
45
+ import os
46
+ from actionbox import Actionbox
47
+
48
+ with Actionbox(os.environ["ACTIONBOX_API_KEY"]) as client:
49
+ decision = client.ask(
50
+ title="Deploy to production?",
51
+ options=["Approve", "Reject"],
52
+ callback_url="https://ci.example.com/actionbox",
53
+ )
54
+ print(decision)
55
+ ```
56
+
57
+ The SDK uses the hosted production API at `https://api.actionbox.cloud` by
58
+ default. Customer integrations should use that default and only pass
59
+ `base_url` in maintainer-controlled test environments.
60
+
61
+ `ask(..., wait=False)` returns an `Action`; `Action.wait()` polls the server and leaves the Action open when the local timeout expires. The concise single-choice API returns the selected option ID as a string.
62
+
63
+ ## Typed interactions and responses
64
+
65
+ The SDK exports typed interaction and response contracts that match the REST API. Use an explicit typed interaction with `create` or `ask` when the human response is more than a single choice:
66
+
67
+ ```python
68
+ from actionbox import Actionbox, BooleanInteraction
69
+
70
+ with Actionbox(os.environ["ACTIONBOX_API_KEY"]) as client:
71
+ action = client.create(
72
+ title="Deploy configuration",
73
+ interaction=BooleanInteraction(
74
+ type="boolean",
75
+ label="Deploy now?",
76
+ true_label="Deploy",
77
+ false_label="Hold",
78
+ ),
79
+ )
80
+ resolved = client.resolve(
81
+ action.id,
82
+ response={"type": "boolean", "value": True},
83
+ reason="Approved by release manager",
84
+ )
85
+ print(resolved.response) # {"type": "boolean", "value": True}
86
+ ```
87
+
88
+ The available interaction types are `boolean`, `single_choice`, `multi_choice`, `text`, `integer`, `number`, `rating`, and `form`. Form fields use the same typed field shapes and are returned as `{"type": "form", "values": {...}}`. Create inputs also accept bounded developer `context` blocks and an explicit typed `on_expire` fallback; omitting it returns `expired` without inventing a response.
89
+
90
+ `resolve` supports concise single-choice syntax and generic typed input:
91
+
92
+ ```python
93
+ client.resolve(action.id, "approve") # single-choice shorthand
94
+ client.resolve(action.id, {"response": {"type": "text", "value": "ship"}})
95
+ client.actions.resolve(action.id, response={"type": "number", "value": 4.5})
96
+ ```
97
+
98
+ `Action.interaction` and `Action.response` expose the canonical typed wire values. `options`, `option_id`, `ask(..., options=[...])`, and string decision results are first-class single-choice conveniences.
99
+
100
+ ## Execution outcomes
101
+
102
+ After carrying out an approved operation, report its real result from the
103
+ resolved Action snapshot:
104
+
105
+ ```python
106
+ outcome = resolved.report_outcome(
107
+ "success",
108
+ duration_ms=48_312,
109
+ rollback=False,
110
+ )
111
+ ```
112
+
113
+ The SDK sends the Action's exact version and fingerprint. Exact retries are
114
+ safe; Actionbox rejects a conflicting second outcome.
115
+
116
+ ## Agent Runs
117
+
118
+ Group an agent task and its Actions without managing another framework:
119
+
120
+ ```python
121
+ run = client.runs.start(
122
+ external_id="checkout-fix-42",
123
+ agent_name="codex",
124
+ title="Fix checkout deadlock",
125
+ stall_after_seconds=900,
126
+ )
127
+ run.progress(stage="tests", checkpoint="test-184")
128
+ action = client.create(title="Approve staging migration", run_id=run.id)
129
+ run.complete()
130
+ ```
131
+
132
+ The SDK handles progress sequence numbers. Runs are optional; standalone
133
+ Actions continue to work exactly as before. When `stall_after_seconds` is set,
134
+ unchanged status/stage/checkpoint updates do not reset the timer. Actionbox
135
+ creates one ordinary Action if progress stalls and resolves it when progress
136
+ changes or the Run completes; `waiting` pauses the timer.
137
+
138
+ ## Heartbeat Watches
139
+
140
+ Source credentials can create and list Watches scoped to that Source. The raw heartbeat URL is returned only by creation:
141
+
142
+ ```python
143
+ from actionbox import Actionbox, send_heartbeat
144
+
145
+ with Actionbox(os.environ["ACTIONBOX_API_KEY"]) as client:
146
+ watch = client.watches.create(
147
+ source_id="src_…",
148
+ name="Nightly backup",
149
+ schedule_type="interval",
150
+ interval_seconds=3600,
151
+ grace_seconds=60,
152
+ signal_method="post",
153
+ )
154
+ send_heartbeat(watch.heartbeat_url, "start")
155
+ ```
156
+
157
+ Use `send_heartbeat` for `ping`, `start`, `success`, or `fail`; it always sends
158
+ POST. `signal_method="post"` prevents link previewers and security scanners
159
+ from accidentally recording a heartbeat with GET. The
160
+ source-scoped resource also exposes `client.watches.pause(id)`,
161
+ `resume(id)`, `rotate_token(id)`, and `archive(id)`; only create/rotate return
162
+ a raw URL. Store capability URLs in a secret manager; Watch details and
163
+ exports never return them.
164
+
165
+ ## License
166
+
167
+ MIT. See [LICENSE](./LICENSE).
@@ -0,0 +1,6 @@
1
+ actionbox/__init__.py,sha256=YNo1ONT4JJHQ0JVH6x5S2KZcGNo2E7wMgQ9dBFefgg8,33096
2
+ actionbox_sdk-0.1.6.dist-info/licenses/LICENSE,sha256=9-tv8G2RZXRAQFiBV4gKdBDMaqUOgNGSM3aoS5SSoEM,1070
3
+ actionbox_sdk-0.1.6.dist-info/METADATA,sha256=8Qy5D9aMW-Fio9macyuB9L2P3GCZF7dm6ayMLXkqU74,5910
4
+ actionbox_sdk-0.1.6.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ actionbox_sdk-0.1.6.dist-info/top_level.txt,sha256=qmZZebCeHoXL38ilOVzKzmA0IcHa_zYbOsgAPbAhZKo,10
6
+ actionbox_sdk-0.1.6.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Suson Sapkota
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ actionbox