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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.