pushary-langgraph 0.1.0__py3-none-any.whl
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.
|
@@ -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,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,6 @@
|
|
|
1
|
+
pushary_langgraph/__init__.py,sha256=5c391ElHkVmHruEwiry3_iiGT0Hg2Pzoo7Sep1y_WPo,6474
|
|
2
|
+
pushary_langgraph/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
pushary_langgraph-0.1.0.dist-info/METADATA,sha256=6ONicjz8gXSANE9624xqH0hgAvgWLZdsBjvJ2brmQio,4282
|
|
4
|
+
pushary_langgraph-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
5
|
+
pushary_langgraph-0.1.0.dist-info/licenses/LICENSE,sha256=dbAOXev8njZg4qMsS4LS9tZq8cVgKYPZ1iTeOux9UBE,1064
|
|
6
|
+
pushary_langgraph-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|