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.
Files changed (28) hide show
  1. {pushary-2.3.0 → pushary-2.4.1}/PKG-INFO +12 -6
  2. {pushary-2.3.0 → pushary-2.4.1}/README.md +10 -4
  3. pushary-2.4.1/examples/stepfun/README.md +491 -0
  4. {pushary-2.3.0 → pushary-2.4.1}/pyproject.toml +2 -2
  5. {pushary-2.3.0 → pushary-2.4.1}/src/pushary/__init__.py +1 -1
  6. {pushary-2.3.0 → pushary-2.4.1}/src/pushary/reminders.py +7 -2
  7. {pushary-2.3.0 → pushary-2.4.1}/tests/test_reminders.py +4 -2
  8. pushary-2.4.1/tests/test_stepfun_example.py +32 -0
  9. {pushary-2.3.0 → pushary-2.4.1}/.gitignore +0 -0
  10. {pushary-2.3.0 → pushary-2.4.1}/LICENSE +0 -0
  11. {pushary-2.3.0 → pushary-2.4.1}/examples/agno/README.md +0 -0
  12. {pushary-2.3.0 → pushary-2.4.1}/examples/dify/README.md +0 -0
  13. {pushary-2.3.0 → pushary-2.4.1}/examples/llamaindex/README.md +0 -0
  14. {pushary-2.3.0 → pushary-2.4.1}/examples/microsoft/README.md +0 -0
  15. {pushary-2.3.0 → pushary-2.4.1}/examples/strands/README.md +0 -0
  16. {pushary-2.3.0 → pushary-2.4.1}/src/pushary/_types.py +0 -0
  17. {pushary-2.3.0 → pushary-2.4.1}/src/pushary/_util.py +0 -0
  18. {pushary-2.3.0 → pushary-2.4.1}/src/pushary/adapters.py +0 -0
  19. {pushary-2.3.0 → pushary-2.4.1}/src/pushary/client.py +0 -0
  20. {pushary-2.3.0 → pushary-2.4.1}/src/pushary/decisions.py +0 -0
  21. {pushary-2.3.0 → pushary-2.4.1}/src/pushary/errors.py +0 -0
  22. {pushary-2.3.0 → pushary-2.4.1}/src/pushary/keys.py +0 -0
  23. {pushary-2.3.0 → pushary-2.4.1}/src/pushary/py.typed +0 -0
  24. {pushary-2.3.0 → pushary-2.4.1}/src/pushary/webhook.py +0 -0
  25. {pushary-2.3.0 → pushary-2.4.1}/tests/test_adapters.py +0 -0
  26. {pushary-2.3.0 → pushary-2.4.1}/tests/test_agent.py +0 -0
  27. {pushary-2.3.0 → pushary-2.4.1}/tests/test_client.py +0 -0
  28. {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.3.0
4
- Summary: The decision layer for AI agents. Your agent asks, your user decides on their phone.
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
- The decision layer for AI agents. Your agent asks, your user decides on their phone.
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/docs/agents/api-key).
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
- The decision layer for AI agents. Your agent asks, your user decides on their phone.
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/docs/agents/api-key).
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.3.0"
8
- description = "The decision layer for AI agents. Your agent asks, your user decides on their phone."
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" }
@@ -23,7 +23,7 @@ from .reminders import RemindersResource
23
23
  from .errors import PusharyError
24
24
  from .webhook import SIGNATURE_HEADER, parse_decision_callback, verify_webhook_signature
25
25
 
26
- __version__ = "2.3.0"
26
+ __version__ = "2.4.1"
27
27
 
28
28
  __all__ = [
29
29
  "PusharyServer",
@@ -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