oneshot-python 0.11.0__tar.gz → 0.12.0__tar.gz

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.
Files changed (20) hide show
  1. {oneshot_python-0.11.0 → oneshot_python-0.12.0}/PKG-INFO +1 -1
  2. {oneshot_python-0.11.0 → oneshot_python-0.12.0}/README.md +31 -0
  3. {oneshot_python-0.11.0 → oneshot_python-0.12.0}/oneshot/__init__.py +22 -0
  4. oneshot_python-0.12.0/oneshot/_types.py +178 -0
  5. {oneshot_python-0.11.0 → oneshot_python-0.12.0}/oneshot/client.py +242 -14
  6. {oneshot_python-0.11.0 → oneshot_python-0.12.0}/pyproject.toml +1 -1
  7. oneshot_python-0.12.0/tests/test_compute.py +291 -0
  8. {oneshot_python-0.11.0 → oneshot_python-0.12.0}/tests/test_email_payload.py +52 -0
  9. {oneshot_python-0.11.0 → oneshot_python-0.12.0}/.gitignore +0 -0
  10. {oneshot_python-0.11.0 → oneshot_python-0.12.0}/oneshot/_errors.py +0 -0
  11. {oneshot_python-0.11.0 → oneshot_python-0.12.0}/oneshot/x402.py +0 -0
  12. {oneshot_python-0.11.0 → oneshot_python-0.12.0}/tests/__init__.py +0 -0
  13. {oneshot_python-0.11.0 → oneshot_python-0.12.0}/tests/test_balance.py +0 -0
  14. {oneshot_python-0.11.0 → oneshot_python-0.12.0}/tests/test_emergency_error.py +0 -0
  15. {oneshot_python-0.11.0 → oneshot_python-0.12.0}/tests/test_max_cost_header.py +0 -0
  16. {oneshot_python-0.11.0 → oneshot_python-0.12.0}/tests/test_phones_pending.py +0 -0
  17. {oneshot_python-0.11.0 → oneshot_python-0.12.0}/tests/test_request_id.py +0 -0
  18. {oneshot_python-0.11.0 → oneshot_python-0.12.0}/tests/test_tag_receipt_value.py +0 -0
  19. {oneshot_python-0.11.0 → oneshot_python-0.12.0}/tests/test_x402.py +0 -0
  20. {oneshot_python-0.11.0 → oneshot_python-0.12.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oneshot-python
3
- Version: 0.11.0
3
+ Version: 0.12.0
4
4
  Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -87,6 +87,37 @@ The SDK operates on **Base Mainnet** with real USDC. Fund your wallet before mak
87
87
  - `call_free_post(endpoint, payload=None)` / `acall_free_post(...)` — POST
88
88
  - `call_free_patch(endpoint, payload=None)` / `acall_free_patch(...)` — PATCH
89
89
 
90
+ ### Compute — autonomous goal orchestration
91
+
92
+ Launch a compute goal and let the orchestrator plan, execute, and iterate. Paid via x402 (same flow as other tools).
93
+
94
+ ```python
95
+ goal = client.compute(
96
+ objective="Research the top 10 AI startups and build a comparison website",
97
+ budget_usdc=5.00,
98
+ max_cost=10.00,
99
+ )
100
+ print(goal["goal_id"])
101
+
102
+ # Observe
103
+ status = client.get_compute_goal(goal["goal_id"])
104
+ tasks = client.get_compute_tasks(goal["goal_id"])
105
+ budget = client.get_compute_budget(goal["goal_id"])
106
+
107
+ # Top up a recurring goal (paid)
108
+ client.fund_compute_goal(goal["goal_id"], 10.00)
109
+
110
+ # Lifecycle
111
+ client.pause_compute_goal(goal["goal_id"])
112
+ client.resume_compute_goal(goal["goal_id"])
113
+ client.cancel_compute_goal(goal["goal_id"], reason="done")
114
+
115
+ # HITL — respond to an approval task
116
+ client.respond_to_compute_task(goal["goal_id"], "task_01H…", approved=True)
117
+ ```
118
+
119
+ Every method has an `a*` async mirror (`acompute`, `aget_compute_goal`, …).
120
+
90
121
  ## Requirements
91
122
 
92
123
  - Python 3.10+
@@ -9,6 +9,18 @@ from oneshot._errors import (
9
9
  ToolError,
10
10
  ValidationError,
11
11
  )
12
+ from oneshot._types import (
13
+ ComputeBudgetStatus,
14
+ ComputeCancelResult,
15
+ ComputeFundResult,
16
+ ComputeGoalResult,
17
+ ComputeGoalStatus,
18
+ ComputePauseResult,
19
+ ComputeResumeResult,
20
+ ComputeSchedule,
21
+ ComputeTask,
22
+ ComputeTaskResponseResult,
23
+ )
12
24
  from oneshot.client import OneShotClient
13
25
  from oneshot.x402 import sign_payment_authorization
14
26
 
@@ -22,4 +34,14 @@ __all__ = [
22
34
  "ContentBlockedError",
23
35
  "EmergencyNumberError",
24
36
  "sign_payment_authorization",
37
+ "ComputeSchedule",
38
+ "ComputeGoalResult",
39
+ "ComputeGoalStatus",
40
+ "ComputeTask",
41
+ "ComputeBudgetStatus",
42
+ "ComputeCancelResult",
43
+ "ComputeTaskResponseResult",
44
+ "ComputePauseResult",
45
+ "ComputeResumeResult",
46
+ "ComputeFundResult",
25
47
  ]
@@ -0,0 +1,178 @@
1
+ """Type hints for the compute orchestration API.
2
+
3
+ These TypedDicts mirror libs/agent-sdk/src/types.ts field-for-field. They are
4
+ optional at runtime — `OneShotClient` continues to return raw dicts — but
5
+ expose the shape to IDEs and `mypy` so callers get auto-complete + type
6
+ checking when they want it.
7
+
8
+ Keep the field set IN SYNC with the TS interfaces. If you add a field here,
9
+ add it there (and vice versa).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, Optional, TypedDict
15
+
16
+
17
+ class ComputeSchedule(TypedDict, total=False):
18
+ """Recurring-goal schedule. Mirrors `ComputeSchedule` in TS."""
19
+
20
+ cron: str
21
+ budget_per_run: float
22
+ max_runs: int
23
+
24
+
25
+ class ComputeGoalInner(TypedDict, total=False):
26
+ objective: str
27
+ budget_usdc: str
28
+ deadline: str
29
+ schedule_cron: str
30
+ budget_per_run: float
31
+ max_runs: int
32
+ next_run_at: str
33
+
34
+
35
+ class ComputeGoalResult(TypedDict, total=False):
36
+ """Returned by `compute()` / `acompute()` after the goal is created."""
37
+
38
+ goal_id: str
39
+ request_id: str
40
+ receipt_id: str
41
+ status: str
42
+ message: str
43
+ memo: str
44
+ cost: float
45
+ goal: ComputeGoalInner
46
+
47
+
48
+ class ComputeBudgetInner(TypedDict, total=False):
49
+ total: str
50
+ spent: str
51
+ reserved: str
52
+ remaining: str
53
+
54
+
55
+ class ComputeGoalScheduleInner(TypedDict, total=False):
56
+ cron: str
57
+ budget_per_run: str
58
+ max_runs: Optional[int]
59
+ run_count: int
60
+ last_run_at: Optional[str]
61
+ next_run_at: Optional[str]
62
+
63
+
64
+ class ComputeGoalStatus(TypedDict, total=False):
65
+ """Returned by `get_compute_goal()` / `aget_compute_goal()`."""
66
+
67
+ id: str
68
+ status: str
69
+ name: str
70
+ objective: str
71
+ current_phase: Optional[int]
72
+ plan: Any
73
+ budget: Optional[ComputeBudgetInner]
74
+ soul_agent_id: Optional[str]
75
+ deadline: Optional[str]
76
+ started_at: Optional[str]
77
+ completed_at: Optional[str]
78
+ last_wake_at: Optional[str]
79
+ next_wake_at: Optional[str]
80
+ created_at: str
81
+ schedule: ComputeGoalScheduleInner
82
+
83
+
84
+ class ComputeTask(TypedDict, total=False):
85
+ """Single item from `get_compute_tasks()` / `aget_compute_tasks()`."""
86
+
87
+ id: str
88
+ task_type: str
89
+ tool: Optional[str]
90
+ description: str
91
+ status: str
92
+ phase: Optional[int]
93
+ sequence: Optional[int]
94
+ progress_pct: Optional[float]
95
+ progress_message: Optional[str]
96
+ result: Any
97
+ quoted_usdc: Optional[str]
98
+ actual_usdc: Optional[str]
99
+ run_number: Optional[int]
100
+ started_at: Optional[str]
101
+ completed_at: Optional[str]
102
+ created_at: str
103
+
104
+
105
+ class ComputeBudgetSpendEntry(TypedDict, total=False):
106
+ category: str
107
+ amount_usdc: str
108
+ description: str
109
+ created_at: str
110
+
111
+
112
+ class ComputeBudgetStatus(TypedDict, total=False):
113
+ """Returned by `get_compute_budget()` / `aget_compute_budget()`."""
114
+
115
+ budgetId: str
116
+ goalId: str
117
+ totalBudgetUsdc: str
118
+ spentUsdc: str
119
+ reservedUsdc: str
120
+ remainingUsdc: str
121
+ spend_entries: list[ComputeBudgetSpendEntry]
122
+
123
+
124
+ class ComputeCancelResult(TypedDict, total=False):
125
+ """Returned by `cancel_compute_goal()`."""
126
+
127
+ goal_id: str
128
+ status: str
129
+ remaining_budget: str
130
+
131
+
132
+ class ComputeTaskResponseResult(TypedDict, total=False):
133
+ """Returned by `respond_to_compute_task()`."""
134
+
135
+ task_id: str
136
+ goal_id: str
137
+ task_status: str
138
+ orchestrator_action: str
139
+
140
+
141
+ class ComputePauseResult(TypedDict, total=False):
142
+ """Returned by `pause_compute_goal()`."""
143
+
144
+ goal_id: str
145
+ status: str
146
+ run_count: int
147
+
148
+
149
+ class ComputeResumeResult(TypedDict, total=False):
150
+ """Returned by `resume_compute_goal()`."""
151
+
152
+ goal_id: str
153
+ status: str
154
+ next_run_at: str
155
+ run_count: int
156
+
157
+
158
+ class ComputeFundResult(TypedDict, total=False):
159
+ """Returned by `fund_compute_goal()`."""
160
+
161
+ goal_id: str
162
+ topped_up: float
163
+ total_budget: str
164
+ remaining: str
165
+
166
+
167
+ __all__ = [
168
+ "ComputeSchedule",
169
+ "ComputeGoalResult",
170
+ "ComputeGoalStatus",
171
+ "ComputeTask",
172
+ "ComputeBudgetStatus",
173
+ "ComputeCancelResult",
174
+ "ComputeTaskResponseResult",
175
+ "ComputePauseResult",
176
+ "ComputeResumeResult",
177
+ "ComputeFundResult",
178
+ ]
@@ -46,29 +46,44 @@ _MAX_POLL_RETRIES = 3
46
46
 
47
47
 
48
48
  def _build_email_payload(
49
- to: str,
50
- subject: str,
49
+ to: Optional[str],
50
+ subject: Optional[str],
51
51
  body: str,
52
52
  from_domain: Optional[str],
53
53
  from_mailbox: Optional[str],
54
54
  from_name: Optional[str],
55
55
  extra: dict[str, Any],
56
+ *,
57
+ reply_to_email_id: Optional[str] = None,
56
58
  ) -> dict[str, Any]:
57
59
  """Build the /v1/tools/email/send body in the API's field convention.
58
60
 
59
61
  The API expects ``from_address`` + ``to_address`` (see
60
62
  EmailSendRequestSchema). Construct from_address from the mailbox/domain
61
63
  and forward the optional display name.
64
+
65
+ When ``reply_to_email_id`` is set (threaded reply to an inbound email),
66
+ ``to``/``subject`` may be omitted — the server derives them from the
67
+ inbound message; otherwise they are required.
62
68
  """
69
+ if not reply_to_email_id:
70
+ if not to:
71
+ raise ValidationError("to is required unless reply_to_email_id is set", "to")
72
+ if not subject:
73
+ raise ValidationError("subject is required unless reply_to_email_id is set", "subject")
63
74
  mailbox = from_mailbox or "agent"
64
75
  domain = from_domain or "oneshotagent.com"
65
76
  payload: dict[str, Any] = {
66
77
  "from_address": f"{mailbox}@{domain}",
67
- "to_address": to,
68
- "subject": subject,
69
78
  "body": body,
70
79
  **extra,
71
80
  }
81
+ if to is not None:
82
+ payload["to_address"] = to
83
+ if subject is not None:
84
+ payload["subject"] = subject
85
+ if reply_to_email_id:
86
+ payload["reply_to_email_id"] = reply_to_email_id
72
87
  if from_name:
73
88
  payload["from_name"] = from_name
74
89
  return payload
@@ -417,6 +432,207 @@ class OneShotClient:
417
432
  return {"success": True}
418
433
  return resp.json()
419
434
 
435
+ # ------------------------------------------------------------------
436
+ # Compute — autonomous goal orchestration (mirrors TS SDK)
437
+ # ------------------------------------------------------------------
438
+ # Two paid endpoints (compute + fund) reuse `acall_tool` for the full
439
+ # x402 quote+pay flow. The seven free endpoints are one-line wrappers
440
+ # around `acall_free_get / post` + the `_unwrap_data` envelope strip.
441
+ # Method names mirror the TS camelCase under Python snake_case
442
+ # (`getComputeGoal` -> `get_compute_goal`).
443
+
444
+ @staticmethod
445
+ def _unwrap_data(envelope: Any) -> Any:
446
+ """Strip the `{"success": true, "data": …}` envelope the compute
447
+ endpoints return — matches the TS SDK convention so callers see the
448
+ inner shape directly. `acall_tool` already unwraps for paid endpoints;
449
+ `acall_free_get / post` do not, so this helper handles them here."""
450
+ if isinstance(envelope, dict) and "data" in envelope:
451
+ return envelope["data"]
452
+ return envelope
453
+
454
+ def compute(
455
+ self,
456
+ objective: str,
457
+ *,
458
+ params: Optional[dict[str, Any]] = None,
459
+ budget_usdc: Optional[float] = None,
460
+ deadline: Optional[str] = None,
461
+ soul_slug: Optional[str] = None,
462
+ soul_service_slug: Optional[str] = None,
463
+ schedule: Optional[dict[str, Any]] = None,
464
+ max_cost: Optional[float] = None,
465
+ memo: Optional[str] = None,
466
+ ) -> Any:
467
+ """Create a compute goal. Blocking."""
468
+ return asyncio.get_event_loop().run_until_complete(
469
+ self.acompute(
470
+ objective,
471
+ params=params,
472
+ budget_usdc=budget_usdc,
473
+ deadline=deadline,
474
+ soul_slug=soul_slug,
475
+ soul_service_slug=soul_service_slug,
476
+ schedule=schedule,
477
+ max_cost=max_cost,
478
+ memo=memo,
479
+ )
480
+ )
481
+
482
+ async def acompute(
483
+ self,
484
+ objective: str,
485
+ *,
486
+ params: Optional[dict[str, Any]] = None,
487
+ budget_usdc: Optional[float] = None,
488
+ deadline: Optional[str] = None,
489
+ soul_slug: Optional[str] = None,
490
+ soul_service_slug: Optional[str] = None,
491
+ schedule: Optional[dict[str, Any]] = None,
492
+ max_cost: Optional[float] = None,
493
+ memo: Optional[str] = None,
494
+ ) -> Any:
495
+ """Create a compute goal — the orchestrator plans + executes autonomously.
496
+
497
+ Quote+pay (x402) flow handled by `acall_tool`. Returns the
498
+ ``data`` block of the 202 response (`goal_id`, `status`, …).
499
+ """
500
+ payload: dict[str, Any] = {"objective": objective}
501
+ if params is not None:
502
+ payload["params"] = params
503
+ if budget_usdc is not None:
504
+ payload["budget_usdc"] = budget_usdc
505
+ if deadline is not None:
506
+ payload["deadline"] = deadline
507
+ if soul_slug is not None:
508
+ payload["soul_slug"] = soul_slug
509
+ if soul_service_slug is not None:
510
+ payload["soul_service_slug"] = soul_service_slug
511
+ if schedule is not None:
512
+ payload["schedule"] = schedule
513
+ if memo is not None:
514
+ payload["memo"] = memo
515
+ return await self.acall_tool("/v1/compute", payload, max_cost=max_cost)
516
+
517
+ def get_compute_goal(self, goal_id: str) -> Any:
518
+ """Get goal status. Blocking."""
519
+ return asyncio.get_event_loop().run_until_complete(
520
+ self.aget_compute_goal(goal_id)
521
+ )
522
+
523
+ async def aget_compute_goal(self, goal_id: str) -> Any:
524
+ """Get goal status. Async."""
525
+ return self._unwrap_data(await self.acall_free_get(f"/v1/compute/{goal_id}"))
526
+
527
+ def get_compute_tasks(self, goal_id: str) -> Any:
528
+ """List tasks under a goal. Blocking."""
529
+ return asyncio.get_event_loop().run_until_complete(
530
+ self.aget_compute_tasks(goal_id)
531
+ )
532
+
533
+ async def aget_compute_tasks(self, goal_id: str) -> Any:
534
+ """List tasks under a goal. Async."""
535
+ return self._unwrap_data(await self.acall_free_get(f"/v1/compute/{goal_id}/tasks"))
536
+
537
+ def get_compute_budget(self, goal_id: str) -> Any:
538
+ """Get budget breakdown. Blocking."""
539
+ return asyncio.get_event_loop().run_until_complete(
540
+ self.aget_compute_budget(goal_id)
541
+ )
542
+
543
+ async def aget_compute_budget(self, goal_id: str) -> Any:
544
+ """Get budget breakdown. Async."""
545
+ return self._unwrap_data(await self.acall_free_get(f"/v1/compute/{goal_id}/budget"))
546
+
547
+ def cancel_compute_goal(self, goal_id: str, reason: Optional[str] = None) -> Any:
548
+ """Cancel a goal; remaining budget is credited. Blocking."""
549
+ return asyncio.get_event_loop().run_until_complete(
550
+ self.acancel_compute_goal(goal_id, reason)
551
+ )
552
+
553
+ async def acancel_compute_goal(
554
+ self, goal_id: str, reason: Optional[str] = None
555
+ ) -> Any:
556
+ """Cancel a goal; remaining budget is credited. Async."""
557
+ payload: dict[str, Any] = {"reason": reason} if reason is not None else {}
558
+ return self._unwrap_data(
559
+ await self.acall_free_post(f"/v1/compute/{goal_id}/cancel", payload)
560
+ )
561
+
562
+ def respond_to_compute_task(
563
+ self,
564
+ goal_id: str,
565
+ task_id: str,
566
+ *,
567
+ response: Optional[str] = None,
568
+ approved: Optional[bool] = None,
569
+ ) -> Any:
570
+ """Respond to a human-in-the-loop approval task. Blocking."""
571
+ return asyncio.get_event_loop().run_until_complete(
572
+ self.arespond_to_compute_task(
573
+ goal_id, task_id, response=response, approved=approved
574
+ )
575
+ )
576
+
577
+ async def arespond_to_compute_task(
578
+ self,
579
+ goal_id: str,
580
+ task_id: str,
581
+ *,
582
+ response: Optional[str] = None,
583
+ approved: Optional[bool] = None,
584
+ ) -> Any:
585
+ """Respond to a human-in-the-loop approval task. Async."""
586
+ payload: dict[str, Any] = {"task_id": task_id}
587
+ if response is not None:
588
+ payload["response"] = response
589
+ if approved is not None:
590
+ payload["approved"] = approved
591
+ return self._unwrap_data(
592
+ await self.acall_free_post(f"/v1/compute/{goal_id}/respond", payload)
593
+ )
594
+
595
+ def pause_compute_goal(self, goal_id: str, reason: Optional[str] = None) -> Any:
596
+ """Pause a recurring goal. Blocking."""
597
+ return asyncio.get_event_loop().run_until_complete(
598
+ self.apause_compute_goal(goal_id, reason)
599
+ )
600
+
601
+ async def apause_compute_goal(
602
+ self, goal_id: str, reason: Optional[str] = None
603
+ ) -> Any:
604
+ """Pause a recurring goal. Async."""
605
+ payload: dict[str, Any] = {"reason": reason} if reason is not None else {}
606
+ return self._unwrap_data(
607
+ await self.acall_free_post(f"/v1/compute/{goal_id}/pause", payload)
608
+ )
609
+
610
+ def resume_compute_goal(self, goal_id: str) -> Any:
611
+ """Resume a paused recurring goal. Blocking."""
612
+ return asyncio.get_event_loop().run_until_complete(
613
+ self.aresume_compute_goal(goal_id)
614
+ )
615
+
616
+ async def aresume_compute_goal(self, goal_id: str) -> Any:
617
+ """Resume a paused recurring goal. Async."""
618
+ return self._unwrap_data(
619
+ await self.acall_free_post(f"/v1/compute/{goal_id}/resume", {})
620
+ )
621
+
622
+ def fund_compute_goal(self, goal_id: str, amount: float) -> Any:
623
+ """Top up budget on a recurring goal. Blocking."""
624
+ return asyncio.get_event_loop().run_until_complete(
625
+ self.afund_compute_goal(goal_id, amount)
626
+ )
627
+
628
+ async def afund_compute_goal(self, goal_id: str, amount: float) -> Any:
629
+ """Top up budget on a recurring goal. Quote+pay (x402) via `acall_tool`."""
630
+ if not amount or amount <= 0:
631
+ raise ValidationError("amount must be a positive number", "amount")
632
+ return await self.acall_tool(
633
+ f"/v1/compute/{goal_id}/fund", {"amount": amount}
634
+ )
635
+
420
636
  # ------------------------------------------------------------------
421
637
  # Browser
422
638
  # ------------------------------------------------------------------
@@ -529,36 +745,48 @@ class OneShotClient:
529
745
 
530
746
  def email(
531
747
  self,
532
- to: str,
533
- subject: str,
534
- body: str,
748
+ to: Optional[str] = None,
749
+ subject: Optional[str] = None,
750
+ body: str = "",
535
751
  *,
536
752
  from_domain: Optional[str] = None,
537
753
  from_mailbox: Optional[str] = None,
538
754
  from_name: Optional[str] = None,
755
+ reply_to_email_id: Optional[str] = None,
539
756
  **kwargs: Any,
540
757
  ) -> Any:
541
- """Send an email. Blocking."""
758
+ """Send an email. Blocking.
759
+
760
+ Set ``reply_to_email_id`` (an inbound email id from ``inbox()``) to
761
+ reply within a thread — ``to``/``subject`` are then derived from the
762
+ inbound message unless given.
763
+ """
542
764
  return self.call_tool(
543
765
  "/v1/tools/email/send",
544
- _build_email_payload(to, subject, body, from_domain, from_mailbox, from_name, kwargs),
766
+ _build_email_payload(to, subject, body, from_domain, from_mailbox, from_name, kwargs, reply_to_email_id=reply_to_email_id),
545
767
  )
546
768
 
547
769
  async def aemail(
548
770
  self,
549
- to: str,
550
- subject: str,
551
- body: str,
771
+ to: Optional[str] = None,
772
+ subject: Optional[str] = None,
773
+ body: str = "",
552
774
  *,
553
775
  from_domain: Optional[str] = None,
554
776
  from_mailbox: Optional[str] = None,
555
777
  from_name: Optional[str] = None,
778
+ reply_to_email_id: Optional[str] = None,
556
779
  **kwargs: Any,
557
780
  ) -> Any:
558
- """Send an email. Async."""
781
+ """Send an email. Async.
782
+
783
+ Set ``reply_to_email_id`` (an inbound email id from ``ainbox()``) to
784
+ reply within a thread — ``to``/``subject`` are then derived from the
785
+ inbound message unless given.
786
+ """
559
787
  return await self.acall_tool(
560
788
  "/v1/tools/email/send",
561
- _build_email_payload(to, subject, body, from_domain, from_mailbox, from_name, kwargs),
789
+ _build_email_payload(to, subject, body, from_domain, from_mailbox, from_name, kwargs, reply_to_email_id=reply_to_email_id),
562
790
  )
563
791
 
564
792
  def voice(self, objective: str, target_number: str, **kwargs: Any) -> Any:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "oneshot-python"
3
- version = "0.11.0"
3
+ version = "0.12.0"
4
4
  description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling"
5
5
  readme = {text = "Core Python SDK for the OneShot API", content-type = "text/plain"}
6
6
  license = "MIT"
@@ -0,0 +1,291 @@
1
+ """Tests for OneShotClient.compute() and the eight friends.
2
+
3
+ Mocks the lower-level `call_tool` / `acall_tool` and `call_free_get/post`
4
+ helpers (matching the test_balance.py pattern). The full x402 quote+pay
5
+ flow is exercised by test_x402.py and the existing acall_tool tests; here
6
+ we lock in the compute-specific contract: correct endpoints, correct
7
+ payload shape, correct passthrough of optional kwargs.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from unittest.mock import AsyncMock, patch
13
+
14
+ import pytest
15
+ from eth_account import Account
16
+
17
+ from oneshot._errors import ValidationError
18
+ from oneshot.client import OneShotClient
19
+
20
+ TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
21
+ TEST_ADDRESS = Account.from_key(TEST_PRIVATE_KEY).address
22
+
23
+ GOAL_ID = "goal_01HXTEST0000000000000000"
24
+ TASK_ID = "task_01HXTEST0000000000000000"
25
+
26
+ MOCK_COMPUTE_RESULT = {
27
+ "goal_id": GOAL_ID,
28
+ "request_id": "req_01HX",
29
+ "status": "pending",
30
+ "message": "Goal created",
31
+ "goal": {"objective": "research X", "budget_usdc": "5.00"},
32
+ }
33
+
34
+ MOCK_GOAL_STATUS = {
35
+ "id": GOAL_ID,
36
+ "status": "running",
37
+ "name": "Research X",
38
+ "objective": "research X",
39
+ "current_phase": 1,
40
+ "plan": None,
41
+ "budget": {"total": "5.00", "spent": "1.20", "reserved": "0.30", "remaining": "3.50"},
42
+ "soul_agent_id": None,
43
+ "deadline": None,
44
+ "started_at": "2026-06-04T00:00:00Z",
45
+ "completed_at": None,
46
+ "last_wake_at": None,
47
+ "next_wake_at": None,
48
+ "created_at": "2026-06-04T00:00:00Z",
49
+ }
50
+
51
+ MOCK_TASKS = [
52
+ {"id": "t1", "task_type": "research", "tool": "research", "description": "…", "status": "completed"},
53
+ {"id": "t2", "task_type": "build", "tool": "build", "description": "…", "status": "pending"},
54
+ ]
55
+
56
+ MOCK_BUDGET = {
57
+ "budgetId": "bud_01HX",
58
+ "goalId": GOAL_ID,
59
+ "totalBudgetUsdc": "5.00",
60
+ "spentUsdc": "1.20",
61
+ "reservedUsdc": "0.30",
62
+ "remainingUsdc": "3.50",
63
+ "spend_entries": [],
64
+ }
65
+
66
+ MOCK_CANCEL = {"goal_id": GOAL_ID, "status": "cancelled", "remaining_budget": "3.50"}
67
+ MOCK_RESPOND = {
68
+ "task_id": TASK_ID,
69
+ "goal_id": GOAL_ID,
70
+ "task_status": "completed",
71
+ "orchestrator_action": "advance",
72
+ }
73
+ MOCK_PAUSE = {"goal_id": GOAL_ID, "status": "paused", "run_count": 4}
74
+ MOCK_RESUME = {"goal_id": GOAL_ID, "status": "running", "next_run_at": "2026-06-05T00:00:00Z", "run_count": 4}
75
+ MOCK_FUND = {"goal_id": GOAL_ID, "topped_up": 10.0, "total_budget": "15.00", "remaining": "13.50"}
76
+
77
+
78
+ def _client() -> OneShotClient:
79
+ return OneShotClient(TEST_PRIVATE_KEY)
80
+
81
+
82
+ # ─── compute() — quote+pay ────────────────────────────────────────────
83
+
84
+
85
+ class TestCompute:
86
+ def test_posts_minimal_payload_to_compute_endpoint(self) -> None:
87
+ client = _client()
88
+ with patch.object(client, "acall_tool", new_callable=AsyncMock, return_value=MOCK_COMPUTE_RESULT) as mock:
89
+ result = client.compute(objective="research X")
90
+ mock.assert_called_once_with("/v1/compute", {"objective": "research X"}, max_cost=None)
91
+ assert result == MOCK_COMPUTE_RESULT
92
+
93
+ def test_threads_all_optional_kwargs_into_payload(self) -> None:
94
+ client = _client()
95
+ with patch.object(client, "acall_tool", new_callable=AsyncMock, return_value=MOCK_COMPUTE_RESULT) as mock:
96
+ client.compute(
97
+ objective="research X",
98
+ params={"depth": "deep"},
99
+ budget_usdc=5.0,
100
+ deadline="2026-12-31T23:59:59Z",
101
+ soul_slug="agent-x",
102
+ soul_service_slug="svc-x",
103
+ schedule={"cron": "0 0 * * *", "budget_per_run": 1.0},
104
+ max_cost=10.0,
105
+ memo="hello",
106
+ )
107
+ call_args = mock.call_args
108
+ assert call_args.args[0] == "/v1/compute"
109
+ payload = call_args.args[1]
110
+ assert payload["objective"] == "research X"
111
+ assert payload["params"] == {"depth": "deep"}
112
+ assert payload["budget_usdc"] == 5.0
113
+ assert payload["deadline"] == "2026-12-31T23:59:59Z"
114
+ assert payload["soul_slug"] == "agent-x"
115
+ assert payload["soul_service_slug"] == "svc-x"
116
+ assert payload["schedule"] == {"cron": "0 0 * * *", "budget_per_run": 1.0}
117
+ assert payload["memo"] == "hello"
118
+ assert call_args.kwargs["max_cost"] == 10.0
119
+
120
+ def test_omits_none_kwargs_from_payload(self) -> None:
121
+ client = _client()
122
+ with patch.object(client, "acall_tool", new_callable=AsyncMock, return_value=MOCK_COMPUTE_RESULT) as mock:
123
+ client.compute(objective="x", budget_usdc=2.0)
124
+ payload = mock.call_args.args[1]
125
+ assert "params" not in payload
126
+ assert "deadline" not in payload
127
+ assert "memo" not in payload
128
+ assert payload["budget_usdc"] == 2.0
129
+
130
+ @pytest.mark.asyncio
131
+ async def test_async_mirror_calls_acall_tool(self) -> None:
132
+ client = _client()
133
+ with patch.object(client, "acall_tool", new_callable=AsyncMock, return_value=MOCK_COMPUTE_RESULT) as mock:
134
+ result = await client.acompute(objective="x", budget_usdc=1.0)
135
+ mock.assert_awaited_once()
136
+ assert result == MOCK_COMPUTE_RESULT
137
+
138
+
139
+ # ─── free read endpoints ──────────────────────────────────────────────
140
+
141
+
142
+ class TestGetComputeGoal:
143
+ def test_calls_correct_path(self) -> None:
144
+ client = _client()
145
+ with patch.object(client, "acall_free_get", new_callable=AsyncMock, return_value=MOCK_GOAL_STATUS) as mock:
146
+ result = client.get_compute_goal(GOAL_ID)
147
+ mock.assert_called_once_with(f"/v1/compute/{GOAL_ID}")
148
+ assert result == MOCK_GOAL_STATUS
149
+
150
+ def test_strips_data_envelope(self) -> None:
151
+ """API returns {success: true, data: {...}}; SDK should unwrap to match TS."""
152
+ client = _client()
153
+ envelope = {"success": True, "data": MOCK_GOAL_STATUS}
154
+ with patch.object(client, "acall_free_get", new_callable=AsyncMock, return_value=envelope):
155
+ result = client.get_compute_goal(GOAL_ID)
156
+ assert result == MOCK_GOAL_STATUS
157
+ assert "data" not in result # confirmed unwrap, not pass-through
158
+
159
+ def test_passes_through_when_already_unwrapped(self) -> None:
160
+ """Some callers (or future API shape changes) may already return the inner
161
+ object. Helper should not double-strip a key called `data`."""
162
+ client = _client()
163
+ inner = {"id": GOAL_ID, "status": "running"}
164
+ with patch.object(client, "acall_free_get", new_callable=AsyncMock, return_value=inner):
165
+ result = client.get_compute_goal(GOAL_ID)
166
+ assert result == inner
167
+
168
+ @pytest.mark.asyncio
169
+ async def test_async_mirror(self) -> None:
170
+ client = _client()
171
+ with patch.object(client, "acall_free_get", new_callable=AsyncMock, return_value=MOCK_GOAL_STATUS):
172
+ result = await client.aget_compute_goal(GOAL_ID)
173
+ assert result["id"] == GOAL_ID
174
+
175
+
176
+ class TestGetComputeTasks:
177
+ def test_calls_correct_path(self) -> None:
178
+ client = _client()
179
+ with patch.object(client, "acall_free_get", new_callable=AsyncMock, return_value=MOCK_TASKS) as mock:
180
+ result = client.get_compute_tasks(GOAL_ID)
181
+ mock.assert_called_once_with(f"/v1/compute/{GOAL_ID}/tasks")
182
+ assert result == MOCK_TASKS
183
+
184
+ @pytest.mark.asyncio
185
+ async def test_async_mirror(self) -> None:
186
+ client = _client()
187
+ with patch.object(client, "acall_free_get", new_callable=AsyncMock, return_value=MOCK_TASKS):
188
+ result = await client.aget_compute_tasks(GOAL_ID)
189
+ assert len(result) == 2
190
+
191
+
192
+ class TestGetComputeBudget:
193
+ def test_calls_correct_path(self) -> None:
194
+ client = _client()
195
+ with patch.object(client, "acall_free_get", new_callable=AsyncMock, return_value=MOCK_BUDGET) as mock:
196
+ result = client.get_compute_budget(GOAL_ID)
197
+ mock.assert_called_once_with(f"/v1/compute/{GOAL_ID}/budget")
198
+ assert result["remainingUsdc"] == "3.50"
199
+
200
+
201
+ # ─── free write endpoints ─────────────────────────────────────────────
202
+
203
+
204
+ class TestCancelComputeGoal:
205
+ def test_omits_reason_when_none(self) -> None:
206
+ client = _client()
207
+ with patch.object(client, "acall_free_post", new_callable=AsyncMock, return_value=MOCK_CANCEL) as mock:
208
+ client.cancel_compute_goal(GOAL_ID)
209
+ mock.assert_called_once_with(f"/v1/compute/{GOAL_ID}/cancel", {})
210
+
211
+ def test_includes_reason_when_provided(self) -> None:
212
+ client = _client()
213
+ with patch.object(client, "acall_free_post", new_callable=AsyncMock, return_value=MOCK_CANCEL) as mock:
214
+ client.cancel_compute_goal(GOAL_ID, reason="user requested")
215
+ mock.assert_called_once_with(f"/v1/compute/{GOAL_ID}/cancel", {"reason": "user requested"})
216
+
217
+ @pytest.mark.asyncio
218
+ async def test_async_mirror(self) -> None:
219
+ client = _client()
220
+ with patch.object(client, "acall_free_post", new_callable=AsyncMock, return_value=MOCK_CANCEL):
221
+ result = await client.acancel_compute_goal(GOAL_ID, reason="x")
222
+ assert result["status"] == "cancelled"
223
+
224
+
225
+ class TestRespondToComputeTask:
226
+ def test_threads_task_id_and_optional_fields(self) -> None:
227
+ client = _client()
228
+ with patch.object(client, "acall_free_post", new_callable=AsyncMock, return_value=MOCK_RESPOND) as mock:
229
+ client.respond_to_compute_task(GOAL_ID, TASK_ID, response="ok", approved=True)
230
+ mock.assert_called_once_with(
231
+ f"/v1/compute/{GOAL_ID}/respond",
232
+ {"task_id": TASK_ID, "response": "ok", "approved": True},
233
+ )
234
+
235
+ def test_omits_none_optional_fields(self) -> None:
236
+ client = _client()
237
+ with patch.object(client, "acall_free_post", new_callable=AsyncMock, return_value=MOCK_RESPOND) as mock:
238
+ client.respond_to_compute_task(GOAL_ID, TASK_ID)
239
+ payload = mock.call_args.args[1]
240
+ assert payload == {"task_id": TASK_ID}
241
+
242
+
243
+ class TestPauseComputeGoal:
244
+ def test_omits_reason_when_none(self) -> None:
245
+ client = _client()
246
+ with patch.object(client, "acall_free_post", new_callable=AsyncMock, return_value=MOCK_PAUSE) as mock:
247
+ client.pause_compute_goal(GOAL_ID)
248
+ mock.assert_called_once_with(f"/v1/compute/{GOAL_ID}/pause", {})
249
+
250
+ def test_includes_reason_when_provided(self) -> None:
251
+ client = _client()
252
+ with patch.object(client, "acall_free_post", new_callable=AsyncMock, return_value=MOCK_PAUSE) as mock:
253
+ client.pause_compute_goal(GOAL_ID, reason="manual")
254
+ mock.assert_called_once_with(f"/v1/compute/{GOAL_ID}/pause", {"reason": "manual"})
255
+
256
+
257
+ class TestResumeComputeGoal:
258
+ def test_calls_correct_path_with_empty_payload(self) -> None:
259
+ client = _client()
260
+ with patch.object(client, "acall_free_post", new_callable=AsyncMock, return_value=MOCK_RESUME) as mock:
261
+ client.resume_compute_goal(GOAL_ID)
262
+ mock.assert_called_once_with(f"/v1/compute/{GOAL_ID}/resume", {})
263
+
264
+
265
+ # ─── fund_compute_goal() — quote+pay + validation ─────────────────────
266
+
267
+
268
+ class TestFundComputeGoal:
269
+ def test_rejects_zero_amount(self) -> None:
270
+ client = _client()
271
+ with pytest.raises(ValidationError):
272
+ client.fund_compute_goal(GOAL_ID, 0)
273
+
274
+ def test_rejects_negative_amount(self) -> None:
275
+ client = _client()
276
+ with pytest.raises(ValidationError):
277
+ client.fund_compute_goal(GOAL_ID, -1.0)
278
+
279
+ def test_posts_to_fund_endpoint_via_acall_tool(self) -> None:
280
+ client = _client()
281
+ with patch.object(client, "acall_tool", new_callable=AsyncMock, return_value=MOCK_FUND) as mock:
282
+ result = client.fund_compute_goal(GOAL_ID, 10.0)
283
+ mock.assert_called_once_with(f"/v1/compute/{GOAL_ID}/fund", {"amount": 10.0})
284
+ assert result == MOCK_FUND
285
+
286
+ @pytest.mark.asyncio
287
+ async def test_async_mirror(self) -> None:
288
+ client = _client()
289
+ with patch.object(client, "acall_tool", new_callable=AsyncMock, return_value=MOCK_FUND):
290
+ result = await client.afund_compute_goal(GOAL_ID, 5.0)
291
+ assert result["total_budget"] == "15.00"
@@ -11,9 +11,11 @@ from unittest.mock import AsyncMock, MagicMock
11
11
 
12
12
  import pytest
13
13
 
14
+ from oneshot._errors import ValidationError
14
15
  from oneshot.client import OneShotClient, _build_email_payload
15
16
 
16
17
  TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
18
+ REPLY_ID = "4fa85f64-5717-4562-b3fc-2c963f66afa6"
17
19
 
18
20
 
19
21
  # ── _build_email_payload (pure) ───────────────────────────────────────────
@@ -44,6 +46,39 @@ def test_payload_passes_extra_kwargs():
44
46
  assert p["wait"] is True
45
47
 
46
48
 
49
+ # ── reply threading (reply_to_email_id) ───────────────────────────────────
50
+
51
+ def test_reply_payload_omits_to_and_subject_when_derivable():
52
+ # Replying: to/subject may be omitted; server derives them.
53
+ p = _build_email_payload(None, None, "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
54
+ assert p["reply_to_email_id"] == REPLY_ID
55
+ assert p["body"] == "B"
56
+ assert "to_address" not in p
57
+ assert "subject" not in p
58
+
59
+
60
+ def test_reply_payload_keeps_explicit_to_and_subject():
61
+ p = _build_email_payload("r@x.com", "Custom", "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
62
+ assert p["reply_to_email_id"] == REPLY_ID
63
+ assert p["to_address"] == "r@x.com"
64
+ assert p["subject"] == "Custom"
65
+
66
+
67
+ def test_non_reply_requires_to():
68
+ with pytest.raises(ValidationError):
69
+ _build_email_payload(None, "S", "B", None, None, None, {})
70
+
71
+
72
+ def test_non_reply_requires_subject():
73
+ with pytest.raises(ValidationError):
74
+ _build_email_payload("r@x.com", None, "B", None, None, None, {})
75
+
76
+
77
+ def test_reply_does_not_require_to_or_subject():
78
+ # Should not raise even though to and subject are None.
79
+ _build_email_payload(None, None, "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
80
+
81
+
47
82
  # ── email() delegation ────────────────────────────────────────────────────
48
83
 
49
84
  def _client() -> OneShotClient:
@@ -72,3 +107,20 @@ async def test_aemail_sends_correct_contract():
72
107
  assert payload["from_address"] == "agent@oneshotagent.com"
73
108
  assert payload["to_address"] == "r@x.com"
74
109
  assert "from_name" not in payload
110
+
111
+
112
+ def test_email_reply_forwards_reply_id_without_to_subject():
113
+ c = _client()
114
+ c.email(reply_to_email_id=REPLY_ID, body="Thanks!")
115
+ endpoint, payload = c.call_tool.call_args[0]
116
+ assert endpoint == "/v1/tools/email/send"
117
+ assert payload["reply_to_email_id"] == REPLY_ID
118
+ assert payload["body"] == "Thanks!"
119
+ assert "to_address" not in payload
120
+ assert "subject" not in payload
121
+
122
+
123
+ def test_email_without_to_or_reply_id_raises():
124
+ c = _client()
125
+ with pytest.raises(ValidationError):
126
+ c.email(body="orphan body")
File without changes