pushary-langgraph 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pushary_langgraph-0.1.0/.gitignore +86 -0
- pushary_langgraph-0.1.0/LICENSE +21 -0
- pushary_langgraph-0.1.0/PKG-INFO +115 -0
- pushary_langgraph-0.1.0/README.md +89 -0
- pushary_langgraph-0.1.0/pyproject.toml +52 -0
- pushary_langgraph-0.1.0/src/pushary_langgraph/__init__.py +191 -0
- pushary_langgraph-0.1.0/src/pushary_langgraph/py.typed +0 -0
- pushary_langgraph-0.1.0/tests/test_langgraph.py +149 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
2
|
+
|
|
3
|
+
# dependencies
|
|
4
|
+
node_modules/
|
|
5
|
+
.pnp
|
|
6
|
+
.pnp.js
|
|
7
|
+
|
|
8
|
+
# testing
|
|
9
|
+
/coverage
|
|
10
|
+
|
|
11
|
+
# next.js
|
|
12
|
+
.next/
|
|
13
|
+
out/
|
|
14
|
+
build/
|
|
15
|
+
dist/
|
|
16
|
+
|
|
17
|
+
# python
|
|
18
|
+
__pycache__/
|
|
19
|
+
*.pyc
|
|
20
|
+
*.egg-info/
|
|
21
|
+
|
|
22
|
+
# production
|
|
23
|
+
/build
|
|
24
|
+
|
|
25
|
+
# misc
|
|
26
|
+
.DS_Store
|
|
27
|
+
*.pem
|
|
28
|
+
|
|
29
|
+
# debug
|
|
30
|
+
npm-debug.log*
|
|
31
|
+
yarn-debug.log*
|
|
32
|
+
yarn-error.log*
|
|
33
|
+
|
|
34
|
+
# env
|
|
35
|
+
.env
|
|
36
|
+
.env.local
|
|
37
|
+
.env.development.local
|
|
38
|
+
.env.test.local
|
|
39
|
+
.env.production.local
|
|
40
|
+
|
|
41
|
+
# vercel
|
|
42
|
+
.vercel
|
|
43
|
+
|
|
44
|
+
# local Cursor MCP config (holds the Pushary API key)
|
|
45
|
+
.cursor/mcp.json
|
|
46
|
+
|
|
47
|
+
# generated at build time: Cursor plugin bundled into the agent-hooks package
|
|
48
|
+
packages/agent-hooks/data/cursor-plugin/
|
|
49
|
+
|
|
50
|
+
# typescript
|
|
51
|
+
*.tsbuild info
|
|
52
|
+
|
|
53
|
+
# turbo
|
|
54
|
+
.turbo
|
|
55
|
+
|
|
56
|
+
# Old Shopify extension directory (deprecated)
|
|
57
|
+
shopify-theme-extension/
|
|
58
|
+
*.egg-info/
|
|
59
|
+
|
|
60
|
+
# Prospect/outreach data
|
|
61
|
+
prospect_list.csv
|
|
62
|
+
prospect_research_report.txt
|
|
63
|
+
|
|
64
|
+
# Launch planning docs
|
|
65
|
+
PRODUCTHUNT_LAUNCH_PLAN.md
|
|
66
|
+
AI-CODING-REMODEL-PLAN.md
|
|
67
|
+
|
|
68
|
+
# HyperFrames launch video (separate project)
|
|
69
|
+
video-launch/
|
|
70
|
+
|
|
71
|
+
# Remotion render outputs
|
|
72
|
+
video/public/logos/out/
|
|
73
|
+
.gstack/
|
|
74
|
+
|
|
75
|
+
# local AI-agent tooling (installed skills + plugin config, machine-local)
|
|
76
|
+
.agents/
|
|
77
|
+
.claude/
|
|
78
|
+
.cursor/rules/
|
|
79
|
+
pushary-app/.agents/
|
|
80
|
+
pushary-app/skills-lock.json
|
|
81
|
+
|
|
82
|
+
# stray empty Expo stub at repo root (real config is pushary-app/app.config.ts)
|
|
83
|
+
/app.json
|
|
84
|
+
|
|
85
|
+
# Play Store listing assets (large, iterating screenshots - keep local)
|
|
86
|
+
pushary-app/play-assets/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pushary
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pushary-langgraph
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Human-in-the-loop for LangGraph and LangChain: a blocking ask_human, plus a durable interrupt()/Command resume that reaches your user on their phone.
|
|
5
|
+
Project-URL: Homepage, https://pushary.com
|
|
6
|
+
Project-URL: Documentation, https://pushary.com/docs/agents/adapters
|
|
7
|
+
Author-email: Pushary <business@pushary.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai-agents,approvals,human-in-the-loop,interrupt,langchain,langgraph
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: langgraph<2,>=1.0
|
|
24
|
+
Requires-Dist: pushary>=1.3.2
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# pushary-langgraph
|
|
28
|
+
|
|
29
|
+
Human-in-the-loop for [LangGraph](https://langchain-ai.github.io/langgraph/) and
|
|
30
|
+
LangChain. Ask a real human to approve, and get the answer on their phone. Two seams:
|
|
31
|
+
|
|
32
|
+
- **A blocking `ask_human`** you call from inside a node.
|
|
33
|
+
- **A durable `pushary_interrupt`** that parks the graph with LangGraph's native
|
|
34
|
+
`interrupt()` and resumes on a signed webhook, so a long wait holds no compute and
|
|
35
|
+
survives a restart.
|
|
36
|
+
|
|
37
|
+
Requires the Pushary [Partner plan](https://pushary.com/agent-notifications-integration).
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install pushary-langgraph
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Set `PUSHARY_API_KEY` (get it in your [dashboard](https://pushary.com/dashboard/settings)).
|
|
46
|
+
|
|
47
|
+
## Connect a phone once
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from pushary_langgraph import connect
|
|
51
|
+
|
|
52
|
+
link = connect("user_123") # show this to your end-user; one tap connects their phone
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Ask a human inside a node
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from pushary_langgraph import ask_human
|
|
59
|
+
|
|
60
|
+
def approval_node(state):
|
|
61
|
+
d = ask_human("Approve this transfer?", external_id=state["user_id"], node="approval")
|
|
62
|
+
return {"approved": d["approved"]}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`ask_human` blocks, polls durably, and fails closed. The idempotency key is derived
|
|
66
|
+
from `external_id + node + question`, so a node that re-runs on resume hits the same
|
|
67
|
+
decision instead of paging the human twice.
|
|
68
|
+
|
|
69
|
+
## Durable interrupt
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from pushary_langgraph import pushary_interrupt
|
|
73
|
+
|
|
74
|
+
def approval_node(state):
|
|
75
|
+
answer = pushary_interrupt(
|
|
76
|
+
"Approve this transfer?",
|
|
77
|
+
external_id=state["user_id"],
|
|
78
|
+
node="approval",
|
|
79
|
+
callback_url=os.environ["PUSHARY_CALLBACK_URL"], # omit to block instead of park
|
|
80
|
+
)
|
|
81
|
+
return {"approved": answer == "yes"}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
With a `callback_url`, the node opens the decision and calls LangGraph's `interrupt()`
|
|
85
|
+
to park the graph (a checkpointer is required). Keep any code before the call
|
|
86
|
+
idempotent, the whole node re-runs on resume.
|
|
87
|
+
|
|
88
|
+
### Resume from the webhook
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from pushary_langgraph import resolve_pushary_callback, SIGNATURE_HEADER
|
|
92
|
+
from langgraph.types import Command
|
|
93
|
+
|
|
94
|
+
# POST /pushary/callback
|
|
95
|
+
def callback(request):
|
|
96
|
+
raw = request.body
|
|
97
|
+
cb = resolve_pushary_callback(raw, request.headers.get(SIGNATURE_HEADER), os.environ["PUSHARY_WEBHOOK_SECRET"])
|
|
98
|
+
if not cb:
|
|
99
|
+
return ("bad signature", 401)
|
|
100
|
+
thread_id = lookup_thread(cb["correlationId"]) # your own correlationId -> thread_id map
|
|
101
|
+
graph.invoke(Command(resume=cb["answer"]), {"configurable": {"thread_id": thread_id}})
|
|
102
|
+
return ("ok", 200)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## API
|
|
106
|
+
|
|
107
|
+
- `connect(external_id, *, api_key=None, base_url=None)` — enroll an end-user's phone, returns the link.
|
|
108
|
+
- `ask_human(question, *, external_id, type="confirm", options=None, node=..., ...)` — blocking, returns the decision dict.
|
|
109
|
+
- `pushary_interrupt(question, *, external_id, node=..., callback_url=None, ...)` — blocking, or durable when `callback_url` is set.
|
|
110
|
+
- `resolve_pushary_callback(raw_body, signature, secret)` — verify + parse a callback into `{correlationId, answer, approved, ...}`.
|
|
111
|
+
- `describe_answer(type, result)`, `is_affirmative(answer)`, `deterministic_key(parts)`, `SIGNATURE_HEADER`.
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# pushary-langgraph
|
|
2
|
+
|
|
3
|
+
Human-in-the-loop for [LangGraph](https://langchain-ai.github.io/langgraph/) and
|
|
4
|
+
LangChain. Ask a real human to approve, and get the answer on their phone. Two seams:
|
|
5
|
+
|
|
6
|
+
- **A blocking `ask_human`** you call from inside a node.
|
|
7
|
+
- **A durable `pushary_interrupt`** that parks the graph with LangGraph's native
|
|
8
|
+
`interrupt()` and resumes on a signed webhook, so a long wait holds no compute and
|
|
9
|
+
survives a restart.
|
|
10
|
+
|
|
11
|
+
Requires the Pushary [Partner plan](https://pushary.com/agent-notifications-integration).
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install pushary-langgraph
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Set `PUSHARY_API_KEY` (get it in your [dashboard](https://pushary.com/dashboard/settings)).
|
|
20
|
+
|
|
21
|
+
## Connect a phone once
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from pushary_langgraph import connect
|
|
25
|
+
|
|
26
|
+
link = connect("user_123") # show this to your end-user; one tap connects their phone
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Ask a human inside a node
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from pushary_langgraph import ask_human
|
|
33
|
+
|
|
34
|
+
def approval_node(state):
|
|
35
|
+
d = ask_human("Approve this transfer?", external_id=state["user_id"], node="approval")
|
|
36
|
+
return {"approved": d["approved"]}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`ask_human` blocks, polls durably, and fails closed. The idempotency key is derived
|
|
40
|
+
from `external_id + node + question`, so a node that re-runs on resume hits the same
|
|
41
|
+
decision instead of paging the human twice.
|
|
42
|
+
|
|
43
|
+
## Durable interrupt
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from pushary_langgraph import pushary_interrupt
|
|
47
|
+
|
|
48
|
+
def approval_node(state):
|
|
49
|
+
answer = pushary_interrupt(
|
|
50
|
+
"Approve this transfer?",
|
|
51
|
+
external_id=state["user_id"],
|
|
52
|
+
node="approval",
|
|
53
|
+
callback_url=os.environ["PUSHARY_CALLBACK_URL"], # omit to block instead of park
|
|
54
|
+
)
|
|
55
|
+
return {"approved": answer == "yes"}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
With a `callback_url`, the node opens the decision and calls LangGraph's `interrupt()`
|
|
59
|
+
to park the graph (a checkpointer is required). Keep any code before the call
|
|
60
|
+
idempotent, the whole node re-runs on resume.
|
|
61
|
+
|
|
62
|
+
### Resume from the webhook
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from pushary_langgraph import resolve_pushary_callback, SIGNATURE_HEADER
|
|
66
|
+
from langgraph.types import Command
|
|
67
|
+
|
|
68
|
+
# POST /pushary/callback
|
|
69
|
+
def callback(request):
|
|
70
|
+
raw = request.body
|
|
71
|
+
cb = resolve_pushary_callback(raw, request.headers.get(SIGNATURE_HEADER), os.environ["PUSHARY_WEBHOOK_SECRET"])
|
|
72
|
+
if not cb:
|
|
73
|
+
return ("bad signature", 401)
|
|
74
|
+
thread_id = lookup_thread(cb["correlationId"]) # your own correlationId -> thread_id map
|
|
75
|
+
graph.invoke(Command(resume=cb["answer"]), {"configurable": {"thread_id": thread_id}})
|
|
76
|
+
return ("ok", 200)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## API
|
|
80
|
+
|
|
81
|
+
- `connect(external_id, *, api_key=None, base_url=None)` — enroll an end-user's phone, returns the link.
|
|
82
|
+
- `ask_human(question, *, external_id, type="confirm", options=None, node=..., ...)` — blocking, returns the decision dict.
|
|
83
|
+
- `pushary_interrupt(question, *, external_id, node=..., callback_url=None, ...)` — blocking, or durable when `callback_url` is set.
|
|
84
|
+
- `resolve_pushary_callback(raw_body, signature, secret)` — verify + parse a callback into `{correlationId, answer, approved, ...}`.
|
|
85
|
+
- `describe_answer(type, result)`, `is_affirmative(answer)`, `deterministic_key(parts)`, `SIGNATURE_HEADER`.
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pushary-langgraph"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Human-in-the-loop for LangGraph and LangChain: a blocking ask_human, plus a durable interrupt()/Command resume that reaches your user on their phone."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Pushary", email = "business@pushary.com" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"langgraph",
|
|
15
|
+
"langchain",
|
|
16
|
+
"human-in-the-loop",
|
|
17
|
+
"ai-agents",
|
|
18
|
+
"approvals",
|
|
19
|
+
"interrupt",
|
|
20
|
+
]
|
|
21
|
+
classifiers = [
|
|
22
|
+
"Development Status :: 4 - Beta",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3.10",
|
|
28
|
+
"Programming Language :: Python :: 3.11",
|
|
29
|
+
"Programming Language :: Python :: 3.12",
|
|
30
|
+
"Programming Language :: Python :: 3.13",
|
|
31
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
32
|
+
"Typing :: Typed",
|
|
33
|
+
]
|
|
34
|
+
dependencies = [
|
|
35
|
+
"pushary>=1.3.2",
|
|
36
|
+
"langgraph>=1.0,<2",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://pushary.com"
|
|
41
|
+
Documentation = "https://pushary.com/docs/agents/adapters"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/pushary_langgraph"]
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.sdist]
|
|
47
|
+
include = [
|
|
48
|
+
"src/pushary_langgraph",
|
|
49
|
+
"README.md",
|
|
50
|
+
"LICENSE",
|
|
51
|
+
"tests",
|
|
52
|
+
]
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Human-in-the-loop for LangGraph and LangChain, powered by Pushary.
|
|
2
|
+
|
|
3
|
+
Two seams over the durable two-call contract (``enroll`` + ``decisions.ask``):
|
|
4
|
+
|
|
5
|
+
- ``ask_human`` / ``pushary_interrupt`` without a callback: a blocking approval you
|
|
6
|
+
call from inside a node. It polls durably and fails closed.
|
|
7
|
+
- ``pushary_interrupt`` with a ``callback_url``: parks the graph with LangGraph's
|
|
8
|
+
native ``interrupt()`` and resumes on Pushary's signed webhook, so an hour-long
|
|
9
|
+
wait holds no compute and survives a restart.
|
|
10
|
+
|
|
11
|
+
Zero framework import at module load: LangGraph is imported lazily, only on the
|
|
12
|
+
durable path, so the blocking helpers work (and test) without it installed.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
from pushary import (
|
|
21
|
+
PusharyServer,
|
|
22
|
+
SIGNATURE_HEADER,
|
|
23
|
+
deterministic_key,
|
|
24
|
+
is_approved,
|
|
25
|
+
parse_decision_callback,
|
|
26
|
+
verify_webhook_signature,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"connect",
|
|
33
|
+
"ask_human",
|
|
34
|
+
"pushary_interrupt",
|
|
35
|
+
"describe_answer",
|
|
36
|
+
"resolve_pushary_callback",
|
|
37
|
+
"is_affirmative",
|
|
38
|
+
"deterministic_key",
|
|
39
|
+
"SIGNATURE_HEADER",
|
|
40
|
+
"__version__",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _client(api_key: Optional[str] = None, base_url: Optional[str] = None) -> PusharyServer:
|
|
45
|
+
key = api_key or os.environ.get("PUSHARY_API_KEY")
|
|
46
|
+
if not key:
|
|
47
|
+
raise ValueError("Pushary: set PUSHARY_API_KEY or pass api_key=... to the LangGraph helpers.")
|
|
48
|
+
return PusharyServer(api_key=key, base_url=base_url)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _idempotency_key(external_id: str, node: str, question: str) -> str:
|
|
52
|
+
return deterministic_key([external_id, node, question])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_affirmative(answer: Optional[str]) -> bool:
|
|
56
|
+
"""Fail-closed yes/no check for a confirm answer."""
|
|
57
|
+
return is_approved("answered", "confirm", answer)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def connect(external_id: str, *, api_key: Optional[str] = None, base_url: Optional[str] = None) -> str:
|
|
61
|
+
"""Connect one end-user's phone (keyless). Returns a single-use link to show them."""
|
|
62
|
+
return _client(api_key, base_url).enroll(external_id)["universalLink"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def ask_human(
|
|
66
|
+
question: str,
|
|
67
|
+
*,
|
|
68
|
+
external_id: str,
|
|
69
|
+
type: str = "confirm",
|
|
70
|
+
options: Optional[List[str]] = None,
|
|
71
|
+
node: str = "ask-human",
|
|
72
|
+
context: Optional[str] = None,
|
|
73
|
+
agent_name: Optional[str] = None,
|
|
74
|
+
timeout_seconds: Optional[float] = None,
|
|
75
|
+
api_key: Optional[str] = None,
|
|
76
|
+
base_url: Optional[str] = None,
|
|
77
|
+
) -> Dict[str, Any]:
|
|
78
|
+
"""Blocking ask (Pattern A): create a decision and poll durably until answered.
|
|
79
|
+
|
|
80
|
+
Returns the decision dict (``decisionId``, ``status``, ``answered``, ``value``,
|
|
81
|
+
``type``, fail-closed ``approved``). The idempotency key is derived from
|
|
82
|
+
external_id + node + question, so a node that re-runs on resume hits the same
|
|
83
|
+
decision instead of paging the human twice.
|
|
84
|
+
"""
|
|
85
|
+
return _client(api_key, base_url).decisions.ask(
|
|
86
|
+
question,
|
|
87
|
+
type=type,
|
|
88
|
+
options=options,
|
|
89
|
+
external_id=external_id,
|
|
90
|
+
context=context,
|
|
91
|
+
agent_name=agent_name,
|
|
92
|
+
timeout_seconds=timeout_seconds,
|
|
93
|
+
idempotency_key=_idempotency_key(external_id, node, question),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def describe_answer(type: str, result: Dict[str, Any]) -> str:
|
|
98
|
+
"""Turn a decision outcome into an unambiguous instruction for the model."""
|
|
99
|
+
if not result.get("answered"):
|
|
100
|
+
return (
|
|
101
|
+
f"No answer (status: {result.get('status')}). "
|
|
102
|
+
"Treat this as NOT approved and do not proceed."
|
|
103
|
+
)
|
|
104
|
+
if type == "confirm":
|
|
105
|
+
return (
|
|
106
|
+
"The human approved. You may proceed."
|
|
107
|
+
if result.get("approved")
|
|
108
|
+
else "The human declined. Do not proceed."
|
|
109
|
+
)
|
|
110
|
+
return f"The human answered: {result.get('value') or ''}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def resolve_pushary_callback(
|
|
114
|
+
raw_body: Any, signature: Optional[str], secret: str
|
|
115
|
+
) -> Optional[Dict[str, Any]]:
|
|
116
|
+
"""Verify a callback signature and parse it, or return None.
|
|
117
|
+
|
|
118
|
+
Feed ``answer`` into ``graph.invoke(Command(resume=answer), config)``.
|
|
119
|
+
"""
|
|
120
|
+
if not verify_webhook_signature(raw_body, signature, secret):
|
|
121
|
+
return None
|
|
122
|
+
cb = parse_decision_callback(raw_body)
|
|
123
|
+
if not cb:
|
|
124
|
+
return None
|
|
125
|
+
return {
|
|
126
|
+
"correlationId": cb.get("correlationId"),
|
|
127
|
+
"answer": cb.get("answer"),
|
|
128
|
+
"value": cb.get("value"),
|
|
129
|
+
"approved": is_affirmative(cb.get("answer")),
|
|
130
|
+
"context": cb.get("context"),
|
|
131
|
+
"answeredAt": cb.get("answeredAt"),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def pushary_interrupt(
|
|
136
|
+
question: str,
|
|
137
|
+
*,
|
|
138
|
+
external_id: str,
|
|
139
|
+
node: str = "hitl",
|
|
140
|
+
type: str = "confirm",
|
|
141
|
+
options: Optional[List[str]] = None,
|
|
142
|
+
callback_url: Optional[str] = None,
|
|
143
|
+
context: Optional[str] = None,
|
|
144
|
+
agent_name: Optional[str] = None,
|
|
145
|
+
timeout_seconds: Optional[float] = None,
|
|
146
|
+
api_key: Optional[str] = None,
|
|
147
|
+
base_url: Optional[str] = None,
|
|
148
|
+
) -> Optional[str]:
|
|
149
|
+
"""Ask a human from inside a LangGraph node.
|
|
150
|
+
|
|
151
|
+
- ``callback_url`` omitted (Pattern A): blocks, polls durably, returns the answer
|
|
152
|
+
(or None if fail-closed). Zero extra infra, holds the run open for the wait.
|
|
153
|
+
- ``callback_url`` set (Pattern B): opens the decision, then calls LangGraph's
|
|
154
|
+
``interrupt()`` to park the graph in your checkpointer. Resume with
|
|
155
|
+
``Command(resume=answer)`` from the signed webhook. Holds no idle compute.
|
|
156
|
+
|
|
157
|
+
The whole node re-runs on resume, so keep code before this call idempotent. The
|
|
158
|
+
decision's idempotency key is derived from external_id + node + question, so the
|
|
159
|
+
re-run lands on the same decision.
|
|
160
|
+
"""
|
|
161
|
+
idem = _idempotency_key(external_id, node, question)
|
|
162
|
+
px = _client(api_key, base_url)
|
|
163
|
+
|
|
164
|
+
if not callback_url:
|
|
165
|
+
d = px.decisions.ask(
|
|
166
|
+
question,
|
|
167
|
+
type=type,
|
|
168
|
+
options=options,
|
|
169
|
+
external_id=external_id,
|
|
170
|
+
context=context,
|
|
171
|
+
agent_name=agent_name,
|
|
172
|
+
timeout_seconds=timeout_seconds,
|
|
173
|
+
idempotency_key=idem,
|
|
174
|
+
)
|
|
175
|
+
return d.get("value") if d.get("answered") else None
|
|
176
|
+
|
|
177
|
+
px.decisions.create(
|
|
178
|
+
question,
|
|
179
|
+
type=type,
|
|
180
|
+
options=options,
|
|
181
|
+
external_id=external_id,
|
|
182
|
+
context=context,
|
|
183
|
+
agent_name=agent_name,
|
|
184
|
+
callback_url=callback_url,
|
|
185
|
+
idempotency_key=idem,
|
|
186
|
+
wait=False,
|
|
187
|
+
)
|
|
188
|
+
# Lazy import: only the durable path needs LangGraph installed.
|
|
189
|
+
from langgraph.types import interrupt
|
|
190
|
+
|
|
191
|
+
return interrupt({"pushary": "decision", "question": question, "external_id": external_id})
|
|
File without changes
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Tests for pushary_langgraph. Framework-free: LangGraph is never imported here,
|
|
2
|
+
so the blocking helpers are exercised without it installed. HTTP is mocked by
|
|
3
|
+
swapping the module's ``_client`` for a stub.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import json
|
|
9
|
+
import unittest
|
|
10
|
+
|
|
11
|
+
import pushary_langgraph as plg
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeDecisions:
|
|
15
|
+
def __init__(self, ask_result=None, create_result=None):
|
|
16
|
+
self.ask_calls = []
|
|
17
|
+
self.create_calls = []
|
|
18
|
+
self._ask_result = ask_result or {}
|
|
19
|
+
self._create_result = create_result or {}
|
|
20
|
+
|
|
21
|
+
def ask(self, question, **kwargs):
|
|
22
|
+
self.ask_calls.append({"question": question, **kwargs})
|
|
23
|
+
return self._ask_result
|
|
24
|
+
|
|
25
|
+
def create(self, question, **kwargs):
|
|
26
|
+
self.create_calls.append({"question": question, **kwargs})
|
|
27
|
+
return self._create_result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FakeClient:
|
|
31
|
+
def __init__(self, decisions=None, enroll_result=None):
|
|
32
|
+
self.decisions = decisions or FakeDecisions()
|
|
33
|
+
self._enroll_result = enroll_result or {}
|
|
34
|
+
self.enroll_calls = []
|
|
35
|
+
|
|
36
|
+
def enroll(self, external_id):
|
|
37
|
+
self.enroll_calls.append(external_id)
|
|
38
|
+
return self._enroll_result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class WithFakeClient:
|
|
42
|
+
"""Patch plg._client to return a supplied FakeClient for the duration of a test."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, client):
|
|
45
|
+
self.client = client
|
|
46
|
+
self._orig = None
|
|
47
|
+
|
|
48
|
+
def __enter__(self):
|
|
49
|
+
self._orig = plg._client
|
|
50
|
+
plg._client = lambda *a, **k: self.client
|
|
51
|
+
return self.client
|
|
52
|
+
|
|
53
|
+
def __exit__(self, *exc):
|
|
54
|
+
plg._client = self._orig
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
SECRET = "whsec_test"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def sign(body: str) -> str:
|
|
61
|
+
return hmac.new(SECRET.encode(), body.encode(), hashlib.sha256).hexdigest()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ConnectTests(unittest.TestCase):
|
|
65
|
+
def test_connect_returns_universal_link(self):
|
|
66
|
+
client = FakeClient(enroll_result={"universalLink": "https://pushary.com/e/tok"})
|
|
67
|
+
with WithFakeClient(client):
|
|
68
|
+
link = plg.connect("user_1")
|
|
69
|
+
self.assertEqual(link, "https://pushary.com/e/tok")
|
|
70
|
+
self.assertEqual(client.enroll_calls, ["user_1"])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AskHumanTests(unittest.TestCase):
|
|
74
|
+
def test_ask_human_forwards_deterministic_idempotency_key(self):
|
|
75
|
+
decisions = FakeDecisions(ask_result={"status": "answered", "answered": True, "value": "yes", "approved": True})
|
|
76
|
+
with WithFakeClient(FakeClient(decisions=decisions)):
|
|
77
|
+
out = plg.ask_human("Approve?", external_id="user_1", node="approval")
|
|
78
|
+
self.assertTrue(out["approved"])
|
|
79
|
+
call = decisions.ask_calls[0]
|
|
80
|
+
self.assertEqual(call["external_id"], "user_1")
|
|
81
|
+
self.assertTrue(call["idempotency_key"])
|
|
82
|
+
# same input -> same key (re-run safe)
|
|
83
|
+
expected = plg.deterministic_key(["user_1", "approval", "Approve?"])
|
|
84
|
+
self.assertEqual(call["idempotency_key"], expected)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class PusharyInterruptTests(unittest.TestCase):
|
|
88
|
+
def test_blocking_pattern_returns_value_when_answered(self):
|
|
89
|
+
decisions = FakeDecisions(ask_result={"answered": True, "value": "yes"})
|
|
90
|
+
with WithFakeClient(FakeClient(decisions=decisions)):
|
|
91
|
+
answer = plg.pushary_interrupt("Approve?", external_id="user_1", node="n")
|
|
92
|
+
self.assertEqual(answer, "yes")
|
|
93
|
+
|
|
94
|
+
def test_blocking_pattern_fails_closed_when_unanswered(self):
|
|
95
|
+
decisions = FakeDecisions(ask_result={"answered": False, "status": "expired"})
|
|
96
|
+
with WithFakeClient(FakeClient(decisions=decisions)):
|
|
97
|
+
answer = plg.pushary_interrupt("Approve?", external_id="user_1", node="n")
|
|
98
|
+
self.assertIsNone(answer)
|
|
99
|
+
|
|
100
|
+
def test_durable_pattern_opens_decision_with_callback(self):
|
|
101
|
+
# callback_url set -> Pattern B calls create() then imports langgraph.interrupt.
|
|
102
|
+
# langgraph is not installed in this test env, so we assert the create call happened
|
|
103
|
+
# and that the lazy import is what raises (not our code path).
|
|
104
|
+
decisions = FakeDecisions(create_result={"decisionId": "d1", "status": "pending"})
|
|
105
|
+
with WithFakeClient(FakeClient(decisions=decisions)):
|
|
106
|
+
with self.assertRaises(ImportError):
|
|
107
|
+
plg.pushary_interrupt(
|
|
108
|
+
"Approve?", external_id="user_1", node="n", callback_url="https://x/cb"
|
|
109
|
+
)
|
|
110
|
+
create = decisions.create_calls[0]
|
|
111
|
+
self.assertEqual(create["callback_url"], "https://x/cb")
|
|
112
|
+
self.assertEqual(create["wait"], False)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class DescribeAnswerTests(unittest.TestCase):
|
|
116
|
+
def test_formats_every_outcome(self):
|
|
117
|
+
self.assertIn("approved", plg.describe_answer("confirm", {"answered": True, "approved": True}))
|
|
118
|
+
self.assertIn("declined", plg.describe_answer("confirm", {"answered": True, "approved": False}))
|
|
119
|
+
self.assertIn("NOT approved", plg.describe_answer("confirm", {"answered": False, "status": "expired"}))
|
|
120
|
+
self.assertIn("B", plg.describe_answer("select", {"answered": True, "value": "B"}))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ResolveCallbackTests(unittest.TestCase):
|
|
124
|
+
def test_verifies_parses_and_folds_approved(self):
|
|
125
|
+
body = json.dumps({"correlationId": "d1", "answer": "yes", "answeredAt": "", "context": "t-1"})
|
|
126
|
+
out = plg.resolve_pushary_callback(body, sign(body), SECRET)
|
|
127
|
+
self.assertEqual(out["correlationId"], "d1")
|
|
128
|
+
self.assertTrue(out["approved"])
|
|
129
|
+
self.assertEqual(out["context"], "t-1")
|
|
130
|
+
|
|
131
|
+
def test_rejects_bad_signature(self):
|
|
132
|
+
body = json.dumps({"correlationId": "d1", "answer": "yes", "answeredAt": ""})
|
|
133
|
+
self.assertIsNone(plg.resolve_pushary_callback(body, "nope", SECRET))
|
|
134
|
+
|
|
135
|
+
def test_decline_is_not_approved(self):
|
|
136
|
+
body = json.dumps({"correlationId": "d2", "answer": "no", "answeredAt": ""})
|
|
137
|
+
out = plg.resolve_pushary_callback(body, sign(body), SECRET)
|
|
138
|
+
self.assertFalse(out["approved"])
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class IsAffirmativeTests(unittest.TestCase):
|
|
142
|
+
def test_fail_closed(self):
|
|
143
|
+
self.assertTrue(plg.is_affirmative("yes"))
|
|
144
|
+
self.assertFalse(plg.is_affirmative("no"))
|
|
145
|
+
self.assertFalse(plg.is_affirmative(None))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
unittest.main()
|