pushary 2.3.0__tar.gz → 2.4.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.
- {pushary-2.3.0 → pushary-2.4.1}/PKG-INFO +12 -6
- {pushary-2.3.0 → pushary-2.4.1}/README.md +10 -4
- pushary-2.4.1/examples/stepfun/README.md +491 -0
- {pushary-2.3.0 → pushary-2.4.1}/pyproject.toml +2 -2
- {pushary-2.3.0 → pushary-2.4.1}/src/pushary/__init__.py +1 -1
- {pushary-2.3.0 → pushary-2.4.1}/src/pushary/reminders.py +7 -2
- {pushary-2.3.0 → pushary-2.4.1}/tests/test_reminders.py +4 -2
- pushary-2.4.1/tests/test_stepfun_example.py +32 -0
- {pushary-2.3.0 → pushary-2.4.1}/.gitignore +0 -0
- {pushary-2.3.0 → pushary-2.4.1}/LICENSE +0 -0
- {pushary-2.3.0 → pushary-2.4.1}/examples/agno/README.md +0 -0
- {pushary-2.3.0 → pushary-2.4.1}/examples/dify/README.md +0 -0
- {pushary-2.3.0 → pushary-2.4.1}/examples/llamaindex/README.md +0 -0
- {pushary-2.3.0 → pushary-2.4.1}/examples/microsoft/README.md +0 -0
- {pushary-2.3.0 → pushary-2.4.1}/examples/strands/README.md +0 -0
- {pushary-2.3.0 → pushary-2.4.1}/src/pushary/_types.py +0 -0
- {pushary-2.3.0 → pushary-2.4.1}/src/pushary/_util.py +0 -0
- {pushary-2.3.0 → pushary-2.4.1}/src/pushary/adapters.py +0 -0
- {pushary-2.3.0 → pushary-2.4.1}/src/pushary/client.py +0 -0
- {pushary-2.3.0 → pushary-2.4.1}/src/pushary/decisions.py +0 -0
- {pushary-2.3.0 → pushary-2.4.1}/src/pushary/errors.py +0 -0
- {pushary-2.3.0 → pushary-2.4.1}/src/pushary/keys.py +0 -0
- {pushary-2.3.0 → pushary-2.4.1}/src/pushary/py.typed +0 -0
- {pushary-2.3.0 → pushary-2.4.1}/src/pushary/webhook.py +0 -0
- {pushary-2.3.0 → pushary-2.4.1}/tests/test_adapters.py +0 -0
- {pushary-2.3.0 → pushary-2.4.1}/tests/test_agent.py +0 -0
- {pushary-2.3.0 → pushary-2.4.1}/tests/test_client.py +0 -0
- {pushary-2.3.0 → pushary-2.4.1}/tests/test_webhook.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pushary
|
|
3
|
-
Version: 2.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 2.4.1
|
|
4
|
+
Summary: Human-in-the-loop (HITL) for AI agents: ask a person for approvals or answers by push notification.
|
|
5
5
|
Project-URL: Homepage, https://pushary.com
|
|
6
6
|
Project-URL: Documentation, https://pushary.com/docs
|
|
7
7
|
Project-URL: Repository, https://github.com/Pushary/pushary-python
|
|
@@ -28,15 +28,15 @@ Description-Content-Type: text/markdown
|
|
|
28
28
|
|
|
29
29
|
# pushary
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
Human-in-the-loop (HITL) for AI agents. Your agent asks, and a person approves or answers on their phone by push notification.
|
|
32
32
|
|
|
33
33
|
This is the Python counterpart of the `@pushary/server` SDK. It is zero
|
|
34
34
|
dependency (Python standard library only) and targets Python 3.9 and newer.
|
|
35
35
|
|
|
36
36
|
## What you need
|
|
37
37
|
|
|
38
|
-
- A Pushary Partner plan, from $99 a month. [Start the trial](https://pushary.com/sign-up?from=agent&plan=partner).
|
|
39
|
-
- An API key from [Partner onboarding](https://pushary.com/onboarding/partner), set as `PUSHARY_API_KEY`.
|
|
38
|
+
- A Pushary Partner plan, from $99 a month. [Start the trial](https://pushary.com/sign-up?from=agent&plan=partner&utm_source=github&utm_medium=oss-adapter&utm_campaign=pushary-python&utm_content=partner-start).
|
|
39
|
+
- An API key from [Partner onboarding](https://pushary.com/onboarding/partner?utm_source=github&utm_medium=oss-adapter&utm_campaign=pushary-python&utm_content=partner-start), set as `PUSHARY_API_KEY`.
|
|
40
40
|
- Your users install the free Pushary app ([iPhone](https://apps.apple.com/us/app/pushary/id6785677563), [Android](https://play.google.com/store/apps/details?id=com.pushary.app)). They never sign up or pay.
|
|
41
41
|
|
|
42
42
|
## Installation
|
|
@@ -49,7 +49,7 @@ pip install pushary
|
|
|
49
49
|
|
|
50
50
|
The SDK needs your full API key (`pk_xxx.sk_xxx`), which includes the secret
|
|
51
51
|
half. Never expose it in client-side code. Get your key from your
|
|
52
|
-
[Pushary dashboard](https://pushary.com/
|
|
52
|
+
[Pushary dashboard](https://pushary.com/onboarding/partner?utm_source=github&utm_medium=oss-adapter&utm_campaign=pushary-python&utm_content=partner-start).
|
|
53
53
|
|
|
54
54
|
```python
|
|
55
55
|
import os
|
|
@@ -84,6 +84,12 @@ waits or your own resume logic, use `create` plus a webhook or `get` below.
|
|
|
84
84
|
|
|
85
85
|
## Framework recipes
|
|
86
86
|
|
|
87
|
+
### StepFun
|
|
88
|
+
|
|
89
|
+
[Require customer approval before a Step 5 tool releases an order](examples/stepfun/README.md).
|
|
90
|
+
Persists the model proposal and tool-call IDs across worker restarts, uses one-use
|
|
91
|
+
execution permits, and includes a runnable offline demonstration.
|
|
92
|
+
|
|
87
93
|
### Agno
|
|
88
94
|
|
|
89
95
|
[Persist an Agno workflow pause and resume after customer phone approval](examples/agno/README.md).
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# pushary
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Human-in-the-loop (HITL) for AI agents. Your agent asks, and a person approves or answers on their phone by push notification.
|
|
4
4
|
|
|
5
5
|
This is the Python counterpart of the `@pushary/server` SDK. It is zero
|
|
6
6
|
dependency (Python standard library only) and targets Python 3.9 and newer.
|
|
7
7
|
|
|
8
8
|
## What you need
|
|
9
9
|
|
|
10
|
-
- A Pushary Partner plan, from $99 a month. [Start the trial](https://pushary.com/sign-up?from=agent&plan=partner).
|
|
11
|
-
- An API key from [Partner onboarding](https://pushary.com/onboarding/partner), set as `PUSHARY_API_KEY`.
|
|
10
|
+
- A Pushary Partner plan, from $99 a month. [Start the trial](https://pushary.com/sign-up?from=agent&plan=partner&utm_source=github&utm_medium=oss-adapter&utm_campaign=pushary-python&utm_content=partner-start).
|
|
11
|
+
- An API key from [Partner onboarding](https://pushary.com/onboarding/partner?utm_source=github&utm_medium=oss-adapter&utm_campaign=pushary-python&utm_content=partner-start), set as `PUSHARY_API_KEY`.
|
|
12
12
|
- Your users install the free Pushary app ([iPhone](https://apps.apple.com/us/app/pushary/id6785677563), [Android](https://play.google.com/store/apps/details?id=com.pushary.app)). They never sign up or pay.
|
|
13
13
|
|
|
14
14
|
## Installation
|
|
@@ -21,7 +21,7 @@ pip install pushary
|
|
|
21
21
|
|
|
22
22
|
The SDK needs your full API key (`pk_xxx.sk_xxx`), which includes the secret
|
|
23
23
|
half. Never expose it in client-side code. Get your key from your
|
|
24
|
-
[Pushary dashboard](https://pushary.com/
|
|
24
|
+
[Pushary dashboard](https://pushary.com/onboarding/partner?utm_source=github&utm_medium=oss-adapter&utm_campaign=pushary-python&utm_content=partner-start).
|
|
25
25
|
|
|
26
26
|
```python
|
|
27
27
|
import os
|
|
@@ -56,6 +56,12 @@ waits or your own resume logic, use `create` plus a webhook or `get` below.
|
|
|
56
56
|
|
|
57
57
|
## Framework recipes
|
|
58
58
|
|
|
59
|
+
### StepFun
|
|
60
|
+
|
|
61
|
+
[Require customer approval before a Step 5 tool releases an order](examples/stepfun/README.md).
|
|
62
|
+
Persists the model proposal and tool-call IDs across worker restarts, uses one-use
|
|
63
|
+
execution permits, and includes a runnable offline demonstration.
|
|
64
|
+
|
|
59
65
|
### Agno
|
|
60
66
|
|
|
61
67
|
[Persist an Agno workflow pause and resume after customer phone approval](examples/agno/README.md).
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
# Step 5 tool calling: let the customer approve before an order changes
|
|
2
|
+
|
|
3
|
+
Step 5 Preview takes messages and function definitions and returns text or
|
|
4
|
+
structured tool-call proposals. Your application executes the functions. That
|
|
5
|
+
is where customer approval belongs.
|
|
6
|
+
|
|
7
|
+
This recipe puts the decision on the customer's phone. Step 5 proposes an order
|
|
8
|
+
change, Pushary asks the customer, and your backend changes the order only after
|
|
9
|
+
approval. If the worker restarts, it resumes the saved request.
|
|
10
|
+
|
|
11
|
+
You will build this with Python, two synthetic orders and the existing Pushary
|
|
12
|
+
SDK. The demo writes to a local SQLite table. It does not take a payment or send
|
|
13
|
+
a shipment. Start with the offline demo below; it needs no accounts or keys.
|
|
14
|
+
|
|
15
|
+
## Prepare your environment
|
|
16
|
+
|
|
17
|
+
Use Python 3.9 or newer. The only dependency is the published, pinned
|
|
18
|
+
`pushary==2.4.0` SDK; HTTP, JSON and SQLite use the Python standard library.
|
|
19
|
+
StepFun and Pushary are independent services. Live execution needs StepFun API
|
|
20
|
+
access and a Pushary Partner workspace with a connected test customer's phone.
|
|
21
|
+
|
|
22
|
+
From this example's directory, on macOS or Linux:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
python3 -m venv .venv
|
|
26
|
+
.venv/bin/python -m pip install -r requirements.txt
|
|
27
|
+
.venv/bin/python check_run.py --demo
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
On Windows PowerShell:
|
|
31
|
+
|
|
32
|
+
```powershell
|
|
33
|
+
py -3 -m venv .venv
|
|
34
|
+
.venv\Scripts\python -m pip install -r requirements.txt
|
|
35
|
+
.venv\Scripts\python check_run.py --demo
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The offline demo needs no accounts or credentials. It explicitly simulates the
|
|
39
|
+
StepFun response and phone answer while running the published SDK and separate
|
|
40
|
+
SQLite-backed worker processes. It prints zero order changes before approval,
|
|
41
|
+
two changes afterward, and still two after replay. This is reproducible local
|
|
42
|
+
evidence, not a live phone or model-provider test.
|
|
43
|
+
|
|
44
|
+
For live execution, obtain a key from the [StepFun platform](https://platform.stepfun.ai/)
|
|
45
|
+
and your [Pushary Partner workspace](https://pushary.com/sign-up?plan=partner&utm_source=github&utm_medium=oss-adapter&utm_campaign=pushary-stepfun&utm_content=readme).
|
|
46
|
+
Your backend must already have [enrolled the test customer](https://github.com/Pushary/pushary-python#quick-start-two-calls).
|
|
47
|
+
Use the customer's application ID, not an email invented by the model.
|
|
48
|
+
|
|
49
|
+
Inject `STEP_API_KEY` and `PUSHARY_API_KEY` as environment variables from your
|
|
50
|
+
secret manager. For local macOS/Linux use hidden terminal input:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
read -r -s STEP_API_KEY
|
|
54
|
+
export STEP_API_KEY
|
|
55
|
+
read -r -s PUSHARY_API_KEY
|
|
56
|
+
export PUSHARY_API_KEY
|
|
57
|
+
export STEPFUN_REGION=global
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
For PowerShell, use hidden input:
|
|
61
|
+
|
|
62
|
+
```powershell
|
|
63
|
+
$env:STEP_API_KEY = [System.Net.NetworkCredential]::new('', (Read-Host 'StepFun API key' -AsSecureString)).Password
|
|
64
|
+
$env:PUSHARY_API_KEY = [System.Net.NetworkCredential]::new('', (Read-Host 'Pushary API key' -AsSecureString)).Password
|
|
65
|
+
$env:STEPFUN_REGION = 'global'
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The global API is `https://api.stepfun.ai/v1`; `STEPFUN_REGION=cn` selects
|
|
69
|
+
`https://api.stepfun.com/v1`. Use a key provisioned for your selected region.
|
|
70
|
+
The example requests `step-5-preview`, low reasoning effort, a 4,096-token output
|
|
71
|
+
limit, a 90-second HTTP socket timeout and at most two proposed order calls.
|
|
72
|
+
It makes one proposal request and one final-summary request on the successful
|
|
73
|
+
path. It does not automatically retry HTTP requests.
|
|
74
|
+
|
|
75
|
+
## Build the smallest complete flow
|
|
76
|
+
|
|
77
|
+
Save the following four Python blocks, in order, as `run.py`. The adjacent
|
|
78
|
+
`run.py` contains exactly these blocks and is ready to run. Each block owns a
|
|
79
|
+
single part of the application boundary.
|
|
80
|
+
|
|
81
|
+
### Define the model request and validate proposals
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
"""Step 5 tool proposals, customer approval and a persisted local order effect."""
|
|
85
|
+
from __future__ import annotations
|
|
86
|
+
|
|
87
|
+
import json
|
|
88
|
+
import os
|
|
89
|
+
import re
|
|
90
|
+
import sqlite3
|
|
91
|
+
import sys
|
|
92
|
+
import time
|
|
93
|
+
import urllib.request
|
|
94
|
+
import uuid
|
|
95
|
+
from dataclasses import dataclass
|
|
96
|
+
from pathlib import Path
|
|
97
|
+
|
|
98
|
+
from pushary.adapters import AdapterKernel, decision_fingerprint
|
|
99
|
+
|
|
100
|
+
REVISION = "stepfun-order-v1"
|
|
101
|
+
MODEL = "step-5-preview"
|
|
102
|
+
APPROVAL_SECONDS = 3600
|
|
103
|
+
MAX_RESPONSE_BYTES = 1_000_000
|
|
104
|
+
ORDERS = {"order-1": 1900, "order-2": 1900}
|
|
105
|
+
BASE_URLS = {"global": "https://api.stepfun.ai/v1", "cn": "https://api.stepfun.com/v1"}
|
|
106
|
+
TOOL = {"type": "function", "function": {
|
|
107
|
+
"name": "release_order",
|
|
108
|
+
"description": "Propose releasing a local demo order. The application requires customer approval.",
|
|
109
|
+
"parameters": {"type": "object", "properties": {
|
|
110
|
+
"order_id": {"type": "string", "enum": list(ORDERS)},
|
|
111
|
+
"amount_cents": {"type": "integer", "enum": [1900]}},
|
|
112
|
+
"required": ["order_id", "amount_cents"], "additionalProperties": False}}}
|
|
113
|
+
INITIAL_MESSAGES = [
|
|
114
|
+
{"role": "system", "content": "Propose release_order for each requested order. The application obtains customer approval. Never claim an order changed without a successful tool result."},
|
|
115
|
+
{"role": "user", "content": "Release order-1 and order-2, each for 1900 cents. These are local demonstration orders."},
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def json_object(value: object) -> dict[str, object]:
|
|
120
|
+
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
|
121
|
+
raise ValueError("Expected a JSON object")
|
|
122
|
+
return dict(value)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def bounded_text(value: object, maximum: int = 100) -> str:
|
|
126
|
+
if not isinstance(value, str) or not value.strip() or len(value) > maximum:
|
|
127
|
+
raise ValueError("Expected bounded text")
|
|
128
|
+
return value
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def identity(value: object) -> str:
|
|
132
|
+
text = bounded_text(value)
|
|
133
|
+
if not re.fullmatch(r"[A-Za-z0-9_.:@-]+", text):
|
|
134
|
+
raise ValueError("Use identities from authenticated application state")
|
|
135
|
+
return text
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class OrderCall:
|
|
140
|
+
call_id: str
|
|
141
|
+
order_id: str
|
|
142
|
+
amount_cents: int
|
|
143
|
+
|
|
144
|
+
def result(self) -> dict[str, object]:
|
|
145
|
+
return {"order_id": self.order_id, "amount_cents": self.amount_cents, "status": "released"}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class Proposal:
|
|
150
|
+
assistant: dict[str, object]
|
|
151
|
+
calls: tuple[OrderCall, ...]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def parse_order_call(value: object) -> OrderCall:
|
|
155
|
+
call = json_object(value)
|
|
156
|
+
function = json_object(call.get("function"))
|
|
157
|
+
if call.get("type") != "function" or function.get("name") != "release_order":
|
|
158
|
+
raise ValueError("Unsupported tool")
|
|
159
|
+
arguments = json_object(json.loads(bounded_text(function.get("arguments"), 1000)))
|
|
160
|
+
if set(arguments) != {"order_id", "amount_cents"}:
|
|
161
|
+
raise ValueError("Unexpected tool arguments")
|
|
162
|
+
order_id = bounded_text(arguments["order_id"])
|
|
163
|
+
amount = arguments["amount_cents"]
|
|
164
|
+
if type(amount) is not int or order_id not in ORDERS or amount != ORDERS[order_id]:
|
|
165
|
+
raise ValueError("Order proposal differs from the application catalog")
|
|
166
|
+
return OrderCall(identity(call.get("id")), order_id, amount)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def response_choice(value: object) -> dict[str, object]:
|
|
170
|
+
response = json_object(value)
|
|
171
|
+
choices = response.get("choices")
|
|
172
|
+
if not isinstance(choices, list) or len(choices) != 1:
|
|
173
|
+
raise ValueError("Expected one complete model choice")
|
|
174
|
+
return json_object(choices[0])
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def parse_proposal(value: object) -> Proposal:
|
|
178
|
+
choice = response_choice(value)
|
|
179
|
+
message = json_object(choice.get("message"))
|
|
180
|
+
if choice.get("finish_reason") not in ("tool_calls", "stop") or message.get("role") != "assistant" or message.get("refusal"):
|
|
181
|
+
raise ValueError("Model did not return a complete proposal")
|
|
182
|
+
requested = message.get("tool_calls")
|
|
183
|
+
if not isinstance(requested, list) or not 1 <= len(requested) <= len(ORDERS):
|
|
184
|
+
raise ValueError("Expected one or two order proposals")
|
|
185
|
+
calls = tuple(parse_order_call(call) for call in requested)
|
|
186
|
+
if len({call.call_id for call in calls}) != len(calls) or len({call.order_id for call in calls}) != len(calls):
|
|
187
|
+
raise ValueError("Repeated tool call or order")
|
|
188
|
+
assistant = {key: message[key] for key in ("role", "content", "tool_calls", "reasoning_content") if key in message}
|
|
189
|
+
for field in ("content", "reasoning_content"):
|
|
190
|
+
text = assistant.get(field)
|
|
191
|
+
if text is not None and (not isinstance(text, str) or len(text) > MAX_RESPONSE_BYTES):
|
|
192
|
+
raise ValueError("Invalid assistant text")
|
|
193
|
+
return Proposal(assistant, calls)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def parse_final(value: object) -> str:
|
|
197
|
+
choice = response_choice(value)
|
|
198
|
+
message = json_object(choice.get("message"))
|
|
199
|
+
if choice.get("finish_reason") != "stop" or message.get("role") != "assistant" or message.get("tool_calls") or message.get("refusal"):
|
|
200
|
+
raise ValueError("Final answer incomplete or requested another action")
|
|
201
|
+
return bounded_text(message.get("content"), MAX_RESPONSE_BYTES)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def step_chat(payload: dict[str, object]) -> object:
|
|
205
|
+
region = os.environ.get("STEPFUN_REGION", "global")
|
|
206
|
+
if region not in BASE_URLS:
|
|
207
|
+
raise ValueError("STEPFUN_REGION must be global or cn")
|
|
208
|
+
key = bounded_text(os.environ.get("STEP_API_KEY"), 1000)
|
|
209
|
+
request = urllib.request.Request(BASE_URLS[region] + "/chat/completions",
|
|
210
|
+
data=json.dumps(payload, allow_nan=False).encode(),
|
|
211
|
+
headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"}, method="POST")
|
|
212
|
+
with urllib.request.urlopen(request, timeout=90) as response:
|
|
213
|
+
body = response.read(MAX_RESPONSE_BYTES + 1)
|
|
214
|
+
if len(body) > MAX_RESPONSE_BYTES:
|
|
215
|
+
raise ValueError("Model response exceeded the byte limit")
|
|
216
|
+
return json.loads(body)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def model_request(messages: list[dict[str, object]], tool_choice: str) -> dict[str, object]:
|
|
220
|
+
return {"model": MODEL, "messages": messages, "tools": [TOOL], "tool_choice": tool_choice,
|
|
221
|
+
"reasoning_effort": "low", "max_tokens": 4096, "stream": False}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The parser accepts only the known function, catalog order IDs, integer amounts, unique call IDs and a completed response. Unknown arguments, including a model-selected recipient, stop before any approval request. The original assistant message retains `reasoning_content` and each raw tool argument string.
|
|
225
|
+
|
|
226
|
+
### Save the proposal before asking for approval
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
@dataclass(frozen=True)
|
|
230
|
+
class Operation:
|
|
231
|
+
tenant: str
|
|
232
|
+
customer: str
|
|
233
|
+
run_id: str
|
|
234
|
+
expires_at: int
|
|
235
|
+
proposal: Proposal
|
|
236
|
+
|
|
237
|
+
def payload(self) -> dict[str, object]:
|
|
238
|
+
return {"revision": REVISION, "tenant": self.tenant, "customer": self.customer,
|
|
239
|
+
"run_id": self.run_id, "expires_at": self.expires_at, "assistant": self.proposal.assistant}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def load_operation(db: sqlite3.Connection, tenant: str, customer: str) -> Operation:
|
|
243
|
+
row = db.execute("select payload,digest from operation where id=1").fetchone()
|
|
244
|
+
if row is None:
|
|
245
|
+
raise ValueError("No saved proposal; reconcile this run")
|
|
246
|
+
payload = json_object(json.loads(bounded_text(row[0], MAX_RESPONSE_BYTES)))
|
|
247
|
+
if decision_fingerprint(payload) != row[1] or set(payload) != {"revision", "tenant", "customer", "run_id", "expires_at", "assistant"}:
|
|
248
|
+
raise ValueError("Saved operation changed")
|
|
249
|
+
if payload["revision"] != REVISION or payload["tenant"] != tenant or payload["customer"] != customer:
|
|
250
|
+
raise ValueError("Operation does not belong to this caller or code revision")
|
|
251
|
+
deadline = payload["expires_at"]
|
|
252
|
+
if type(deadline) is not int or deadline <= 0:
|
|
253
|
+
raise ValueError("Invalid approval deadline")
|
|
254
|
+
proposal = parse_proposal({"choices": [{"finish_reason": "tool_calls", "message": payload["assistant"]}]})
|
|
255
|
+
return Operation(tenant, customer, identity(payload["run_id"]), deadline, proposal)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def start_operation(folder: Path, tenant: str, customer: str) -> None:
|
|
259
|
+
folder.mkdir(mode=0o700)
|
|
260
|
+
with sqlite3.connect(folder / "orders.db") as db:
|
|
261
|
+
db.executescript("create table operation(id integer primary key check(id=1), payload text not null, digest text not null, final text);"
|
|
262
|
+
"create table released(call_id text primary key, order_id text unique not null, result text not null);")
|
|
263
|
+
proposal = parse_proposal(step_chat(model_request(INITIAL_MESSAGES, "auto")))
|
|
264
|
+
operation = Operation(tenant, customer, uuid.uuid4().hex, int(time.time()) + APPROVAL_SECONDS, proposal)
|
|
265
|
+
payload = operation.payload()
|
|
266
|
+
db.execute("insert into operation(id,payload,digest) values (1,?,?)", (json.dumps(payload), decision_fingerprint(payload)))
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
The operation binds the trusted tenant, customer, run ID, deadline and assistant proposal. Saving occurs before contacting Pushary. A hash catches accidental storage changes; it is not a signature against someone who controls the database. Resume also checks the caller and code revision.
|
|
270
|
+
|
|
271
|
+
### Consume a one-use permit before changing the order
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
def release_approved_order(db: sqlite3.Connection, operation: Operation, call: OrderCall) -> dict[str, object]:
|
|
275
|
+
if time.time() >= operation.expires_at:
|
|
276
|
+
raise TimeoutError("Approval expired before execution")
|
|
277
|
+
result = call.result()
|
|
278
|
+
db.execute("insert into released values (?,?,?)", (call.call_id, call.order_id, json.dumps(result)))
|
|
279
|
+
db.commit()
|
|
280
|
+
return result
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def read_releases(db: sqlite3.Connection, operation: Operation) -> dict[str, dict[str, object]]:
|
|
284
|
+
released = {}
|
|
285
|
+
for call in operation.proposal.calls:
|
|
286
|
+
row = db.execute("select order_id,result from released where call_id=?", (call.call_id,)).fetchone()
|
|
287
|
+
if row is not None:
|
|
288
|
+
result = json_object(json.loads(row[1]))
|
|
289
|
+
if row[0] != call.order_id or result != call.result():
|
|
290
|
+
raise ValueError("Saved effect differs from the approved proposal")
|
|
291
|
+
released[call.call_id] = result
|
|
292
|
+
return released
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def approve_order(db: sqlite3.Connection, operation: Operation, call: OrderCall) -> str | None:
|
|
296
|
+
if time.time() >= operation.expires_at:
|
|
297
|
+
return "Approval expired"
|
|
298
|
+
protect = AdapterKernel("StepFun order cookbook").create_protect(
|
|
299
|
+
policy=False, timeout_seconds=0, expires_in_seconds=APPROVAL_SECONDS, require_reachable=True)
|
|
300
|
+
|
|
301
|
+
def execute() -> dict[str, object]:
|
|
302
|
+
return release_approved_order(db, operation, call)
|
|
303
|
+
|
|
304
|
+
result = protect("order.release", execute, external_id=operation.customer,
|
|
305
|
+
run_id=operation.run_id, call_id=call.call_id, target=call.order_id, actor=operation.tenant,
|
|
306
|
+
question=f"Release demo {call.order_id} for {call.amount_cents} cents?",
|
|
307
|
+
facts={"order_id": call.order_id, "amount_cents": call.amount_cents,
|
|
308
|
+
"pushary_binding": decision_fingerprint(operation.payload())})
|
|
309
|
+
return None if result.ok else bounded_text(result.reason, 2000)
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
The SDK asks a confirm question and consumes an execution permit before calling the effect. Missing, refused, expired or unconfirmed permission leaves the order unchanged. The effect commits its result before the SDK reports the receipt, so a crash during receipt delivery does not repeat the order change.
|
|
313
|
+
|
|
314
|
+
### Return the matching tool results and resume safely
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
def finish_operation(db: sqlite3.Connection, operation: Operation, released: dict[str, dict[str, object]]) -> dict[str, object]:
|
|
318
|
+
db.execute("begin immediate")
|
|
319
|
+
row = db.execute("select final from operation where id=1").fetchone()
|
|
320
|
+
if row is None:
|
|
321
|
+
raise ValueError("Saved operation missing")
|
|
322
|
+
results = list(released.values())
|
|
323
|
+
if row[0] is None:
|
|
324
|
+
messages = [*INITIAL_MESSAGES, operation.proposal.assistant]
|
|
325
|
+
for call in operation.proposal.calls:
|
|
326
|
+
messages.append({"role": "tool", "tool_call_id": call.call_id, "content": json.dumps(released[call.call_id])})
|
|
327
|
+
try:
|
|
328
|
+
answer = parse_final(step_chat(model_request(messages, "none")))
|
|
329
|
+
except Exception as error:
|
|
330
|
+
db.rollback()
|
|
331
|
+
return {"result": results, "summary_error": type(error).__name__}
|
|
332
|
+
db.execute("update operation set final=? where id=1", (answer,))
|
|
333
|
+
else:
|
|
334
|
+
answer = bounded_text(row[0], MAX_RESPONSE_BYTES)
|
|
335
|
+
db.commit()
|
|
336
|
+
return {"result": results, "assistant": answer}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def run_demo(mode: str, folder: Path, tenant: str, customer: str) -> dict[str, object]:
|
|
340
|
+
tenant, customer = identity(tenant), identity(customer)
|
|
341
|
+
if mode == "start":
|
|
342
|
+
start_operation(folder, tenant, customer)
|
|
343
|
+
elif mode != "resume":
|
|
344
|
+
raise ValueError("Use start or resume")
|
|
345
|
+
if not (folder / "orders.db").is_file():
|
|
346
|
+
raise ValueError("Saved run not found")
|
|
347
|
+
with sqlite3.connect(folder / "orders.db", timeout=120) as db:
|
|
348
|
+
operation = load_operation(db, tenant, customer)
|
|
349
|
+
released = read_releases(db, operation)
|
|
350
|
+
blocked = {}
|
|
351
|
+
for call in operation.proposal.calls:
|
|
352
|
+
if call.call_id not in released:
|
|
353
|
+
reason = approve_order(db, operation, call)
|
|
354
|
+
if reason is not None:
|
|
355
|
+
blocked[call.call_id] = reason
|
|
356
|
+
released = read_releases(db, operation)
|
|
357
|
+
blocked = {call_id: reason for call_id, reason in blocked.items() if call_id not in released}
|
|
358
|
+
if blocked:
|
|
359
|
+
return {"result": list(released.values()), "blocked": blocked}
|
|
360
|
+
return finish_operation(db, operation, released)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
if __name__ == "__main__":
|
|
364
|
+
if len(sys.argv) != 5:
|
|
365
|
+
raise SystemExit("Usage: python run.py start|resume STATE_DIRECTORY TENANT TEST_CUSTOMER_ID")
|
|
366
|
+
try:
|
|
367
|
+
print(json.dumps(run_demo(sys.argv[1], Path(sys.argv[2]), sys.argv[3], sys.argv[4])))
|
|
368
|
+
except Exception:
|
|
369
|
+
raise SystemExit("Stopped. Some orders may already have changed. Inspect the saved operation and execution permits before retrying.")
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Each tool result uses its original `tool_call_id`. The final request disables tools, and the response parser rejects further tool calls. A model summary is presentation; the `released` table is the evidence of what happened.
|
|
373
|
+
|
|
374
|
+
## Run and read the result
|
|
375
|
+
|
|
376
|
+
Run from this directory with a new state directory. `demo-store` and
|
|
377
|
+
`TEST_CUSTOMER_ID` stand for identities from your authenticated backend; the
|
|
378
|
+
CLI demonstrates that boundary but does not authenticate a caller itself.
|
|
379
|
+
|
|
380
|
+
```bash
|
|
381
|
+
.venv/bin/python run.py start ./order-run-1 demo-store TEST_CUSTOMER_ID
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Every normal response contains `result`, the list of orders already changed.
|
|
385
|
+
The first response normally has an empty list and `blocked` keyed by tool-call ID. Check the
|
|
386
|
+
customer's phone and approve or refuse each proposed order. Then launch a new
|
|
387
|
+
worker with the same directory and trusted identities:
|
|
388
|
+
|
|
389
|
+
```bash
|
|
390
|
+
.venv/bin/python run.py resume ./order-run-1 demo-store TEST_CUSTOMER_ID
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
On Windows, replace `.venv/bin/python` with `.venv\Scripts\python`.
|
|
394
|
+
|
|
395
|
+
A successful response has a `result` list with the released order IDs, amounts
|
|
396
|
+
and statuses, plus the model's `assistant` text. The model may propose one or
|
|
397
|
+
two orders; read the actual result list instead of assuming both executed.
|
|
398
|
+
Repeating `resume` returns the saved result without another order change or
|
|
399
|
+
another successful model request. Pending and refused approvals remain blocked. `resume` checks the same saved
|
|
400
|
+
decision; it never starts a fresh action merely because the SDK says the prior
|
|
401
|
+
attempt was blocked.
|
|
402
|
+
Each order is independent: approving one can release it while the other remains
|
|
403
|
+
pending. The response shows that completed order in `result` and the remaining
|
|
404
|
+
call in `blocked`.
|
|
405
|
+
|
|
406
|
+
If the final model request fails or tries to request another action, `result`
|
|
407
|
+
still shows the completed orders and `summary_error` gives the error class.
|
|
408
|
+
`resume` can retry that summary without repeating the order changes. Error
|
|
409
|
+
messages and HTTP response bodies are never included in this field.
|
|
410
|
+
|
|
411
|
+
Inspect the persisted business evidence with SQLite:
|
|
412
|
+
|
|
413
|
+
```sql
|
|
414
|
+
SELECT call_id, order_id, result FROM released;
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Run the failure checks independently of live credentials:
|
|
418
|
+
|
|
419
|
+
```bash
|
|
420
|
+
.venv/bin/python check_run.py -v
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
These checks exercise the published SDK with simulated service responses, real
|
|
424
|
+
SQLite writes and separate Python workers. They cover partial approvals,
|
|
425
|
+
refusals, malformed model proposals, changed identities and state, concurrent
|
|
426
|
+
resume, lost permit responses, expiry after authorization, receipt-time crashes,
|
|
427
|
+
a crash between permit consumption and the order commit, and a final model
|
|
428
|
+
response that tries to request another action. HTTP checks send real requests
|
|
429
|
+
to a local fixture server to verify the endpoint, credentials header, request
|
|
430
|
+
body, response-size limit and handling of malformed or failed responses.
|
|
431
|
+
|
|
432
|
+
## Understand the choices and recovery limits
|
|
433
|
+
|
|
434
|
+
| Choice | Why it matters | What to inspect |
|
|
435
|
+
| --- | --- | --- |
|
|
436
|
+
| Application-owned customer and tenant | Model arguments cannot redirect the approval | The persisted operation and recipient on the phone |
|
|
437
|
+
| Original assistant message and call IDs | Resuming returns each result to the correct request | The tool results in the final StepFun request |
|
|
438
|
+
| One-hour application deadline | A delayed approval cannot execute indefinitely | `expires_at` in the stored operation |
|
|
439
|
+
| `policy=False` and confirm questions | This lesson always requires an explicit human answer | No effect while pending or refused |
|
|
440
|
+
| Shared SDK execution permit | An affirmative answer alone is insufficient for execution | Permit consumption and execution receipt |
|
|
441
|
+
| Persisted business result | A later worker recognizes an already-completed action | One `released` row per order |
|
|
442
|
+
| Final `tool_choice="none"` | The summary cannot silently initiate another operation | Additional tool calls are rejected |
|
|
443
|
+
|
|
444
|
+
Use protected persistent storage. The state directory is created with mode
|
|
445
|
+
0700 where supported. The SDK's remote permit serializes execution of each
|
|
446
|
+
approved action. SQLite records the effect and result together. The final
|
|
447
|
+
summary holds a write transaction to avoid concurrent duplicate model requests;
|
|
448
|
+
that tradeoff is limited to this run's private database. Use this example on one
|
|
449
|
+
host; it is not a distributed execution service.
|
|
450
|
+
|
|
451
|
+
A lost permit response or crash after permit consumption but before the local
|
|
452
|
+
commit leaves the operation uncertain and blocked. Reconcile the permit and
|
|
453
|
+
the business record; do not delete state, issue a fresh approval automatically,
|
|
454
|
+
or reset a consumed permit to force a retry. A crash after the local commit can
|
|
455
|
+
resume from the stored result, although the remote execution receipt may still
|
|
456
|
+
need reconciliation. A failed final model request can be retried with `resume`
|
|
457
|
+
after checking state; it does not repeat completed order effects.
|
|
458
|
+
|
|
459
|
+
If execution stops unexpectedly, the CLI tells you that some orders may already
|
|
460
|
+
have changed. Inspect saved state and service-side execution records before
|
|
461
|
+
retrying. It never prints credentials or service bodies. A trusted
|
|
462
|
+
administrator who can edit Python or SQLite is outside this approval boundary.
|
|
463
|
+
The recipe does not intercept arbitrary tools or claim protection for a whole
|
|
464
|
+
agent runtime.
|
|
465
|
+
|
|
466
|
+
## Adapt it to your application
|
|
467
|
+
|
|
468
|
+
For a one-order demo, change `ORDERS` to `{"order-1": 1900}` and change
|
|
469
|
+
`INITIAL_MESSAGES` to request only `order-1`. The allowed order IDs and call budget
|
|
470
|
+
follow the catalog. For different amounts, update the tool schema's
|
|
471
|
+
`amount_cents` enum as well as the trusted catalog, then bump `REVISION` before
|
|
472
|
+
starting fresh operations. Never migrate pending approvals across revisions.
|
|
473
|
+
|
|
474
|
+
For a real order service, load the order and recipient from authenticated
|
|
475
|
+
application state, replace the SQLite effect with your transactional order
|
|
476
|
+
operation, and make that operation idempotent by business order ID. Pass the
|
|
477
|
+
same saved call identity on resume. Keep authorization immediately before the
|
|
478
|
+
effect, and recheck the current order version there. A remote API call and local
|
|
479
|
+
SQLite commit are not one transaction; use your service's idempotency and
|
|
480
|
+
reconciliation facilities before making external changes.
|
|
481
|
+
|
|
482
|
+
The two orders and all offline responses are synthetic fixtures authored for
|
|
483
|
+
this recipe. No customer data or measured model-performance claim is included.
|
|
484
|
+
|
|
485
|
+
References, checked 2026-10-17:
|
|
486
|
+
|
|
487
|
+
- [StepFun tool-calling cookbook](https://platform.stepfun.ai/cookbook/step-5-tool-calling)
|
|
488
|
+
- [StepFun cookbook source and contribution guide](https://github.com/stepfun-ai/Step-Cookbook)
|
|
489
|
+
- [Pushary Python SDK](https://github.com/Pushary/pushary-python)
|
|
490
|
+
|
|
491
|
+
This independently maintained example is not an official StepFun integration.
|
|
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pushary"
|
|
7
|
-
version = "2.
|
|
8
|
-
description = "
|
|
7
|
+
version = "2.4.1"
|
|
8
|
+
description = "Human-in-the-loop (HITL) for AI agents: ask a person for approvals or answers by push notification."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
11
11
|
license = { text = "MIT" }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
from typing import Callable, Dict, Optional
|
|
3
|
+
from uuid import uuid4
|
|
3
4
|
|
|
4
5
|
RequestFn = Callable[..., Dict[str, object]]
|
|
5
6
|
|
|
@@ -12,10 +13,11 @@ class RemindersResource:
|
|
|
12
13
|
self, body: str, *, in_minutes: Optional[int] = None, at: Optional[str] = None,
|
|
13
14
|
title: Optional[str] = None, agent_name: Optional[str] = None,
|
|
14
15
|
session_id: Optional[str] = None, machine_id: Optional[str] = None,
|
|
15
|
-
env: Optional[str] = None,
|
|
16
|
+
env: Optional[str] = None, request_id: Optional[str] = None,
|
|
16
17
|
) -> Dict[str, object]:
|
|
17
18
|
values = {"body": body, "inMinutes": in_minutes, "at": at, "title": title,
|
|
18
|
-
"agentName": agent_name, "sessionId": session_id, "machineId": machine_id, "env": env
|
|
19
|
+
"agentName": agent_name, "sessionId": session_id, "machineId": machine_id, "env": env,
|
|
20
|
+
"requestId": str(uuid4()) if request_id is None else request_id}
|
|
19
21
|
return self._request("POST", "/reminders", body={key: value for key, value in values.items() if value is not None})
|
|
20
22
|
|
|
21
23
|
def list(self) -> Dict[str, object]:
|
|
@@ -23,3 +25,6 @@ class RemindersResource:
|
|
|
23
25
|
|
|
24
26
|
def cancel(self, reminder_id: str) -> Dict[str, object]:
|
|
25
27
|
return self._request("POST", "/reminders", body={"cancelReminderId": reminder_id})
|
|
28
|
+
|
|
29
|
+
def get(self, reminder_id: str) -> Dict[str, object]:
|
|
30
|
+
return self._request("POST", "/reminders", body={"reminderId": reminder_id})
|
|
@@ -7,11 +7,13 @@ class ReminderTests(unittest.TestCase):
|
|
|
7
7
|
def test_schedule_list_and_cancel_use_authenticated_reminder_resource(self):
|
|
8
8
|
with mock.patch.object(PusharyServer, '_request', return_value={'pending': []}) as request:
|
|
9
9
|
client = PusharyServer('pk_test.secret')
|
|
10
|
-
client.reminders.schedule('Check deploy', in_minutes=30)
|
|
10
|
+
client.reminders.schedule('Check deploy', in_minutes=30, request_id='stable-request')
|
|
11
11
|
client.reminders.list()
|
|
12
12
|
client.reminders.cancel('id')
|
|
13
|
+
client.reminders.get('id')
|
|
13
14
|
self.assertEqual(request.call_args_list, [
|
|
14
|
-
mock.call('POST', '/reminders', body={'body': 'Check deploy', 'inMinutes': 30}),
|
|
15
|
+
mock.call('POST', '/reminders', body={'body': 'Check deploy', 'inMinutes': 30, 'requestId': 'stable-request'}),
|
|
15
16
|
mock.call('GET', '/reminders'),
|
|
16
17
|
mock.call('POST', '/reminders', body={'cancelReminderId': 'id'}),
|
|
18
|
+
mock.call('POST', '/reminders', body={'reminderId': 'id'}),
|
|
17
19
|
])
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Exercise both the executable and the article's runnable code."""
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
EXAMPLE = Path(__file__).resolve().parents[1] / "examples" / "stepfun"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StepFunExampleTests(unittest.TestCase):
|
|
14
|
+
def check_example(self, folder: Path) -> None:
|
|
15
|
+
result = subprocess.run([sys.executable, str(folder / "check_run.py")], capture_output=True, text=True, timeout=60)
|
|
16
|
+
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
17
|
+
|
|
18
|
+
def test_persisted_order_approval(self) -> None:
|
|
19
|
+
self.check_example(EXAMPLE)
|
|
20
|
+
|
|
21
|
+
def test_article_runs_from_a_fresh_directory(self) -> None:
|
|
22
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
23
|
+
folder = Path(temporary)
|
|
24
|
+
blocks = re.findall(r"```python\n(.*?)\n```", (EXAMPLE / "README.md").read_text(), re.S)
|
|
25
|
+
self.assertTrue(blocks, "The reader needs the complete implementation")
|
|
26
|
+
(folder / "run.py").write_text("\n\n".join(blocks) + "\n")
|
|
27
|
+
shutil.copy(EXAMPLE / "check_run.py", folder)
|
|
28
|
+
self.check_example(folder)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|