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.
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/noxy-langgraph.svg)](https://pypi.org/project/noxy-langgraph/)
41
+ [![Python versions](https://img.shields.io/pypi/pyversions/noxy-langgraph.svg)](https://pypi.org/project/noxy-langgraph/)
42
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
+ [![PyPI version](https://img.shields.io/pypi/v/noxy-langgraph.svg)](https://pypi.org/project/noxy-langgraph/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/noxy-langgraph.svg)](https://pypi.org/project/noxy-langgraph/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,3 @@
1
+ """Package version."""
2
+
3
+ __version__ = "1.0.0"
@@ -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