oneshot-python 0.11.0__tar.gz → 0.12.1__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.1}/PKG-INFO +1 -1
  2. {oneshot_python-0.11.0 → oneshot_python-0.12.1}/README.md +32 -1
  3. {oneshot_python-0.11.0 → oneshot_python-0.12.1}/oneshot/__init__.py +22 -0
  4. oneshot_python-0.12.1/oneshot/_types.py +178 -0
  5. {oneshot_python-0.11.0 → oneshot_python-0.12.1}/oneshot/client.py +250 -15
  6. {oneshot_python-0.11.0 → oneshot_python-0.12.1}/pyproject.toml +1 -1
  7. oneshot_python-0.12.1/tests/test_compute.py +291 -0
  8. {oneshot_python-0.11.0 → oneshot_python-0.12.1}/tests/test_email_payload.py +52 -0
  9. {oneshot_python-0.11.0 → oneshot_python-0.12.1}/.gitignore +0 -0
  10. {oneshot_python-0.11.0 → oneshot_python-0.12.1}/oneshot/_errors.py +0 -0
  11. {oneshot_python-0.11.0 → oneshot_python-0.12.1}/oneshot/x402.py +0 -0
  12. {oneshot_python-0.11.0 → oneshot_python-0.12.1}/tests/__init__.py +0 -0
  13. {oneshot_python-0.11.0 → oneshot_python-0.12.1}/tests/test_balance.py +0 -0
  14. {oneshot_python-0.11.0 → oneshot_python-0.12.1}/tests/test_emergency_error.py +0 -0
  15. {oneshot_python-0.11.0 → oneshot_python-0.12.1}/tests/test_max_cost_header.py +0 -0
  16. {oneshot_python-0.11.0 → oneshot_python-0.12.1}/tests/test_phones_pending.py +0 -0
  17. {oneshot_python-0.11.0 → oneshot_python-0.12.1}/tests/test_request_id.py +0 -0
  18. {oneshot_python-0.11.0 → oneshot_python-0.12.1}/tests/test_tag_receipt_value.py +0 -0
  19. {oneshot_python-0.11.0 → oneshot_python-0.12.1}/tests/test_x402.py +0 -0
  20. {oneshot_python-0.11.0 → oneshot_python-0.12.1}/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.1
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+
@@ -96,7 +127,7 @@ The SDK operates on **Base Mainnet** with real USDC. Fund your wallet before mak
96
127
  ## Links
97
128
 
98
129
  - [Documentation](https://docs.oneshotagent.com/sdk/installation#install-via-pip-python)
99
- - [LangChain integration](https://pypi.org/project/langchain-oneshot/) — 26 tools as LangChain BaseTool
130
+ - [LangChain integration](https://pypi.org/project/langchain-oneshot/) — 31 tools as LangChain BaseTool
100
131
  - [GAME plugin](https://pypi.org/project/game-plugin-oneshot/) — Virtuals Protocol integration
101
132
  - [TypeScript SDK](https://www.npmjs.com/package/@oneshot-agent/sdk)
102
133
  - [MCP Server](https://www.npmjs.com/package/@oneshot-agent/mcp-server)
@@ -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
+ ]
@@ -31,7 +31,14 @@ from oneshot.x402 import (
31
31
  sign_payment_authorization,
32
32
  )
33
33
 
34
- SDK_VERSION = "0.11.0"
34
+ # Derived from the installed package metadata so it never drifts from
35
+ # pyproject.toml. Falls back to a literal for editable/uninstalled runs.
36
+ try:
37
+ from importlib.metadata import version as _pkg_version
38
+
39
+ SDK_VERSION = _pkg_version("oneshot-python")
40
+ except Exception: # pragma: no cover - editable/source runs without dist metadata
41
+ SDK_VERSION = "0.12.1"
35
42
 
36
43
  # ---------------------------------------------------------------------------
37
44
  # Environment configuration
@@ -46,29 +53,44 @@ _MAX_POLL_RETRIES = 3
46
53
 
47
54
 
48
55
  def _build_email_payload(
49
- to: str,
50
- subject: str,
56
+ to: Optional[str],
57
+ subject: Optional[str],
51
58
  body: str,
52
59
  from_domain: Optional[str],
53
60
  from_mailbox: Optional[str],
54
61
  from_name: Optional[str],
55
62
  extra: dict[str, Any],
63
+ *,
64
+ reply_to_email_id: Optional[str] = None,
56
65
  ) -> dict[str, Any]:
57
66
  """Build the /v1/tools/email/send body in the API's field convention.
58
67
 
59
68
  The API expects ``from_address`` + ``to_address`` (see
60
69
  EmailSendRequestSchema). Construct from_address from the mailbox/domain
61
70
  and forward the optional display name.
71
+
72
+ When ``reply_to_email_id`` is set (threaded reply to an inbound email),
73
+ ``to``/``subject`` may be omitted — the server derives them from the
74
+ inbound message; otherwise they are required.
62
75
  """
76
+ if not reply_to_email_id:
77
+ if not to:
78
+ raise ValidationError("to is required unless reply_to_email_id is set", "to")
79
+ if not subject:
80
+ raise ValidationError("subject is required unless reply_to_email_id is set", "subject")
63
81
  mailbox = from_mailbox or "agent"
64
82
  domain = from_domain or "oneshotagent.com"
65
83
  payload: dict[str, Any] = {
66
84
  "from_address": f"{mailbox}@{domain}",
67
- "to_address": to,
68
- "subject": subject,
69
85
  "body": body,
70
86
  **extra,
71
87
  }
88
+ if to is not None:
89
+ payload["to_address"] = to
90
+ if subject is not None:
91
+ payload["subject"] = subject
92
+ if reply_to_email_id:
93
+ payload["reply_to_email_id"] = reply_to_email_id
72
94
  if from_name:
73
95
  payload["from_name"] = from_name
74
96
  return payload
@@ -417,6 +439,207 @@ class OneShotClient:
417
439
  return {"success": True}
418
440
  return resp.json()
419
441
 
442
+ # ------------------------------------------------------------------
443
+ # Compute — autonomous goal orchestration (mirrors TS SDK)
444
+ # ------------------------------------------------------------------
445
+ # Two paid endpoints (compute + fund) reuse `acall_tool` for the full
446
+ # x402 quote+pay flow. The seven free endpoints are one-line wrappers
447
+ # around `acall_free_get / post` + the `_unwrap_data` envelope strip.
448
+ # Method names mirror the TS camelCase under Python snake_case
449
+ # (`getComputeGoal` -> `get_compute_goal`).
450
+
451
+ @staticmethod
452
+ def _unwrap_data(envelope: Any) -> Any:
453
+ """Strip the `{"success": true, "data": …}` envelope the compute
454
+ endpoints return — matches the TS SDK convention so callers see the
455
+ inner shape directly. `acall_tool` already unwraps for paid endpoints;
456
+ `acall_free_get / post` do not, so this helper handles them here."""
457
+ if isinstance(envelope, dict) and "data" in envelope:
458
+ return envelope["data"]
459
+ return envelope
460
+
461
+ def compute(
462
+ self,
463
+ objective: str,
464
+ *,
465
+ params: Optional[dict[str, Any]] = None,
466
+ budget_usdc: Optional[float] = None,
467
+ deadline: Optional[str] = None,
468
+ soul_slug: Optional[str] = None,
469
+ soul_service_slug: Optional[str] = None,
470
+ schedule: Optional[dict[str, Any]] = None,
471
+ max_cost: Optional[float] = None,
472
+ memo: Optional[str] = None,
473
+ ) -> Any:
474
+ """Create a compute goal. Blocking."""
475
+ return asyncio.get_event_loop().run_until_complete(
476
+ self.acompute(
477
+ objective,
478
+ params=params,
479
+ budget_usdc=budget_usdc,
480
+ deadline=deadline,
481
+ soul_slug=soul_slug,
482
+ soul_service_slug=soul_service_slug,
483
+ schedule=schedule,
484
+ max_cost=max_cost,
485
+ memo=memo,
486
+ )
487
+ )
488
+
489
+ async def acompute(
490
+ self,
491
+ objective: str,
492
+ *,
493
+ params: Optional[dict[str, Any]] = None,
494
+ budget_usdc: Optional[float] = None,
495
+ deadline: Optional[str] = None,
496
+ soul_slug: Optional[str] = None,
497
+ soul_service_slug: Optional[str] = None,
498
+ schedule: Optional[dict[str, Any]] = None,
499
+ max_cost: Optional[float] = None,
500
+ memo: Optional[str] = None,
501
+ ) -> Any:
502
+ """Create a compute goal — the orchestrator plans + executes autonomously.
503
+
504
+ Quote+pay (x402) flow handled by `acall_tool`. Returns the
505
+ ``data`` block of the 202 response (`goal_id`, `status`, …).
506
+ """
507
+ payload: dict[str, Any] = {"objective": objective}
508
+ if params is not None:
509
+ payload["params"] = params
510
+ if budget_usdc is not None:
511
+ payload["budget_usdc"] = budget_usdc
512
+ if deadline is not None:
513
+ payload["deadline"] = deadline
514
+ if soul_slug is not None:
515
+ payload["soul_slug"] = soul_slug
516
+ if soul_service_slug is not None:
517
+ payload["soul_service_slug"] = soul_service_slug
518
+ if schedule is not None:
519
+ payload["schedule"] = schedule
520
+ if memo is not None:
521
+ payload["memo"] = memo
522
+ return await self.acall_tool("/v1/compute", payload, max_cost=max_cost)
523
+
524
+ def get_compute_goal(self, goal_id: str) -> Any:
525
+ """Get goal status. Blocking."""
526
+ return asyncio.get_event_loop().run_until_complete(
527
+ self.aget_compute_goal(goal_id)
528
+ )
529
+
530
+ async def aget_compute_goal(self, goal_id: str) -> Any:
531
+ """Get goal status. Async."""
532
+ return self._unwrap_data(await self.acall_free_get(f"/v1/compute/{goal_id}"))
533
+
534
+ def get_compute_tasks(self, goal_id: str) -> Any:
535
+ """List tasks under a goal. Blocking."""
536
+ return asyncio.get_event_loop().run_until_complete(
537
+ self.aget_compute_tasks(goal_id)
538
+ )
539
+
540
+ async def aget_compute_tasks(self, goal_id: str) -> Any:
541
+ """List tasks under a goal. Async."""
542
+ return self._unwrap_data(await self.acall_free_get(f"/v1/compute/{goal_id}/tasks"))
543
+
544
+ def get_compute_budget(self, goal_id: str) -> Any:
545
+ """Get budget breakdown. Blocking."""
546
+ return asyncio.get_event_loop().run_until_complete(
547
+ self.aget_compute_budget(goal_id)
548
+ )
549
+
550
+ async def aget_compute_budget(self, goal_id: str) -> Any:
551
+ """Get budget breakdown. Async."""
552
+ return self._unwrap_data(await self.acall_free_get(f"/v1/compute/{goal_id}/budget"))
553
+
554
+ def cancel_compute_goal(self, goal_id: str, reason: Optional[str] = None) -> Any:
555
+ """Cancel a goal; remaining budget is credited. Blocking."""
556
+ return asyncio.get_event_loop().run_until_complete(
557
+ self.acancel_compute_goal(goal_id, reason)
558
+ )
559
+
560
+ async def acancel_compute_goal(
561
+ self, goal_id: str, reason: Optional[str] = None
562
+ ) -> Any:
563
+ """Cancel a goal; remaining budget is credited. Async."""
564
+ payload: dict[str, Any] = {"reason": reason} if reason is not None else {}
565
+ return self._unwrap_data(
566
+ await self.acall_free_post(f"/v1/compute/{goal_id}/cancel", payload)
567
+ )
568
+
569
+ def respond_to_compute_task(
570
+ self,
571
+ goal_id: str,
572
+ task_id: str,
573
+ *,
574
+ response: Optional[str] = None,
575
+ approved: Optional[bool] = None,
576
+ ) -> Any:
577
+ """Respond to a human-in-the-loop approval task. Blocking."""
578
+ return asyncio.get_event_loop().run_until_complete(
579
+ self.arespond_to_compute_task(
580
+ goal_id, task_id, response=response, approved=approved
581
+ )
582
+ )
583
+
584
+ async def arespond_to_compute_task(
585
+ self,
586
+ goal_id: str,
587
+ task_id: str,
588
+ *,
589
+ response: Optional[str] = None,
590
+ approved: Optional[bool] = None,
591
+ ) -> Any:
592
+ """Respond to a human-in-the-loop approval task. Async."""
593
+ payload: dict[str, Any] = {"task_id": task_id}
594
+ if response is not None:
595
+ payload["response"] = response
596
+ if approved is not None:
597
+ payload["approved"] = approved
598
+ return self._unwrap_data(
599
+ await self.acall_free_post(f"/v1/compute/{goal_id}/respond", payload)
600
+ )
601
+
602
+ def pause_compute_goal(self, goal_id: str, reason: Optional[str] = None) -> Any:
603
+ """Pause a recurring goal. Blocking."""
604
+ return asyncio.get_event_loop().run_until_complete(
605
+ self.apause_compute_goal(goal_id, reason)
606
+ )
607
+
608
+ async def apause_compute_goal(
609
+ self, goal_id: str, reason: Optional[str] = None
610
+ ) -> Any:
611
+ """Pause a recurring goal. Async."""
612
+ payload: dict[str, Any] = {"reason": reason} if reason is not None else {}
613
+ return self._unwrap_data(
614
+ await self.acall_free_post(f"/v1/compute/{goal_id}/pause", payload)
615
+ )
616
+
617
+ def resume_compute_goal(self, goal_id: str) -> Any:
618
+ """Resume a paused recurring goal. Blocking."""
619
+ return asyncio.get_event_loop().run_until_complete(
620
+ self.aresume_compute_goal(goal_id)
621
+ )
622
+
623
+ async def aresume_compute_goal(self, goal_id: str) -> Any:
624
+ """Resume a paused recurring goal. Async."""
625
+ return self._unwrap_data(
626
+ await self.acall_free_post(f"/v1/compute/{goal_id}/resume", {})
627
+ )
628
+
629
+ def fund_compute_goal(self, goal_id: str, amount: float) -> Any:
630
+ """Top up budget on a recurring goal. Blocking."""
631
+ return asyncio.get_event_loop().run_until_complete(
632
+ self.afund_compute_goal(goal_id, amount)
633
+ )
634
+
635
+ async def afund_compute_goal(self, goal_id: str, amount: float) -> Any:
636
+ """Top up budget on a recurring goal. Quote+pay (x402) via `acall_tool`."""
637
+ if not amount or amount <= 0:
638
+ raise ValidationError("amount must be a positive number", "amount")
639
+ return await self.acall_tool(
640
+ f"/v1/compute/{goal_id}/fund", {"amount": amount}
641
+ )
642
+
420
643
  # ------------------------------------------------------------------
421
644
  # Browser
422
645
  # ------------------------------------------------------------------
@@ -529,36 +752,48 @@ class OneShotClient:
529
752
 
530
753
  def email(
531
754
  self,
532
- to: str,
533
- subject: str,
534
- body: str,
755
+ to: Optional[str] = None,
756
+ subject: Optional[str] = None,
757
+ body: str = "",
535
758
  *,
536
759
  from_domain: Optional[str] = None,
537
760
  from_mailbox: Optional[str] = None,
538
761
  from_name: Optional[str] = None,
762
+ reply_to_email_id: Optional[str] = None,
539
763
  **kwargs: Any,
540
764
  ) -> Any:
541
- """Send an email. Blocking."""
765
+ """Send an email. Blocking.
766
+
767
+ Set ``reply_to_email_id`` (an inbound email id from ``inbox()``) to
768
+ reply within a thread — ``to``/``subject`` are then derived from the
769
+ inbound message unless given.
770
+ """
542
771
  return self.call_tool(
543
772
  "/v1/tools/email/send",
544
- _build_email_payload(to, subject, body, from_domain, from_mailbox, from_name, kwargs),
773
+ _build_email_payload(to, subject, body, from_domain, from_mailbox, from_name, kwargs, reply_to_email_id=reply_to_email_id),
545
774
  )
546
775
 
547
776
  async def aemail(
548
777
  self,
549
- to: str,
550
- subject: str,
551
- body: str,
778
+ to: Optional[str] = None,
779
+ subject: Optional[str] = None,
780
+ body: str = "",
552
781
  *,
553
782
  from_domain: Optional[str] = None,
554
783
  from_mailbox: Optional[str] = None,
555
784
  from_name: Optional[str] = None,
785
+ reply_to_email_id: Optional[str] = None,
556
786
  **kwargs: Any,
557
787
  ) -> Any:
558
- """Send an email. Async."""
788
+ """Send an email. Async.
789
+
790
+ Set ``reply_to_email_id`` (an inbound email id from ``ainbox()``) to
791
+ reply within a thread — ``to``/``subject`` are then derived from the
792
+ inbound message unless given.
793
+ """
559
794
  return await self.acall_tool(
560
795
  "/v1/tools/email/send",
561
- _build_email_payload(to, subject, body, from_domain, from_mailbox, from_name, kwargs),
796
+ _build_email_payload(to, subject, body, from_domain, from_mailbox, from_name, kwargs, reply_to_email_id=reply_to_email_id),
562
797
  )
563
798
 
564
799
  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.1"
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