noxy-langgraph 1.0.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.
- noxy_langgraph-1.0.0/LICENSE +21 -0
- noxy_langgraph-1.0.0/PKG-INFO +243 -0
- noxy_langgraph-1.0.0/README.md +206 -0
- noxy_langgraph-1.0.0/noxy_langgraph/__init__.py +32 -0
- noxy_langgraph-1.0.0/noxy_langgraph/_version.py +3 -0
- noxy_langgraph-1.0.0/noxy_langgraph/actionable.py +27 -0
- noxy_langgraph-1.0.0/noxy_langgraph/bridge.py +47 -0
- noxy_langgraph-1.0.0/noxy_langgraph/errors.py +22 -0
- noxy_langgraph-1.0.0/noxy_langgraph/hitl.py +83 -0
- noxy_langgraph-1.0.0/noxy_langgraph/py.typed +0 -0
- noxy_langgraph-1.0.0/noxy_langgraph/registry.py +39 -0
- noxy_langgraph-1.0.0/noxy_langgraph/resume.py +74 -0
- noxy_langgraph-1.0.0/noxy_langgraph/types.py +135 -0
- noxy_langgraph-1.0.0/noxy_langgraph.egg-info/PKG-INFO +243 -0
- noxy_langgraph-1.0.0/noxy_langgraph.egg-info/SOURCES.txt +22 -0
- noxy_langgraph-1.0.0/noxy_langgraph.egg-info/dependency_links.txt +1 -0
- noxy_langgraph-1.0.0/noxy_langgraph.egg-info/requires.txt +13 -0
- noxy_langgraph-1.0.0/noxy_langgraph.egg-info/top_level.txt +1 -0
- noxy_langgraph-1.0.0/pyproject.toml +69 -0
- noxy_langgraph-1.0.0/setup.cfg +4 -0
- noxy_langgraph-1.0.0/tests/test_actionable.py +23 -0
- noxy_langgraph-1.0.0/tests/test_integration.py +198 -0
- noxy_langgraph-1.0.0/tests/test_registry.py +28 -0
- noxy_langgraph-1.0.0/tests/test_types.py +111 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Noxy Network (noxy.network)
|
|
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,243 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: noxy-langgraph
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: LangGraph connector for Noxy human-in-the-loop decisions via interrupt/resume and webhooks.
|
|
5
|
+
Author: Noxy Network
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://noxy.network
|
|
8
|
+
Project-URL: Documentation, https://docs.noxy.network
|
|
9
|
+
Project-URL: Repository, https://github.com/noxy-network/langgraph-connector
|
|
10
|
+
Project-URL: Issues, https://github.com/noxy-network/langgraph-connector/issues
|
|
11
|
+
Keywords: noxy,noxy-network,langgraph,human-in-the-loop,ai-agents,interrupt,webhook
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: noxy-sdk>=2.1.0
|
|
26
|
+
Requires-Dist: langgraph>=0.2.0
|
|
27
|
+
Requires-Dist: langgraph-checkpoint>=2.0.0
|
|
28
|
+
Provides-Extra: examples
|
|
29
|
+
Requires-Dist: fastapi>=0.110.0; extra == "examples"
|
|
30
|
+
Requires-Dist: uvicorn>=0.27.0; extra == "examples"
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: build>=1.0.0; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
34
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
35
|
+
Requires-Dist: twine>=5.0.0; extra == "dev"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# Noxy LangGraph Connector
|
|
39
|
+
|
|
40
|
+
[](https://pypi.org/project/noxy-langgraph/)
|
|
41
|
+
[](https://pypi.org/project/noxy-langgraph/)
|
|
42
|
+
[](https://opensource.org/licenses/MIT)
|
|
43
|
+
|
|
44
|
+
LangGraph connector for [Noxy](https://noxy.network) **human-in-the-loop** guardrails. Pauses agent graphs with `interrupt()`, delivers encrypted approval prompts to user devices, and resumes execution when Noxy fires a webhook.
|
|
45
|
+
|
|
46
|
+
## Flow
|
|
47
|
+
|
|
48
|
+
```mermaid
|
|
49
|
+
sequenceDiagram
|
|
50
|
+
participant G as LangGraph
|
|
51
|
+
participant N as Noxy Relay
|
|
52
|
+
participant P as User Phone
|
|
53
|
+
participant S as Your Server
|
|
54
|
+
|
|
55
|
+
G->>N: send_decision (push payload)
|
|
56
|
+
N->>P: Push notification
|
|
57
|
+
G->>G: interrupt() — state saved to checkpointer
|
|
58
|
+
Note over G: Graph suspended
|
|
59
|
+
|
|
60
|
+
alt User responds
|
|
61
|
+
P->>N: Approve / Reject
|
|
62
|
+
N->>S: Webhook (outcome)
|
|
63
|
+
S->>G: Command(resume=decision)
|
|
64
|
+
G->>G: Continue with decision in state
|
|
65
|
+
else Timeout
|
|
66
|
+
N->>S: Webhook (timeout / expired)
|
|
67
|
+
S->>G: Command(resume=timeout)
|
|
68
|
+
G->>G: Continue with default behaviour
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
1. Graph reaches the HITL node.
|
|
73
|
+
2. Noxy routes an encrypted actionable to the user's device (push).
|
|
74
|
+
3. The node calls `interrupt()` — LangGraph suspends and persists state via a checkpointer.
|
|
75
|
+
4. User responds on mobile **or** the decision TTL expires.
|
|
76
|
+
5. Noxy fires a webhook to your server.
|
|
77
|
+
6. Your server calls `NoxyGraphResumeHandler.resume_from_webhook()` → `Command(resume=...)`.
|
|
78
|
+
7. The graph continues with the human decision (or timeout default) in state.
|
|
79
|
+
|
|
80
|
+
## Requirements
|
|
81
|
+
|
|
82
|
+
- Python **>= 3.10**
|
|
83
|
+
- A LangGraph graph compiled **with a checkpointer** (required for `interrupt()`)
|
|
84
|
+
- [noxy-sdk](https://pypi.org/project/noxy-sdk/) credentials (`NOXY_APP_TOKEN`, target identity)
|
|
85
|
+
|
|
86
|
+
Target identity can be a **phone number**, **email**, **user id**, or **wallet address** (`0x…`).
|
|
87
|
+
|
|
88
|
+
## Installation
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pip install noxy-langgraph
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
For the FastAPI webhook example:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
pip install "noxy-langgraph[examples]"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Local development against the monorepo SDK:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
pip install -e ../../sdks/python-sdk
|
|
104
|
+
pip install -e ".[dev,examples]"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Quick start
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
import uuid
|
|
111
|
+
from typing import Optional, TypedDict
|
|
112
|
+
|
|
113
|
+
from langgraph.checkpoint.memory import InMemorySaver
|
|
114
|
+
from langgraph.constants import END, START
|
|
115
|
+
from langgraph.graph import StateGraph
|
|
116
|
+
from noxy import NoxyConfig, init_noxy_agent_client
|
|
117
|
+
|
|
118
|
+
from noxy_langgraph import NoxyLangGraphBridge, build_tool_call_actionable
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class State(TypedDict, total=False):
|
|
122
|
+
task: str
|
|
123
|
+
noxy_decision: Optional[dict]
|
|
124
|
+
_noxy_sent_decision_id: Optional[str]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def build_actionable(state: State) -> dict:
|
|
128
|
+
return build_tool_call_actionable(
|
|
129
|
+
tool="run_task",
|
|
130
|
+
args={"task": state["task"]},
|
|
131
|
+
title="Approve task?",
|
|
132
|
+
summary=state["task"],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
client = init_noxy_agent_client(
|
|
137
|
+
NoxyConfig(
|
|
138
|
+
endpoint="https://relay.noxy.network",
|
|
139
|
+
auth_token="your-app-token",
|
|
140
|
+
decision_ttl_seconds=3600,
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
# Phone, email, user id, or wallet address
|
|
144
|
+
bridge = NoxyLangGraphBridge(client, "user@example.com")
|
|
145
|
+
|
|
146
|
+
builder = StateGraph(State)
|
|
147
|
+
builder.add_node("noxy_hitl", bridge.create_hitl_node(build_actionable))
|
|
148
|
+
builder.add_edge(START, "noxy_hitl")
|
|
149
|
+
builder.add_edge("noxy_hitl", END)
|
|
150
|
+
|
|
151
|
+
graph = builder.compile(checkpointer=InMemorySaver())
|
|
152
|
+
resume_handler = bridge.create_resume_handler(graph)
|
|
153
|
+
|
|
154
|
+
config = {"configurable": {"thread_id": str(uuid.uuid4())}}
|
|
155
|
+
paused = graph.invoke({"task": "Send 1 wei"}, config)
|
|
156
|
+
# paused["__interrupt__"] contains the pending decision_id
|
|
157
|
+
|
|
158
|
+
# Later, in your webhook handler:
|
|
159
|
+
final = resume_handler.resume_from_webhook({
|
|
160
|
+
"decisionId": "<decisionId>",
|
|
161
|
+
"identityId": "user@example.com",
|
|
162
|
+
"outcome": "approved", # or "rejected", "expired"
|
|
163
|
+
})
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Graph state
|
|
167
|
+
|
|
168
|
+
Include optional `_noxy_sent_decision_id` in your state schema. The resume handler sets it via `Command(update=...)` so the HITL node does not re-send the push when LangGraph re-executes the node after resume (LangGraph always re-runs the node body from the top).
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from noxy_langgraph import NOXY_SENT_DECISION_ID_KEY
|
|
172
|
+
|
|
173
|
+
class State(TypedDict, total=False):
|
|
174
|
+
...
|
|
175
|
+
_noxy_sent_decision_id: Optional[str] # or use NOXY_SENT_DECISION_ID_KEY
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Webhook payload
|
|
179
|
+
|
|
180
|
+
Noxy delivers JSON to your registered webhook URL:
|
|
181
|
+
|
|
182
|
+
| Field | Type | Description |
|
|
183
|
+
|-------|------|-------------|
|
|
184
|
+
| `decisionId` | `str` | Decision to resume (snake_case `decision_id` also accepted) |
|
|
185
|
+
| `identityId` | `str` | Identity that took the decision (phone, email, user id, or wallet) |
|
|
186
|
+
| `outcome` | `str` | `approved`, `rejected`, `expired`, or `timeout` |
|
|
187
|
+
| `receivedAt` | `str` | Optional ISO timestamp |
|
|
188
|
+
|
|
189
|
+
On **timeout/expired**, pass an `on_timeout` callback to `create_hitl_node` to apply default behaviour:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
def on_timeout(state, resume):
|
|
193
|
+
return {"noxy_decision": resume.to_state(), "approved": False}
|
|
194
|
+
|
|
195
|
+
bridge.create_hitl_node(build_actionable, on_timeout=on_timeout)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## API
|
|
199
|
+
|
|
200
|
+
| Symbol | Description |
|
|
201
|
+
|--------|-------------|
|
|
202
|
+
| `NoxyLangGraphBridge` | Wires client, registry, HITL node factory, and resume handler |
|
|
203
|
+
| `create_noxy_hitl_node(...)` | Low-level HITL node factory |
|
|
204
|
+
| `NoxyGraphResumeHandler` | Resume paused graphs from webhook payloads |
|
|
205
|
+
| `PendingInterruptRegistry` | Maps `decision_id` → `thread_id` for resume |
|
|
206
|
+
| `build_tool_call_actionable(...)` | Standard `propose_tool_call` payload builder |
|
|
207
|
+
| `parse_webhook_payload(...)` | Parse raw webhook JSON |
|
|
208
|
+
|
|
209
|
+
## Examples
|
|
210
|
+
|
|
211
|
+
- `examples/basic.py` — end-to-end demo with a mock Noxy client
|
|
212
|
+
- `examples/webhook_server.py` — FastAPI server with `/runs` and `/webhooks/noxy`
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
python examples/basic.py
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Configure the webhook server:
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
export NOXY_APP_TOKEN="your-app-token"
|
|
222
|
+
export NOXY_IDENTITY_ID="user@example.com" # or phone, user id, 0x…
|
|
223
|
+
uvicorn examples.webhook_server:app --reload
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Development
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
make dev # editable install with dev + examples extras
|
|
230
|
+
make test # run pytest
|
|
231
|
+
make build # build sdist + wheel
|
|
232
|
+
make publish-check # build and validate with twine
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Publishing
|
|
236
|
+
|
|
237
|
+
1. Bump version in `pyproject.toml` and `noxy_langgraph/_version.py`.
|
|
238
|
+
2. Update `CHANGELOG.md`.
|
|
239
|
+
3. Merge to `main` — the GitHub Actions publish workflow uploads to PyPI when `PYPI_API_TOKEN` is configured.
|
|
240
|
+
|
|
241
|
+
## License
|
|
242
|
+
|
|
243
|
+
MIT
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# Noxy LangGraph Connector
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/noxy-langgraph/)
|
|
4
|
+
[](https://pypi.org/project/noxy-langgraph/)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
LangGraph connector for [Noxy](https://noxy.network) **human-in-the-loop** guardrails. Pauses agent graphs with `interrupt()`, delivers encrypted approval prompts to user devices, and resumes execution when Noxy fires a webhook.
|
|
8
|
+
|
|
9
|
+
## Flow
|
|
10
|
+
|
|
11
|
+
```mermaid
|
|
12
|
+
sequenceDiagram
|
|
13
|
+
participant G as LangGraph
|
|
14
|
+
participant N as Noxy Relay
|
|
15
|
+
participant P as User Phone
|
|
16
|
+
participant S as Your Server
|
|
17
|
+
|
|
18
|
+
G->>N: send_decision (push payload)
|
|
19
|
+
N->>P: Push notification
|
|
20
|
+
G->>G: interrupt() — state saved to checkpointer
|
|
21
|
+
Note over G: Graph suspended
|
|
22
|
+
|
|
23
|
+
alt User responds
|
|
24
|
+
P->>N: Approve / Reject
|
|
25
|
+
N->>S: Webhook (outcome)
|
|
26
|
+
S->>G: Command(resume=decision)
|
|
27
|
+
G->>G: Continue with decision in state
|
|
28
|
+
else Timeout
|
|
29
|
+
N->>S: Webhook (timeout / expired)
|
|
30
|
+
S->>G: Command(resume=timeout)
|
|
31
|
+
G->>G: Continue with default behaviour
|
|
32
|
+
end
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
1. Graph reaches the HITL node.
|
|
36
|
+
2. Noxy routes an encrypted actionable to the user's device (push).
|
|
37
|
+
3. The node calls `interrupt()` — LangGraph suspends and persists state via a checkpointer.
|
|
38
|
+
4. User responds on mobile **or** the decision TTL expires.
|
|
39
|
+
5. Noxy fires a webhook to your server.
|
|
40
|
+
6. Your server calls `NoxyGraphResumeHandler.resume_from_webhook()` → `Command(resume=...)`.
|
|
41
|
+
7. The graph continues with the human decision (or timeout default) in state.
|
|
42
|
+
|
|
43
|
+
## Requirements
|
|
44
|
+
|
|
45
|
+
- Python **>= 3.10**
|
|
46
|
+
- A LangGraph graph compiled **with a checkpointer** (required for `interrupt()`)
|
|
47
|
+
- [noxy-sdk](https://pypi.org/project/noxy-sdk/) credentials (`NOXY_APP_TOKEN`, target identity)
|
|
48
|
+
|
|
49
|
+
Target identity can be a **phone number**, **email**, **user id**, or **wallet address** (`0x…`).
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install noxy-langgraph
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
For the FastAPI webhook example:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install "noxy-langgraph[examples]"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Local development against the monorepo SDK:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install -e ../../sdks/python-sdk
|
|
67
|
+
pip install -e ".[dev,examples]"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Quick start
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import uuid
|
|
74
|
+
from typing import Optional, TypedDict
|
|
75
|
+
|
|
76
|
+
from langgraph.checkpoint.memory import InMemorySaver
|
|
77
|
+
from langgraph.constants import END, START
|
|
78
|
+
from langgraph.graph import StateGraph
|
|
79
|
+
from noxy import NoxyConfig, init_noxy_agent_client
|
|
80
|
+
|
|
81
|
+
from noxy_langgraph import NoxyLangGraphBridge, build_tool_call_actionable
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class State(TypedDict, total=False):
|
|
85
|
+
task: str
|
|
86
|
+
noxy_decision: Optional[dict]
|
|
87
|
+
_noxy_sent_decision_id: Optional[str]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build_actionable(state: State) -> dict:
|
|
91
|
+
return build_tool_call_actionable(
|
|
92
|
+
tool="run_task",
|
|
93
|
+
args={"task": state["task"]},
|
|
94
|
+
title="Approve task?",
|
|
95
|
+
summary=state["task"],
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
client = init_noxy_agent_client(
|
|
100
|
+
NoxyConfig(
|
|
101
|
+
endpoint="https://relay.noxy.network",
|
|
102
|
+
auth_token="your-app-token",
|
|
103
|
+
decision_ttl_seconds=3600,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
# Phone, email, user id, or wallet address
|
|
107
|
+
bridge = NoxyLangGraphBridge(client, "user@example.com")
|
|
108
|
+
|
|
109
|
+
builder = StateGraph(State)
|
|
110
|
+
builder.add_node("noxy_hitl", bridge.create_hitl_node(build_actionable))
|
|
111
|
+
builder.add_edge(START, "noxy_hitl")
|
|
112
|
+
builder.add_edge("noxy_hitl", END)
|
|
113
|
+
|
|
114
|
+
graph = builder.compile(checkpointer=InMemorySaver())
|
|
115
|
+
resume_handler = bridge.create_resume_handler(graph)
|
|
116
|
+
|
|
117
|
+
config = {"configurable": {"thread_id": str(uuid.uuid4())}}
|
|
118
|
+
paused = graph.invoke({"task": "Send 1 wei"}, config)
|
|
119
|
+
# paused["__interrupt__"] contains the pending decision_id
|
|
120
|
+
|
|
121
|
+
# Later, in your webhook handler:
|
|
122
|
+
final = resume_handler.resume_from_webhook({
|
|
123
|
+
"decisionId": "<decisionId>",
|
|
124
|
+
"identityId": "user@example.com",
|
|
125
|
+
"outcome": "approved", # or "rejected", "expired"
|
|
126
|
+
})
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Graph state
|
|
130
|
+
|
|
131
|
+
Include optional `_noxy_sent_decision_id` in your state schema. The resume handler sets it via `Command(update=...)` so the HITL node does not re-send the push when LangGraph re-executes the node after resume (LangGraph always re-runs the node body from the top).
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from noxy_langgraph import NOXY_SENT_DECISION_ID_KEY
|
|
135
|
+
|
|
136
|
+
class State(TypedDict, total=False):
|
|
137
|
+
...
|
|
138
|
+
_noxy_sent_decision_id: Optional[str] # or use NOXY_SENT_DECISION_ID_KEY
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Webhook payload
|
|
142
|
+
|
|
143
|
+
Noxy delivers JSON to your registered webhook URL:
|
|
144
|
+
|
|
145
|
+
| Field | Type | Description |
|
|
146
|
+
|-------|------|-------------|
|
|
147
|
+
| `decisionId` | `str` | Decision to resume (snake_case `decision_id` also accepted) |
|
|
148
|
+
| `identityId` | `str` | Identity that took the decision (phone, email, user id, or wallet) |
|
|
149
|
+
| `outcome` | `str` | `approved`, `rejected`, `expired`, or `timeout` |
|
|
150
|
+
| `receivedAt` | `str` | Optional ISO timestamp |
|
|
151
|
+
|
|
152
|
+
On **timeout/expired**, pass an `on_timeout` callback to `create_hitl_node` to apply default behaviour:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
def on_timeout(state, resume):
|
|
156
|
+
return {"noxy_decision": resume.to_state(), "approved": False}
|
|
157
|
+
|
|
158
|
+
bridge.create_hitl_node(build_actionable, on_timeout=on_timeout)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## API
|
|
162
|
+
|
|
163
|
+
| Symbol | Description |
|
|
164
|
+
|--------|-------------|
|
|
165
|
+
| `NoxyLangGraphBridge` | Wires client, registry, HITL node factory, and resume handler |
|
|
166
|
+
| `create_noxy_hitl_node(...)` | Low-level HITL node factory |
|
|
167
|
+
| `NoxyGraphResumeHandler` | Resume paused graphs from webhook payloads |
|
|
168
|
+
| `PendingInterruptRegistry` | Maps `decision_id` → `thread_id` for resume |
|
|
169
|
+
| `build_tool_call_actionable(...)` | Standard `propose_tool_call` payload builder |
|
|
170
|
+
| `parse_webhook_payload(...)` | Parse raw webhook JSON |
|
|
171
|
+
|
|
172
|
+
## Examples
|
|
173
|
+
|
|
174
|
+
- `examples/basic.py` — end-to-end demo with a mock Noxy client
|
|
175
|
+
- `examples/webhook_server.py` — FastAPI server with `/runs` and `/webhooks/noxy`
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
python examples/basic.py
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Configure the webhook server:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
export NOXY_APP_TOKEN="your-app-token"
|
|
185
|
+
export NOXY_IDENTITY_ID="user@example.com" # or phone, user id, 0x…
|
|
186
|
+
uvicorn examples.webhook_server:app --reload
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
make dev # editable install with dev + examples extras
|
|
193
|
+
make test # run pytest
|
|
194
|
+
make build # build sdist + wheel
|
|
195
|
+
make publish-check # build and validate with twine
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Publishing
|
|
199
|
+
|
|
200
|
+
1. Bump version in `pyproject.toml` and `noxy_langgraph/_version.py`.
|
|
201
|
+
2. Update `CHANGELOG.md`.
|
|
202
|
+
3. Merge to `main` — the GitHub Actions publish workflow uploads to PyPI when `PYPI_API_TOKEN` is configured.
|
|
203
|
+
|
|
204
|
+
## License
|
|
205
|
+
|
|
206
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Noxy LangGraph connector — webhook-driven human-in-the-loop via interrupt/resume."""
|
|
2
|
+
|
|
3
|
+
from noxy_langgraph._version import __version__
|
|
4
|
+
from noxy_langgraph.actionable import build_tool_call_actionable
|
|
5
|
+
from noxy_langgraph.bridge import NoxyLangGraphBridge
|
|
6
|
+
from noxy_langgraph.errors import (
|
|
7
|
+
NoxyLangGraphError,
|
|
8
|
+
SendDecisionFailedError,
|
|
9
|
+
UnknownDecisionError,
|
|
10
|
+
)
|
|
11
|
+
from noxy_langgraph.hitl import create_noxy_hitl_node
|
|
12
|
+
from noxy_langgraph.registry import PendingInterrupt, PendingInterruptRegistry
|
|
13
|
+
from noxy_langgraph.resume import NoxyGraphResumeHandler, parse_webhook_payload
|
|
14
|
+
from noxy_langgraph.types import NoxyDecisionOutcome, NoxyDecisionResume, NoxyWebhookEvent, NOXY_SENT_DECISION_ID_KEY
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"__version__",
|
|
18
|
+
"NoxyLangGraphBridge",
|
|
19
|
+
"NoxyLangGraphError",
|
|
20
|
+
"NoxyGraphResumeHandler",
|
|
21
|
+
"NoxyDecisionOutcome",
|
|
22
|
+
"NoxyDecisionResume",
|
|
23
|
+
"NoxyWebhookEvent",
|
|
24
|
+
"NOXY_SENT_DECISION_ID_KEY",
|
|
25
|
+
"PendingInterrupt",
|
|
26
|
+
"PendingInterruptRegistry",
|
|
27
|
+
"SendDecisionFailedError",
|
|
28
|
+
"UnknownDecisionError",
|
|
29
|
+
"build_tool_call_actionable",
|
|
30
|
+
"create_noxy_hitl_node",
|
|
31
|
+
"parse_webhook_payload",
|
|
32
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Build Noxy actionable payloads from LangGraph state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_tool_call_actionable(
|
|
9
|
+
*,
|
|
10
|
+
tool: str,
|
|
11
|
+
args: dict[str, Any],
|
|
12
|
+
title: str,
|
|
13
|
+
summary: str,
|
|
14
|
+
kind: str = "propose_tool_call",
|
|
15
|
+
extra: dict[str, Any] | None = None,
|
|
16
|
+
) -> dict[str, Any]:
|
|
17
|
+
"""Build a standard ``propose_tool_call`` actionable for ``send_decision``."""
|
|
18
|
+
payload: dict[str, Any] = {
|
|
19
|
+
"kind": kind,
|
|
20
|
+
"tool": tool,
|
|
21
|
+
"args": args,
|
|
22
|
+
"title": title,
|
|
23
|
+
"summary": summary,
|
|
24
|
+
}
|
|
25
|
+
if extra:
|
|
26
|
+
payload.update(extra)
|
|
27
|
+
return payload
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Convenience wrapper wiring Noxy client, registry, HITL node, and resume handler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from langgraph.graph.state import CompiledStateGraph
|
|
8
|
+
from noxy.client import NoxyAgentClient
|
|
9
|
+
|
|
10
|
+
from noxy_langgraph.hitl import create_noxy_hitl_node
|
|
11
|
+
from noxy_langgraph.registry import PendingInterruptRegistry
|
|
12
|
+
from noxy_langgraph.resume import NoxyGraphResumeHandler
|
|
13
|
+
from noxy_langgraph.types import NoxyDecisionResume
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NoxyLangGraphBridge:
|
|
17
|
+
"""Shared registry and helpers for a Noxy-backed LangGraph application."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
client: NoxyAgentClient,
|
|
22
|
+
identity_id: str,
|
|
23
|
+
*,
|
|
24
|
+
registry: PendingInterruptRegistry | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self.client = client
|
|
27
|
+
self.identity_id = identity_id
|
|
28
|
+
self.registry = registry or PendingInterruptRegistry()
|
|
29
|
+
|
|
30
|
+
def create_hitl_node(
|
|
31
|
+
self,
|
|
32
|
+
build_actionable: Callable[[Any], dict[str, Any]],
|
|
33
|
+
*,
|
|
34
|
+
state_key: str = "noxy_decision",
|
|
35
|
+
on_timeout: Callable[[Any, NoxyDecisionResume], dict[str, Any]] | None = None,
|
|
36
|
+
):
|
|
37
|
+
return create_noxy_hitl_node(
|
|
38
|
+
self.client,
|
|
39
|
+
self.identity_id,
|
|
40
|
+
self.registry,
|
|
41
|
+
build_actionable,
|
|
42
|
+
state_key=state_key,
|
|
43
|
+
on_timeout=on_timeout,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def create_resume_handler(self, graph: CompiledStateGraph) -> NoxyGraphResumeHandler:
|
|
47
|
+
return NoxyGraphResumeHandler(graph, self.registry)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Connector-specific errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NoxyLangGraphError(Exception):
|
|
7
|
+
"""Base error for the Noxy LangGraph connector."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UnknownDecisionError(NoxyLangGraphError):
|
|
11
|
+
"""Raised when a webhook references a decision that is not registered."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, decision_id: str) -> None:
|
|
14
|
+
super().__init__(f"no pending LangGraph interrupt for decision_id={decision_id!r}")
|
|
15
|
+
self.decision_id = decision_id
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SendDecisionFailedError(NoxyLangGraphError):
|
|
19
|
+
"""Raised when Noxy does not return a decision_id after routing."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str = "send_decision returned no decision_id") -> None:
|
|
22
|
+
super().__init__(message)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""LangGraph HITL node that routes to Noxy then suspends via interrupt()."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from langgraph.config import get_config
|
|
8
|
+
from langgraph.types import interrupt
|
|
9
|
+
from noxy.client import NoxyAgentClient
|
|
10
|
+
from noxy.types import NoxyDeliveryOutcome
|
|
11
|
+
|
|
12
|
+
from noxy_langgraph.errors import SendDecisionFailedError
|
|
13
|
+
from noxy_langgraph.registry import PendingInterrupt, PendingInterruptRegistry
|
|
14
|
+
from noxy_langgraph.types import NOXY_SENT_DECISION_ID_KEY, NoxyDecisionResume
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _first_decision_id(deliveries: list[NoxyDeliveryOutcome]) -> str:
|
|
18
|
+
for delivery in deliveries:
|
|
19
|
+
if delivery.decision_id:
|
|
20
|
+
return delivery.decision_id
|
|
21
|
+
raise SendDecisionFailedError()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_noxy_hitl_node(
|
|
25
|
+
client: NoxyAgentClient,
|
|
26
|
+
identity_id: str,
|
|
27
|
+
registry: PendingInterruptRegistry,
|
|
28
|
+
build_actionable: Callable[[Any], dict[str, Any]],
|
|
29
|
+
*,
|
|
30
|
+
state_key: str = "noxy_decision",
|
|
31
|
+
on_timeout: Callable[[Any, NoxyDecisionResume], dict[str, Any]] | None = None,
|
|
32
|
+
) -> Callable[[Any], dict[str, Any]]:
|
|
33
|
+
"""Create a LangGraph node that pushes to Noxy, then suspends until webhook resume.
|
|
34
|
+
|
|
35
|
+
Flow:
|
|
36
|
+
1. Build actionable payload from graph state.
|
|
37
|
+
2. ``send_decision`` — Noxy delivers push notification to the user's device.
|
|
38
|
+
3. Register ``decision_id -> thread_id`` for webhook correlation.
|
|
39
|
+
4. ``interrupt()`` — graph suspends; checkpointer persists state.
|
|
40
|
+
5. On webhook resume, node re-executes and returns the human decision in state.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def noxy_hitl_node(state: Any) -> dict[str, Any]:
|
|
44
|
+
config = get_config()
|
|
45
|
+
thread_id = config["configurable"]["thread_id"]
|
|
46
|
+
|
|
47
|
+
actionable = build_actionable(state)
|
|
48
|
+
existing_decision_id = (
|
|
49
|
+
state.get(NOXY_SENT_DECISION_ID_KEY) if isinstance(state, dict) else None
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if existing_decision_id:
|
|
53
|
+
decision_id = str(existing_decision_id)
|
|
54
|
+
else:
|
|
55
|
+
deliveries = client.send_decision(identity_id, actionable)
|
|
56
|
+
decision_id = _first_decision_id(deliveries)
|
|
57
|
+
registry.register(
|
|
58
|
+
PendingInterrupt(
|
|
59
|
+
decision_id=decision_id,
|
|
60
|
+
thread_id=str(thread_id),
|
|
61
|
+
identity_id=identity_id,
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
resume_raw = interrupt(
|
|
66
|
+
{
|
|
67
|
+
"kind": "noxy_awaiting_decision",
|
|
68
|
+
"decision_id": decision_id,
|
|
69
|
+
"identity_id": identity_id,
|
|
70
|
+
"actionable": actionable,
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
resume = NoxyDecisionResume.from_resume_value(resume_raw)
|
|
75
|
+
if resume.is_timeout and on_timeout is not None:
|
|
76
|
+
return on_timeout(state, resume)
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
state_key: resume.to_state(),
|
|
80
|
+
NOXY_SENT_DECISION_ID_KEY: decision_id,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return noxy_hitl_node
|
|
File without changes
|