noxy-langgraph 1.0.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.
- noxy_langgraph/__init__.py +32 -0
- noxy_langgraph/_version.py +3 -0
- noxy_langgraph/actionable.py +27 -0
- noxy_langgraph/bridge.py +47 -0
- noxy_langgraph/errors.py +22 -0
- noxy_langgraph/hitl.py +83 -0
- noxy_langgraph/py.typed +0 -0
- noxy_langgraph/registry.py +39 -0
- noxy_langgraph/resume.py +74 -0
- noxy_langgraph/types.py +135 -0
- noxy_langgraph-1.0.0.dist-info/METADATA +243 -0
- noxy_langgraph-1.0.0.dist-info/RECORD +15 -0
- noxy_langgraph-1.0.0.dist-info/WHEEL +5 -0
- noxy_langgraph-1.0.0.dist-info/licenses/LICENSE +21 -0
- noxy_langgraph-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
noxy_langgraph/bridge.py
ADDED
|
@@ -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)
|
noxy_langgraph/errors.py
ADDED
|
@@ -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)
|
noxy_langgraph/hitl.py
ADDED
|
@@ -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
|
noxy_langgraph/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Maps Noxy decision_ids to LangGraph thread_ids for webhook resume."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class PendingInterrupt:
|
|
11
|
+
"""Correlation record between a routed Noxy decision and a paused graph."""
|
|
12
|
+
|
|
13
|
+
decision_id: str
|
|
14
|
+
thread_id: str
|
|
15
|
+
identity_id: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PendingInterruptRegistry:
|
|
19
|
+
"""Thread-safe in-memory registry for webhook resume correlation."""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._entries: dict[str, PendingInterrupt] = {}
|
|
23
|
+
self._lock = threading.Lock()
|
|
24
|
+
|
|
25
|
+
def register(self, pending: PendingInterrupt) -> None:
|
|
26
|
+
with self._lock:
|
|
27
|
+
self._entries[pending.decision_id] = pending
|
|
28
|
+
|
|
29
|
+
def lookup(self, decision_id: str) -> PendingInterrupt | None:
|
|
30
|
+
with self._lock:
|
|
31
|
+
return self._entries.get(decision_id)
|
|
32
|
+
|
|
33
|
+
def pop(self, decision_id: str) -> PendingInterrupt | None:
|
|
34
|
+
with self._lock:
|
|
35
|
+
return self._entries.pop(decision_id, None)
|
|
36
|
+
|
|
37
|
+
def __len__(self) -> int:
|
|
38
|
+
with self._lock:
|
|
39
|
+
return len(self._entries)
|
noxy_langgraph/resume.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Resume paused LangGraph runs from Noxy webhook callbacks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from langgraph.graph.state import CompiledStateGraph
|
|
8
|
+
from langgraph.types import Command
|
|
9
|
+
|
|
10
|
+
from noxy_langgraph.errors import UnknownDecisionError
|
|
11
|
+
from noxy_langgraph.registry import PendingInterruptRegistry
|
|
12
|
+
from noxy_langgraph.types import NOXY_SENT_DECISION_ID_KEY, NoxyDecisionResume, NoxyWebhookEvent
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_webhook_payload(payload: dict[str, Any]) -> NoxyWebhookEvent:
|
|
16
|
+
"""Parse a Noxy webhook JSON body into a typed event."""
|
|
17
|
+
return NoxyWebhookEvent.from_payload(payload)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NoxyGraphResumeHandler:
|
|
21
|
+
"""Resume graph threads when Noxy webhooks report a settled decision."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
graph: CompiledStateGraph,
|
|
26
|
+
registry: PendingInterruptRegistry,
|
|
27
|
+
) -> None:
|
|
28
|
+
self._graph = graph
|
|
29
|
+
self._registry = registry
|
|
30
|
+
|
|
31
|
+
def resume_from_webhook(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
32
|
+
"""Parse webhook payload, resume the paused graph, and return final state values."""
|
|
33
|
+
event = parse_webhook_payload(payload)
|
|
34
|
+
return self.resume_from_event(event)
|
|
35
|
+
|
|
36
|
+
def resume_from_event(self, event: NoxyWebhookEvent) -> dict[str, Any]:
|
|
37
|
+
pending = self._registry.pop(event.decision_id)
|
|
38
|
+
if pending is None:
|
|
39
|
+
raise UnknownDecisionError(event.decision_id)
|
|
40
|
+
|
|
41
|
+
if pending.identity_id != event.identity_id:
|
|
42
|
+
raise UnknownDecisionError(event.decision_id)
|
|
43
|
+
|
|
44
|
+
resume = NoxyDecisionResume.from_webhook(event)
|
|
45
|
+
config = {"configurable": {"thread_id": pending.thread_id}}
|
|
46
|
+
return self._graph.invoke(
|
|
47
|
+
Command(
|
|
48
|
+
resume=resume.to_state(),
|
|
49
|
+
update={NOXY_SENT_DECISION_ID_KEY: event.decision_id},
|
|
50
|
+
),
|
|
51
|
+
config,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
async def resume_from_webhook_async(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
55
|
+
event = parse_webhook_payload(payload)
|
|
56
|
+
return await self.resume_from_event_async(event)
|
|
57
|
+
|
|
58
|
+
async def resume_from_event_async(self, event: NoxyWebhookEvent) -> dict[str, Any]:
|
|
59
|
+
pending = self._registry.pop(event.decision_id)
|
|
60
|
+
if pending is None:
|
|
61
|
+
raise UnknownDecisionError(event.decision_id)
|
|
62
|
+
|
|
63
|
+
if pending.identity_id != event.identity_id:
|
|
64
|
+
raise UnknownDecisionError(event.decision_id)
|
|
65
|
+
|
|
66
|
+
resume = NoxyDecisionResume.from_webhook(event)
|
|
67
|
+
config = {"configurable": {"thread_id": pending.thread_id}}
|
|
68
|
+
return await self._graph.ainvoke(
|
|
69
|
+
Command(
|
|
70
|
+
resume=resume.to_state(),
|
|
71
|
+
update={NOXY_SENT_DECISION_ID_KEY: event.decision_id},
|
|
72
|
+
),
|
|
73
|
+
config,
|
|
74
|
+
)
|
noxy_langgraph/types.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Shared types for the Noxy LangGraph connector."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
# State key written by the webhook resume handler so the HITL node skips re-sending.
|
|
10
|
+
NOXY_SENT_DECISION_ID_KEY = "_noxy_sent_decision_id"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NoxyDecisionOutcome(str, Enum):
|
|
14
|
+
"""Human decision surfaced to LangGraph after a Noxy webhook."""
|
|
15
|
+
|
|
16
|
+
APPROVED = "approved"
|
|
17
|
+
REJECTED = "rejected"
|
|
18
|
+
EXPIRED = "expired"
|
|
19
|
+
TIMEOUT = "timeout"
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_raw(cls, raw: str) -> "NoxyDecisionOutcome":
|
|
23
|
+
normalized = raw.strip().lower()
|
|
24
|
+
aliases = {
|
|
25
|
+
"approve": cls.APPROVED,
|
|
26
|
+
"approved": cls.APPROVED,
|
|
27
|
+
"reject": cls.REJECTED,
|
|
28
|
+
"rejected": cls.REJECTED,
|
|
29
|
+
"expire": cls.EXPIRED,
|
|
30
|
+
"expired": cls.EXPIRED,
|
|
31
|
+
"timeout": cls.TIMEOUT,
|
|
32
|
+
"timed_out": cls.TIMEOUT,
|
|
33
|
+
}
|
|
34
|
+
try:
|
|
35
|
+
return aliases[normalized]
|
|
36
|
+
except KeyError as exc:
|
|
37
|
+
raise ValueError(f"unsupported Noxy decision outcome: {raw!r}") from exc
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def is_terminal(self) -> bool:
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_timeout(self) -> bool:
|
|
45
|
+
return self in (NoxyDecisionOutcome.EXPIRED, NoxyDecisionOutcome.TIMEOUT)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def approved(self) -> bool:
|
|
49
|
+
return self is NoxyDecisionOutcome.APPROVED
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class NoxyWebhookEvent:
|
|
54
|
+
"""Parsed Noxy webhook payload for a settled decision."""
|
|
55
|
+
|
|
56
|
+
decision_id: str
|
|
57
|
+
identity_id: str
|
|
58
|
+
outcome: NoxyDecisionOutcome
|
|
59
|
+
received_at: str | None = None
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_payload(cls, payload: dict[str, Any]) -> "NoxyWebhookEvent":
|
|
63
|
+
decision_id = payload.get("decision_id") or payload.get("decisionId")
|
|
64
|
+
identity_id = payload.get("identity_id") or payload.get("identityId")
|
|
65
|
+
outcome_raw = payload.get("outcome")
|
|
66
|
+
received_at = payload.get("received_at") or payload.get("receivedAt")
|
|
67
|
+
|
|
68
|
+
if not decision_id:
|
|
69
|
+
raise ValueError("webhook payload missing decision_id")
|
|
70
|
+
if not identity_id:
|
|
71
|
+
raise ValueError("webhook payload missing identity_id")
|
|
72
|
+
if not outcome_raw:
|
|
73
|
+
raise ValueError("webhook payload missing outcome")
|
|
74
|
+
|
|
75
|
+
return cls(
|
|
76
|
+
decision_id=str(decision_id),
|
|
77
|
+
identity_id=str(identity_id),
|
|
78
|
+
outcome=NoxyDecisionOutcome.from_raw(str(outcome_raw)),
|
|
79
|
+
received_at=str(received_at) if received_at is not None else None,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class NoxyDecisionResume:
|
|
85
|
+
"""Value passed to ``Command(resume=...)`` when a Noxy webhook settles."""
|
|
86
|
+
|
|
87
|
+
decision_id: str
|
|
88
|
+
identity_id: str
|
|
89
|
+
outcome: NoxyDecisionOutcome
|
|
90
|
+
received_at: str | None = None
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_webhook(cls, event: NoxyWebhookEvent) -> "NoxyDecisionResume":
|
|
94
|
+
return cls(
|
|
95
|
+
decision_id=event.decision_id,
|
|
96
|
+
identity_id=event.identity_id,
|
|
97
|
+
outcome=event.outcome,
|
|
98
|
+
received_at=event.received_at,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def from_resume_value(cls, raw: Any) -> "NoxyDecisionResume":
|
|
103
|
+
if isinstance(raw, cls):
|
|
104
|
+
return raw
|
|
105
|
+
if not isinstance(raw, dict):
|
|
106
|
+
raise TypeError(f"expected dict resume value, got {type(raw).__name__}")
|
|
107
|
+
|
|
108
|
+
decision_id = raw.get("decision_id") or raw.get("decisionId")
|
|
109
|
+
identity_id = raw.get("identity_id") or raw.get("identityId")
|
|
110
|
+
outcome_raw = raw.get("outcome")
|
|
111
|
+
received_at = raw.get("received_at") or raw.get("receivedAt")
|
|
112
|
+
|
|
113
|
+
if not decision_id:
|
|
114
|
+
raise ValueError("resume value missing decision_id")
|
|
115
|
+
if not identity_id:
|
|
116
|
+
raise ValueError("resume value missing identity_id")
|
|
117
|
+
if not outcome_raw:
|
|
118
|
+
raise ValueError("resume value missing outcome")
|
|
119
|
+
|
|
120
|
+
return cls(
|
|
121
|
+
decision_id=str(decision_id),
|
|
122
|
+
identity_id=str(identity_id),
|
|
123
|
+
outcome=NoxyDecisionOutcome.from_raw(str(outcome_raw)),
|
|
124
|
+
received_at=str(received_at) if received_at is not None else None,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def to_state(self) -> dict[str, Any]:
|
|
128
|
+
return asdict(self) | {
|
|
129
|
+
"outcome": self.outcome.value,
|
|
130
|
+
"approved": self.outcome.approved,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def is_timeout(self) -> bool:
|
|
135
|
+
return self.outcome.is_timeout
|
|
@@ -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,15 @@
|
|
|
1
|
+
noxy_langgraph/__init__.py,sha256=XKjdDOW21gSwMcisMZPFrK6da0M5Cz3dJKs-byvzzx4,1140
|
|
2
|
+
noxy_langgraph/_version.py,sha256=LJjobBBdBbZ4ybQ45DPhM6moLGGbwqOxUg76FS7Ewmc,46
|
|
3
|
+
noxy_langgraph/actionable.py,sha256=bZPjmnsZSbFiQFDwHnlNRZ3KwjRcjiWQA3yQfvjyGoY,636
|
|
4
|
+
noxy_langgraph/bridge.py,sha256=wbdm6aJknDaiFdXVrKW6D1peJjXHsPpXi7bo3KSS7cs,1517
|
|
5
|
+
noxy_langgraph/errors.py,sha256=J6OIFXye2Q9XQX-ByGPLIvmmdlMDC7OpoX7JArR0b3k,721
|
|
6
|
+
noxy_langgraph/hitl.py,sha256=lKkwNNio8OfCCVYhWo_R3HMVJO_9R_qKtC4XAA9QgkY,2902
|
|
7
|
+
noxy_langgraph/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
noxy_langgraph/registry.py,sha256=cxBpMAJf0TVZoAVpTgUPSaeYeJNR-j6YT6n2gy1Zhwc,1112
|
|
9
|
+
noxy_langgraph/resume.py,sha256=0AyUV1PqIK6wWY_r_rfpHLPg1NJyTzH0XzdiF7IxgEo,2729
|
|
10
|
+
noxy_langgraph/types.py,sha256=EFlByBqYHXsqg5dhXYeLrdNeB48vrGBsjXeBkI1PJ_U,4423
|
|
11
|
+
noxy_langgraph-1.0.0.dist-info/licenses/LICENSE,sha256=pjpjUicySBJY-UcsEYK1IIwgy7bK7_tYuKCdZDeQ94I,1084
|
|
12
|
+
noxy_langgraph-1.0.0.dist-info/METADATA,sha256=F8sQR5km2dms_mAtmhpUsi-f7tcHusPF5D9CsSu0LHs,8102
|
|
13
|
+
noxy_langgraph-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
14
|
+
noxy_langgraph-1.0.0.dist-info/top_level.txt,sha256=t-9fLeRwDjLAHI64wk3S8QsDN61CqABIWdd47nvu880,15
|
|
15
|
+
noxy_langgraph-1.0.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
noxy_langgraph
|