Skip to content

Commit 7b2955f

Browse files
committed
feat: 补齐 Agent API 与分页能力
1 parent ef886d9 commit 7b2955f

16 files changed

Lines changed: 861 additions & 71 deletions

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,16 @@ confirmation tracking. StableOps watches supported chains, matches transfers,
1010
tracks confirmations, checks reorgs, and delivers webhook events to your app.
1111

1212
This SDK is for server-side Python applications that create payment orders and
13-
checkout sessions, manage merchant subscriptions, manage webhook endpoints, and
14-
verify webhook signatures.
13+
checkout sessions, manage merchant subscriptions, webhooks and agent audit
14+
resources, and verify webhook signatures.
1515

1616
## Features
1717

1818
- Type-safe client (Pydantic v2 models) for payment orders, checkout sessions,
1919
merchant subscriptions, and webhooks.
2020
- Sync (`StableOps`) and async (`AsyncStableOps`) clients with an identical API.
21-
- Built-in request retries and explicit idempotency for write operations.
21+
- Conservative retries for reads plus explicitly idempotent payment-order and
22+
checkout-session creation; other writes are never retried automatically.
2223
- Constant-time webhook signature verification.
2324

2425
## Requirements

README.zh-CN.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ StableOps 把链上稳定币转账封装成熟悉的支付原语:支付订单
88
Webhook、自动重试与确认数跟踪。StableOps 负责扫描支持的链、匹配转账、跟踪确认数、
99
校验 reorg,并把 Webhook 事件投递到你的应用。
1010

11-
本 SDK 面向服务端 Python 应用:创建支付订单与 Checkout Session、管理商户订阅、
12-
管理 Webhook 端点、校验 Webhook 签名。
11+
本 SDK 面向服务端 Python 应用:创建支付订单与收银台会话、管理商户订阅、
12+
管理 Webhook 端点与智能体审计资源,并校验 Webhook 签名。
1313

1414
## 特性
1515

1616
- 类型安全客户端(Pydantic v2 模型),覆盖支付订单、Checkout Session、商户订阅、Webhook。
1717
- 同步(`StableOps`)与异步(`AsyncStableOps`)客户端,API 一致。
18-
- 内置请求重试,写操作显式幂等
18+
- 读取请求自动重试;支付订单和收银台会话创建依靠幂等键安全重试,其他写操作不自动重试
1919
- 常数时间的 Webhook 签名校验。
2020

2121
## 要求

stableops/__init__.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
from stableops.errors import StableOpsError
1818
from stableops.types import (
1919
AcceptedAssetInput,
20+
AgentAction,
21+
AgentActionList,
22+
AgentPolicy,
23+
AgentSession,
24+
AgentSessionList,
2025
AmountMode,
2126
CancelEndUserSubscriptionInput,
2227
ChangeEndUserSubscriptionPlanInput,
@@ -44,19 +49,23 @@
4449
PaymentOrder,
4550
PaymentOrderDetail,
4651
PaymentOrderInstruction,
52+
PaymentOrderList,
4753
PaymentOrderStatus,
4854
PaymentOrderTimelineEntry,
4955
PayMerchantInvoiceResponse,
5056
PortalSession,
5157
ReplayDeadLetterItem,
5258
ReplayDeadLettersResult,
5359
ReplayDeliveryResult,
60+
RequestAgentActionResult,
5461
UpdateMerchantBillingSettingsInput,
5562
UpdateMerchantPlanInput,
5663
UpdateWebhookEndpointInput,
5764
WebhookDelivery,
65+
WebhookDeliveryList,
5866
WebhookDeliveryStatus,
5967
WebhookEndpoint,
68+
WebhookEndpointList,
6069
WebhookEventType,
6170
)
6271
from stableops.webhooks import (
@@ -67,7 +76,7 @@
6776
verify_webhook_signature,
6877
)
6978

70-
__version__ = "0.13.0"
79+
__version__ = "1.0.0"
7180

7281
__all__ = [
7382
# Client
@@ -77,6 +86,11 @@
7786
"StableOpsError",
7887
# Types
7988
"AcceptedAssetInput",
89+
"AgentAction",
90+
"AgentActionList",
91+
"AgentPolicy",
92+
"AgentSession",
93+
"AgentSessionList",
8094
"AmountMode",
8195
"CancelEndUserSubscriptionInput",
8296
"ChangeEndUserSubscriptionPlanInput",
@@ -104,19 +118,23 @@
104118
"PaymentOrder",
105119
"PaymentOrderDetail",
106120
"PaymentOrderInstruction",
121+
"PaymentOrderList",
107122
"PaymentOrderStatus",
108123
"PaymentOrderTimelineEntry",
109124
"PayMerchantInvoiceResponse",
110125
"PortalSession",
111126
"ReplayDeadLetterItem",
112127
"ReplayDeadLettersResult",
113128
"ReplayDeliveryResult",
129+
"RequestAgentActionResult",
114130
"UpdateMerchantBillingSettingsInput",
115131
"UpdateMerchantPlanInput",
116132
"UpdateWebhookEndpointInput",
117133
"WebhookDelivery",
134+
"WebhookDeliveryList",
118135
"WebhookDeliveryStatus",
119136
"WebhookEndpoint",
137+
"WebhookEndpointList",
120138
"WebhookEventType",
121139
# Webhooks
122140
"verify_webhook_signature",

stableops/agents.py

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
"""Agent sessions, policies, and action audit APIs."""
2+
3+
from typing import Any, Dict, List, Optional
4+
from urllib.parse import quote
5+
6+
from stableops.http import AsyncHttpClient, HttpClient
7+
from stableops.types import (
8+
AgentAction,
9+
AgentActionList,
10+
AgentPolicy,
11+
AgentSession,
12+
AgentSessionList,
13+
RequestAgentActionResult,
14+
)
15+
16+
17+
def _path(value: str) -> str:
18+
return quote(value, safe="")
19+
20+
21+
class AgentsApi:
22+
"""Agent API (synchronous)."""
23+
24+
def __init__(self, http: HttpClient) -> None:
25+
self.http = http
26+
27+
def create_session(
28+
self, label: Optional[str] = None, expires_at: Optional[str] = None
29+
) -> AgentSession:
30+
response = self.http.request(
31+
method="POST",
32+
path="/v1/agent/sessions",
33+
body={
34+
key: value
35+
for key, value in {"label": label, "expires_at": expires_at}.items()
36+
if value is not None
37+
},
38+
)
39+
return AgentSession(**response)
40+
41+
def list_sessions(
42+
self, limit: Optional[int] = None, offset: Optional[int] = None
43+
) -> AgentSessionList:
44+
response = self.http.request(
45+
method="GET",
46+
path="/v1/agent/sessions",
47+
query={"limit": limit, "offset": offset},
48+
)
49+
return AgentSessionList(**response)
50+
51+
def revoke_session(self, session_id: str) -> AgentSession:
52+
response = self.http.request(
53+
method="POST", path=f"/v1/agent/sessions/{_path(session_id)}/revoke"
54+
)
55+
return AgentSession(**response)
56+
57+
def restore_session(self, session_id: str) -> AgentSession:
58+
response = self.http.request(
59+
method="POST", path=f"/v1/agent/sessions/{_path(session_id)}/restore"
60+
)
61+
return AgentSession(**response)
62+
63+
def get_policy(self) -> AgentPolicy:
64+
return AgentPolicy(**self.http.request(method="GET", path="/v1/agent/policy"))
65+
66+
def upsert_policy(
67+
self,
68+
allowed_tools: Optional[List[str]] = None,
69+
require_approval: Optional[bool] = None,
70+
) -> AgentPolicy:
71+
body: Dict[str, Any] = {}
72+
if allowed_tools is not None:
73+
body["allowed_tools"] = allowed_tools
74+
if require_approval is not None:
75+
body["require_approval"] = require_approval
76+
response = self.http.request(method="POST", path="/v1/agent/policy", body=body)
77+
return AgentPolicy(**response)
78+
79+
def request_action(
80+
self, agent_session_id: str, tool: str, input: Dict[str, Any]
81+
) -> RequestAgentActionResult:
82+
response = self.http.request(
83+
method="POST",
84+
path="/v1/agent/actions",
85+
body={"agent_session_id": agent_session_id, "tool": tool, "input": input},
86+
)
87+
return RequestAgentActionResult(**response)
88+
89+
def list_actions(
90+
self,
91+
session_id: Optional[str] = None,
92+
limit: Optional[int] = None,
93+
offset: Optional[int] = None,
94+
) -> AgentActionList:
95+
response = self.http.request(
96+
method="GET",
97+
path="/v1/agent/actions",
98+
query={"session_id": session_id, "limit": limit, "offset": offset},
99+
)
100+
return AgentActionList(**response)
101+
102+
def approve_action(self, action_id: str, approver_id: Optional[str] = None) -> AgentAction:
103+
body = {} if approver_id is None else {"approver_id": approver_id}
104+
response = self.http.request(
105+
method="POST", path=f"/v1/agent/actions/{_path(action_id)}/approve", body=body
106+
)
107+
return AgentAction(**response)
108+
109+
def reject_action(
110+
self,
111+
action_id: str,
112+
approver_id: Optional[str] = None,
113+
reason: Optional[str] = None,
114+
) -> AgentAction:
115+
body = {
116+
key: value
117+
for key, value in {"approver_id": approver_id, "reason": reason}.items()
118+
if value is not None
119+
}
120+
response = self.http.request(
121+
method="POST", path=f"/v1/agent/actions/{_path(action_id)}/reject", body=body
122+
)
123+
return AgentAction(**response)
124+
125+
def mark_executed(
126+
self,
127+
action_id: str,
128+
agent_session_id: str,
129+
result: Any = None,
130+
error_message: Optional[str] = None,
131+
) -> AgentAction:
132+
if result is not None and error_message is not None:
133+
raise ValueError("result and error_message are mutually exclusive")
134+
body: Dict[str, Any] = {"agent_session_id": agent_session_id}
135+
if error_message is not None:
136+
body["error_message"] = error_message
137+
else:
138+
body["result"] = result
139+
response = self.http.request(
140+
method="POST", path=f"/v1/agent/actions/{_path(action_id)}/executed", body=body
141+
)
142+
return AgentAction(**response)
143+
144+
145+
class AsyncAgentsApi:
146+
"""Agent API (asynchronous)."""
147+
148+
def __init__(self, http: AsyncHttpClient) -> None:
149+
self.http = http
150+
151+
async def create_session(
152+
self, label: Optional[str] = None, expires_at: Optional[str] = None
153+
) -> AgentSession:
154+
response = await self.http.request(
155+
method="POST",
156+
path="/v1/agent/sessions",
157+
body={
158+
key: value
159+
for key, value in {"label": label, "expires_at": expires_at}.items()
160+
if value is not None
161+
},
162+
)
163+
return AgentSession(**response)
164+
165+
async def list_sessions(
166+
self, limit: Optional[int] = None, offset: Optional[int] = None
167+
) -> AgentSessionList:
168+
response = await self.http.request(
169+
method="GET",
170+
path="/v1/agent/sessions",
171+
query={"limit": limit, "offset": offset},
172+
)
173+
return AgentSessionList(**response)
174+
175+
async def revoke_session(self, session_id: str) -> AgentSession:
176+
response = await self.http.request(
177+
method="POST", path=f"/v1/agent/sessions/{_path(session_id)}/revoke"
178+
)
179+
return AgentSession(**response)
180+
181+
async def restore_session(self, session_id: str) -> AgentSession:
182+
response = await self.http.request(
183+
method="POST", path=f"/v1/agent/sessions/{_path(session_id)}/restore"
184+
)
185+
return AgentSession(**response)
186+
187+
async def get_policy(self) -> AgentPolicy:
188+
response = await self.http.request(method="GET", path="/v1/agent/policy")
189+
return AgentPolicy(**response)
190+
191+
async def upsert_policy(
192+
self,
193+
allowed_tools: Optional[List[str]] = None,
194+
require_approval: Optional[bool] = None,
195+
) -> AgentPolicy:
196+
body: Dict[str, Any] = {}
197+
if allowed_tools is not None:
198+
body["allowed_tools"] = allowed_tools
199+
if require_approval is not None:
200+
body["require_approval"] = require_approval
201+
response = await self.http.request(method="POST", path="/v1/agent/policy", body=body)
202+
return AgentPolicy(**response)
203+
204+
async def request_action(
205+
self, agent_session_id: str, tool: str, input: Dict[str, Any]
206+
) -> RequestAgentActionResult:
207+
response = await self.http.request(
208+
method="POST",
209+
path="/v1/agent/actions",
210+
body={"agent_session_id": agent_session_id, "tool": tool, "input": input},
211+
)
212+
return RequestAgentActionResult(**response)
213+
214+
async def list_actions(
215+
self,
216+
session_id: Optional[str] = None,
217+
limit: Optional[int] = None,
218+
offset: Optional[int] = None,
219+
) -> AgentActionList:
220+
response = await self.http.request(
221+
method="GET",
222+
path="/v1/agent/actions",
223+
query={"session_id": session_id, "limit": limit, "offset": offset},
224+
)
225+
return AgentActionList(**response)
226+
227+
async def approve_action(
228+
self, action_id: str, approver_id: Optional[str] = None
229+
) -> AgentAction:
230+
body = {} if approver_id is None else {"approver_id": approver_id}
231+
response = await self.http.request(
232+
method="POST", path=f"/v1/agent/actions/{_path(action_id)}/approve", body=body
233+
)
234+
return AgentAction(**response)
235+
236+
async def reject_action(
237+
self,
238+
action_id: str,
239+
approver_id: Optional[str] = None,
240+
reason: Optional[str] = None,
241+
) -> AgentAction:
242+
body = {
243+
key: value
244+
for key, value in {"approver_id": approver_id, "reason": reason}.items()
245+
if value is not None
246+
}
247+
response = await self.http.request(
248+
method="POST", path=f"/v1/agent/actions/{_path(action_id)}/reject", body=body
249+
)
250+
return AgentAction(**response)
251+
252+
async def mark_executed(
253+
self,
254+
action_id: str,
255+
agent_session_id: str,
256+
result: Any = None,
257+
error_message: Optional[str] = None,
258+
) -> AgentAction:
259+
if result is not None and error_message is not None:
260+
raise ValueError("result and error_message are mutually exclusive")
261+
body: Dict[str, Any] = {"agent_session_id": agent_session_id}
262+
if error_message is not None:
263+
body["error_message"] = error_message
264+
else:
265+
body["result"] = result
266+
response = await self.http.request(
267+
method="POST", path=f"/v1/agent/actions/{_path(action_id)}/executed", body=body
268+
)
269+
return AgentAction(**response)

0 commit comments

Comments
 (0)