looppause 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,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: looppause
3
+ Version: 0.1.0
4
+ Summary: Human-in-the-loop API client for LoopPause. Includes LangGraph and CrewAI integrations.
5
+ Project-URL: Homepage, https://looppause.com
6
+ Project-URL: Documentation, https://looppause.com/docs
7
+ Project-URL: Repository, https://github.com/looppause/looppause
8
+ Project-URL: Bug Tracker, https://github.com/looppause/looppause/issues
9
+ License: MIT
10
+ Keywords: ai-agents,crewai,hitl,human-in-the-loop,langgraph,looppause
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
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: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: httpx>=0.25
21
+ Provides-Extra: all
22
+ Requires-Dist: crewai>=0.28; extra == 'all'
23
+ Requires-Dist: langgraph>=0.2; extra == 'all'
24
+ Provides-Extra: crewai
25
+ Requires-Dist: crewai>=0.28; extra == 'crewai'
26
+ Provides-Extra: dev
27
+ Requires-Dist: mypy>=1.8; extra == 'dev'
28
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
29
+ Requires-Dist: pytest>=7; extra == 'dev'
30
+ Requires-Dist: ruff>=0.3; extra == 'dev'
31
+ Provides-Extra: langgraph
32
+ Requires-Dist: langgraph>=0.2; extra == 'langgraph'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # looppause
36
+
37
+ Python client for the [LoopPause](https://looppause.com) human-in-the-loop API.
38
+
39
+ Pause any agent action, route approval to Slack or email, and resume with an HMAC-SHA256 signed proof.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install looppause # base client
45
+ pip install looppause[langgraph] # + LangGraph integration
46
+ pip install looppause[crewai] # + CrewAI integration
47
+ pip install looppause[langgraph,crewai] # everything
48
+ ```
49
+
50
+ ## Quick start
51
+
52
+ ```python
53
+ from looppause_client import LoopPauseClient
54
+
55
+ client = LoopPauseClient() # reads LOOPPAUSE_API_KEY + LOOPPAUSE_SIGNING_SECRET
56
+
57
+ pause_id = client.create_pause(
58
+ agent_id="billing-agent",
59
+ action={"type": "payment", "description": "Pay Acme Corp $12,450"},
60
+ recipients=[{"channel": "slack", "target": "#finance-approvals"}],
61
+ idempotency_key="idem_abc123",
62
+ )
63
+
64
+ proof = client.wait_for_proof(pause_id)
65
+
66
+ if proof.human_authorized:
67
+ # proceed
68
+ pass
69
+ ```
70
+
71
+ ## Early access
72
+
73
+ LoopPause is in early access. Request access: hello@looppause.com
@@ -0,0 +1,6 @@
1
+ looppause_client.py,sha256=sd8uVNkwS3xuc9TJq9bpGWq0q3WjJ9EyFmz9VREiIrQ,8172
2
+ looppause_crewai.py,sha256=SYHA7cK6Fc3q6WHId1cuBe2DxXXRcNh_jWrSipybniE,5497
3
+ looppause_langgraph.py,sha256=J7oZW8bFz5Ki64j4Vj7fkv_jmVfbP1UDhBkZ-KMk4_g,8778
4
+ looppause-0.1.0.dist-info/METADATA,sha256=GXNUfRBmcFQdhnhJjsYvp1jYc-CX3hPO9nN6vdHfokM,2465
5
+ looppause-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
6
+ looppause-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
looppause_client.py ADDED
@@ -0,0 +1,229 @@
1
+ """
2
+ looppause_client — shared Python client for the LoopPause HITL API.
3
+
4
+ Used by both the LangGraph integration and the CrewAI tool.
5
+
6
+ Server-side requirement assumed by this client:
7
+ * GET /v1/pauses/:id returns the full signed proof (including `signature`)
8
+ once status == "responded".
9
+ * POST /v1/pauses honors an `Idempotency-Key` header: the same key returns
10
+ the existing pause_id instead of creating a duplicate. This is what makes
11
+ the LangGraph interrupt() pattern correct, since the node re-executes on
12
+ resume and would otherwise create a second pause.
13
+
14
+ Signing contract (must match the server's signer):
15
+ HMAC-SHA256 over the canonical JSON of the proof with the `signature` field
16
+ removed, keys sorted recursively, compact separators, UTF-8, hex digest.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import hmac
23
+ import json
24
+ import os
25
+ import time
26
+ from dataclasses import dataclass, field
27
+ from typing import Any, Optional
28
+
29
+ import httpx # pip install httpx
30
+
31
+
32
+ # --------------------------------------------------------------------------- #
33
+ # Errors
34
+ # --------------------------------------------------------------------------- #
35
+ class LoopPauseError(Exception):
36
+ """Base error."""
37
+
38
+
39
+ class LoopPauseUnavailable(LoopPauseError):
40
+ """LoopPause is unreachable or returned 503. Callers MUST fail closed."""
41
+
42
+
43
+ class LoopPauseDenied(LoopPauseError):
44
+ """A terminal non-approval: rejected, timed out, expired, unverifiable,
45
+ or a system_fallback where a human decision was required."""
46
+
47
+
48
+ # --------------------------------------------------------------------------- #
49
+ # Signing
50
+ # --------------------------------------------------------------------------- #
51
+ def _canonical(payload: dict) -> bytes:
52
+ body = {k: v for k, v in payload.items() if k != "signature"}
53
+ return json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")
54
+
55
+
56
+ def verify_signature(proof: dict, signing_secret: str) -> bool:
57
+ provided = proof.get("signature", "")
58
+ expected = hmac.new(
59
+ signing_secret.encode("utf-8"), _canonical(proof), hashlib.sha256
60
+ ).hexdigest()
61
+ return hmac.compare_digest(provided, expected)
62
+
63
+
64
+ def stable_idempotency_key(*parts: Any) -> str:
65
+ """Deterministic key so a retried/re-executed create does not duplicate."""
66
+ blob = json.dumps(parts, sort_keys=True, separators=(",", ":"), default=str)
67
+ return "idem_" + hashlib.sha256(blob.encode("utf-8")).hexdigest()[:32]
68
+
69
+
70
+ # --------------------------------------------------------------------------- #
71
+ # Proof
72
+ # --------------------------------------------------------------------------- #
73
+ @dataclass
74
+ class Proof:
75
+ pause_id: str
76
+ decision: str # "approved" | "rejected"
77
+ authorization_type: str # "human" | "system_fallback"
78
+ human_response: bool
79
+ responder: str
80
+ responded_at: str
81
+ channel: Optional[str] = None
82
+ comment: Optional[str] = None
83
+ fields: Optional[dict] = None
84
+ raw: dict = field(default_factory=dict)
85
+
86
+ @property
87
+ def human_authorized(self) -> bool:
88
+ """True only for a genuine human approval. system_fallback is False."""
89
+ return (
90
+ self.authorization_type == "human"
91
+ and self.human_response is True
92
+ and self.decision == "approved"
93
+ )
94
+
95
+ @classmethod
96
+ def from_payload(cls, p: dict) -> "Proof":
97
+ return cls(
98
+ pause_id=p["pause_id"],
99
+ decision=p["decision"],
100
+ authorization_type=p["authorization_type"],
101
+ human_response=bool(p.get("human_response", False)),
102
+ responder=p.get("responder", ""),
103
+ responded_at=p.get("responded_at", ""),
104
+ channel=p.get("channel"),
105
+ comment=p.get("comment"),
106
+ fields=p.get("fields"),
107
+ raw=p,
108
+ )
109
+
110
+
111
+ # --------------------------------------------------------------------------- #
112
+ # Client
113
+ # --------------------------------------------------------------------------- #
114
+ class LoopPauseClient:
115
+ def __init__(
116
+ self,
117
+ api_key: Optional[str] = None,
118
+ signing_secret: Optional[str] = None,
119
+ base_url: str = "https://api.looppause.com",
120
+ request_timeout: float = 10.0,
121
+ ) -> None:
122
+ self.api_key = api_key or os.environ["LOOPPAUSE_API_KEY"]
123
+ self.signing_secret = signing_secret or os.environ["LOOPPAUSE_SIGNING_SECRET"]
124
+ self.base_url = base_url.rstrip("/")
125
+ self._http = httpx.Client(timeout=request_timeout)
126
+
127
+ def _headers(self, idempotency_key: Optional[str] = None) -> dict:
128
+ h = {
129
+ "Authorization": f"Bearer {self.api_key}",
130
+ "Content-Type": "application/json",
131
+ }
132
+ if idempotency_key:
133
+ h["Idempotency-Key"] = idempotency_key
134
+ return h
135
+
136
+ def create_pause(
137
+ self,
138
+ *,
139
+ agent_id: str,
140
+ action: dict,
141
+ recipients: list[dict],
142
+ idempotency_key: str,
143
+ webhook_url: Optional[str] = None,
144
+ form: Optional[dict] = None,
145
+ timeout_hours: int = 24,
146
+ escalation: Optional[dict] = None,
147
+ ) -> str:
148
+ body: dict[str, Any] = {
149
+ "agent_id": agent_id,
150
+ "action": action,
151
+ "recipients": recipients,
152
+ "timeout_hours": timeout_hours,
153
+ }
154
+ if webhook_url:
155
+ body["webhook_url"] = webhook_url
156
+ if form:
157
+ body["form"] = form
158
+ if escalation:
159
+ body["escalation"] = escalation
160
+
161
+ try:
162
+ r = self._http.post(
163
+ f"{self.base_url}/v1/pauses",
164
+ json=body,
165
+ headers=self._headers(idempotency_key),
166
+ )
167
+ except httpx.RequestError as exc:
168
+ raise LoopPauseUnavailable(f"create_pause transport error: {exc}") from exc
169
+
170
+ if r.status_code == 503:
171
+ raise LoopPauseUnavailable("LoopPause returned 503 on create — failing closed")
172
+ r.raise_for_status()
173
+ return r.json()["pause_id"]
174
+
175
+ def wait_for_proof(
176
+ self,
177
+ pause_id: str,
178
+ *,
179
+ poll_interval: float = 2.0,
180
+ max_interval: float = 30.0,
181
+ deadline_seconds: int = 24 * 3600,
182
+ ) -> Proof:
183
+ """Poll until responded. Verifies the HMAC signature before returning.
184
+
185
+ Raises LoopPauseUnavailable on persistent unreachability (fail closed),
186
+ LoopPauseDenied on timeout/expiry/unverifiable signature.
187
+ """
188
+ start = time.monotonic()
189
+ interval = poll_interval
190
+
191
+ while True:
192
+ if time.monotonic() - start > deadline_seconds:
193
+ raise LoopPauseDenied(f"client deadline exceeded for {pause_id} — failing closed")
194
+
195
+ try:
196
+ r = self._http.get(
197
+ f"{self.base_url}/v1/pauses/{pause_id}", headers=self._headers()
198
+ )
199
+ except httpx.RequestError:
200
+ time.sleep(interval)
201
+ interval = min(interval * 2, max_interval)
202
+ continue
203
+
204
+ if r.status_code == 503:
205
+ # transient; back off. The deadline above bounds total wait.
206
+ time.sleep(interval)
207
+ interval = min(interval * 2, max_interval)
208
+ continue
209
+
210
+ r.raise_for_status()
211
+ data = r.json()
212
+ status = data.get("status")
213
+
214
+ if status == "responded":
215
+ if not verify_signature(data, self.signing_secret):
216
+ raise LoopPauseDenied(f"signature verification failed for {pause_id} — failing closed")
217
+ return Proof.from_payload(data)
218
+
219
+ if status in ("timed_out", "expired"):
220
+ raise LoopPauseDenied(f"pause {pause_id} {status} — failing closed")
221
+
222
+ time.sleep(interval)
223
+ interval = min(interval * 2, max_interval)
224
+
225
+ def parse_resume_proof(self, payload: dict) -> Proof:
226
+ """Verify a proof handed in via Command(resume=...) (webhook path)."""
227
+ if not verify_signature(payload, self.signing_secret):
228
+ raise LoopPauseDenied("resume proof signature verification failed — failing closed")
229
+ return Proof.from_payload(payload)
looppause_crewai.py ADDED
@@ -0,0 +1,135 @@
1
+ """
2
+ looppause_crewai — a CrewAI tool that gates an agent action on human approval
3
+ via the LoopPause API.
4
+
5
+ Install (PyPI): pip install looppause-crewai
6
+ Usage:
7
+ from looppause_crewai import HumanApprovalTool
8
+ tool = HumanApprovalTool(slack_channel="#approvals", fallback_email="x@acme.eu")
9
+ agent = Agent(role="Procurement", goal="...", tools=[tool])
10
+
11
+ The tool BLOCKS until a human responds (or the pause times out). It fails CLOSED:
12
+ * LoopPause 503 / unreachable -> raises (the crew stops; it never silently proceeds)
13
+ * timeout / expiry / bad signature -> raises
14
+ * system_fallback when require_human=True -> raises (a configured default is not a sign-off)
15
+ * human rejection -> returns a clear "DENIED" string the agent must honor
16
+ Only a genuine human approval returns an "APPROVED" result.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ from typing import Any, List, Optional, Type
24
+
25
+ from crewai.tools import BaseTool
26
+ from pydantic import BaseModel, Field
27
+
28
+ from looppause_client import (
29
+ LoopPauseClient,
30
+ LoopPauseDenied,
31
+ LoopPauseUnavailable,
32
+ stable_idempotency_key,
33
+ )
34
+
35
+
36
+ class HumanApprovalInput(BaseModel):
37
+ """Input schema for the human approval gate."""
38
+
39
+ action_description: str = Field(
40
+ ..., description="A clear, human-readable description of the action awaiting approval."
41
+ )
42
+ action_details: dict = Field(
43
+ default_factory=dict,
44
+ description="Structured details of the action (amounts, targets, identifiers).",
45
+ )
46
+ action_type: str = Field(
47
+ default="agent_action", description="Short machine label for the action type."
48
+ )
49
+
50
+
51
+ class HumanApprovalTool(BaseTool):
52
+ name: str = "request_human_approval"
53
+ description: str = (
54
+ "Request human approval before performing a sensitive or irreversible action. "
55
+ "Blocks until a person approves or rejects and returns a signed decision. "
56
+ "Call this BEFORE any irreversible action (payments, deletions, external "
57
+ "communications). If the result is not an explicit human APPROVED, do not proceed."
58
+ )
59
+ args_schema: Type[BaseModel] = HumanApprovalInput
60
+
61
+ # Configuration (set at construction; not exposed to the LLM)
62
+ agent_id: str = "crewai-agent"
63
+ slack_channel: Optional[str] = None
64
+ fallback_email: Optional[str] = None
65
+ require_human: bool = True
66
+ timeout_hours: int = 24
67
+
68
+ # Lazily constructed client
69
+ _client: Optional[LoopPauseClient] = None
70
+
71
+ def __init__(self, **kwargs: Any) -> None:
72
+ super().__init__(**kwargs)
73
+ if not (self.slack_channel or self.fallback_email):
74
+ raise ValueError("HumanApprovalTool needs a slack_channel and/or fallback_email.")
75
+ # Surface missing credentials early and clearly.
76
+ if "LOOPPAUSE_API_KEY" not in os.environ or "LOOPPAUSE_SIGNING_SECRET" not in os.environ:
77
+ raise EnvironmentError(
78
+ "Set LOOPPAUSE_API_KEY and LOOPPAUSE_SIGNING_SECRET before using HumanApprovalTool."
79
+ )
80
+ self._client = LoopPauseClient()
81
+
82
+ def _recipients(self) -> List[dict]:
83
+ rcpt: dict[str, Any] = {}
84
+ if self.slack_channel:
85
+ rcpt = {"channel": "slack", "target": self.slack_channel}
86
+ if self.fallback_email:
87
+ rcpt["fallback_email"] = self.fallback_email
88
+ else:
89
+ rcpt = {"channel": "email", "target": self.fallback_email}
90
+ return [rcpt]
91
+
92
+ def _run(self, action_description: str, action_details: dict, action_type: str = "agent_action") -> str:
93
+ action = {"type": action_type, "description": action_description, "details": action_details}
94
+ idem = stable_idempotency_key(self.agent_id, action)
95
+
96
+ try:
97
+ pause_id = self._client.create_pause(
98
+ agent_id=self.agent_id,
99
+ action=action,
100
+ recipients=self._recipients(),
101
+ idempotency_key=idem,
102
+ timeout_hours=self.timeout_hours,
103
+ escalation={"after_hours": max(1, self.timeout_hours // 4),
104
+ "fallback_action": "auto_reject"},
105
+ )
106
+ proof = self._client.wait_for_proof(pause_id)
107
+ except LoopPauseUnavailable as exc:
108
+ # Fail closed hard: stop the crew rather than let it proceed unapproved.
109
+ raise RuntimeError(f"Human approval could not be obtained (LoopPause unavailable): {exc}")
110
+ except LoopPauseDenied as exc:
111
+ raise RuntimeError(f"Action not approved (no valid human decision): {exc}")
112
+
113
+ if self.require_human and not proof.human_authorized:
114
+ # e.g. system_fallback auto-approval — not a human sign-off for irreversible work.
115
+ raise RuntimeError(
116
+ f"Action not human-approved (authorization_type={proof.authorization_type}); refusing to proceed."
117
+ )
118
+
119
+ if proof.decision == "approved":
120
+ return json.dumps({
121
+ "result": "APPROVED",
122
+ "responder": proof.responder,
123
+ "authorization_type": proof.authorization_type,
124
+ "comment": proof.comment,
125
+ "fields": proof.fields,
126
+ "pause_id": proof.pause_id,
127
+ })
128
+
129
+ return json.dumps({
130
+ "result": "DENIED",
131
+ "responder": proof.responder,
132
+ "comment": proof.comment,
133
+ "pause_id": proof.pause_id,
134
+ "instruction": "Do not perform the action. A human rejected it.",
135
+ })
looppause_langgraph.py ADDED
@@ -0,0 +1,232 @@
1
+ """
2
+ looppause_langgraph — native LoopPause human-in-the-loop nodes for LangGraph.
3
+
4
+ Two patterns are provided:
5
+
6
+ 1. make_polling_gate(...) — a node that creates a pause and BLOCKS, polling
7
+ GET /v1/pauses/:id until responded. Universally compatible (no checkpointer
8
+ or inbound webhook required) but holds a graph worker for the whole wait.
9
+
10
+ 2. make_interrupt_gate(...) — the durable, graph-native pattern. The node
11
+ creates a pause and calls interrupt(); the graph hibernates in the
12
+ checkpointer. When LoopPause's webhook fires, your app resumes the thread
13
+ with Command(resume=<signed_proof>). Recommended for production.
14
+
15
+ Both write a structured result into state and fail CLOSED: any outcome that is
16
+ not a genuine human approval routes away from the protected action.
17
+
18
+ pip install langgraph httpx
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any, Callable, Optional, TypedDict
24
+
25
+ from langgraph.types import interrupt
26
+
27
+ from looppause_client import (
28
+ LoopPauseClient,
29
+ LoopPauseDenied,
30
+ LoopPauseUnavailable,
31
+ Proof,
32
+ stable_idempotency_key,
33
+ )
34
+
35
+
36
+ class GateResult(TypedDict):
37
+ approved: bool
38
+ authorization_type: Optional[str]
39
+ responder: Optional[str]
40
+ comment: Optional[str]
41
+ pause_id: Optional[str]
42
+ halt_reason: Optional[str]
43
+
44
+
45
+ def _result_from_proof(proof: Proof, *, require_human: bool) -> GateResult:
46
+ approved = proof.human_authorized if require_human else (proof.decision == "approved")
47
+ return GateResult(
48
+ approved=approved,
49
+ authorization_type=proof.authorization_type,
50
+ responder=proof.responder,
51
+ comment=proof.comment,
52
+ pause_id=proof.pause_id,
53
+ halt_reason=None if approved else f"decision={proof.decision},auth={proof.authorization_type}",
54
+ )
55
+
56
+
57
+ def _halt(reason: str, pause_id: Optional[str] = None) -> GateResult:
58
+ return GateResult(
59
+ approved=False,
60
+ authorization_type=None,
61
+ responder=None,
62
+ comment=None,
63
+ pause_id=pause_id,
64
+ halt_reason=reason,
65
+ )
66
+
67
+
68
+ # --------------------------------------------------------------------------- #
69
+ # Pattern 1: blocking polling gate
70
+ # --------------------------------------------------------------------------- #
71
+ def make_polling_gate(
72
+ client: LoopPauseClient,
73
+ *,
74
+ agent_id: str,
75
+ action_builder: Callable[[dict], dict],
76
+ recipients: list[dict],
77
+ state_key: str = "looppause",
78
+ require_human: bool = True,
79
+ timeout_hours: int = 24,
80
+ escalation: Optional[dict] = None,
81
+ ) -> Callable[[dict], dict]:
82
+ """Returns a node that creates a pause and blocks until a decision."""
83
+
84
+ def node(state: dict) -> dict:
85
+ action = action_builder(state)
86
+ idem = stable_idempotency_key(agent_id, "polling_gate", action)
87
+ try:
88
+ pause_id = client.create_pause(
89
+ agent_id=agent_id,
90
+ action=action,
91
+ recipients=recipients,
92
+ idempotency_key=idem,
93
+ timeout_hours=timeout_hours,
94
+ escalation=escalation,
95
+ )
96
+ proof = client.wait_for_proof(pause_id)
97
+ return {state_key: _result_from_proof(proof, require_human=require_human)}
98
+ except LoopPauseUnavailable as exc:
99
+ return {state_key: _halt(f"looppause_unavailable: {exc}")}
100
+ except LoopPauseDenied as exc:
101
+ return {state_key: _halt(f"denied: {exc}")}
102
+
103
+ return node
104
+
105
+
106
+ # --------------------------------------------------------------------------- #
107
+ # Pattern 2: durable interrupt() gate (recommended)
108
+ # --------------------------------------------------------------------------- #
109
+ def make_interrupt_gate(
110
+ client: LoopPauseClient,
111
+ *,
112
+ agent_id: str,
113
+ action_builder: Callable[[dict], dict],
114
+ recipients: list[dict],
115
+ webhook_url: str,
116
+ state_key: str = "looppause",
117
+ require_human: bool = True,
118
+ timeout_hours: int = 24,
119
+ escalation: Optional[dict] = None,
120
+ ) -> Callable[..., dict]:
121
+ """Returns a node that hibernates the graph via interrupt().
122
+
123
+ IMPORTANT — re-execution semantics: on resume, LangGraph re-runs this node
124
+ from the top, so create_pause() is called a SECOND time. The deterministic
125
+ idempotency key makes that second call return the SAME pause_id rather than
126
+ opening a duplicate pause. Do not remove it.
127
+
128
+ Resume the thread elsewhere with Command(resume=<signed_proof_dict>) when
129
+ LoopPause delivers the webhook (see resume_thread below). Pass thread_id in
130
+ the pause metadata so your webhook knows which thread to wake.
131
+ """
132
+
133
+ def node(state: dict, config: dict) -> dict:
134
+ thread_id = config["configurable"]["thread_id"]
135
+ action = action_builder(state)
136
+ idem = stable_idempotency_key(thread_id, "interrupt_gate", action)
137
+
138
+ try:
139
+ pause_id = client.create_pause(
140
+ agent_id=agent_id,
141
+ action=action,
142
+ recipients=recipients,
143
+ idempotency_key=idem,
144
+ webhook_url=webhook_url,
145
+ timeout_hours=timeout_hours,
146
+ escalation=escalation,
147
+ )
148
+ except LoopPauseUnavailable as exc:
149
+ # Do not suspend if we could not even create the pause — fail closed now.
150
+ return {state_key: _halt(f"looppause_unavailable: {exc}")}
151
+
152
+ # Graph suspends here. The resume value is the signed proof delivered by
153
+ # the external resumer. On resume the node re-runs; create_pause above is
154
+ # idempotent, and interrupt() returns the resume value instead of pausing.
155
+ resume_value: Any = interrupt({"pause_id": pause_id, "action": action})
156
+
157
+ try:
158
+ proof = client.parse_resume_proof(resume_value)
159
+ return {state_key: _result_from_proof(proof, require_human=require_human)}
160
+ except LoopPauseDenied as exc:
161
+ return {state_key: _halt(f"denied: {exc}", pause_id=pause_id)}
162
+
163
+ return node
164
+
165
+
166
+ def route_on_gate(state_key: str = "looppause") -> Callable[[dict], str]:
167
+ """Conditional-edge function: 'approved' or 'halt'. Default deny."""
168
+
169
+ def router(state: dict) -> str:
170
+ return "approved" if state.get(state_key, {}).get("approved") else "halt"
171
+
172
+ return router
173
+
174
+
175
+ # --------------------------------------------------------------------------- #
176
+ # External resumer (call this from your webhook handler)
177
+ # --------------------------------------------------------------------------- #
178
+ def resume_thread(graph, thread_id: str, signed_proof: dict) -> None:
179
+ """Wake a hibernating graph with the signed proof from a LoopPause webhook."""
180
+ from langgraph.types import Command
181
+
182
+ graph.invoke(Command(resume=signed_proof), config={"configurable": {"thread_id": thread_id}})
183
+
184
+
185
+ # --------------------------------------------------------------------------- #
186
+ # Example wiring
187
+ # --------------------------------------------------------------------------- #
188
+ if __name__ == "__main__":
189
+ from langgraph.graph import StateGraph, START, END
190
+ from langgraph.checkpoint.memory import MemorySaver
191
+
192
+ class S(TypedDict, total=False):
193
+ invoice: dict
194
+ looppause: GateResult
195
+ result: str
196
+
197
+ client = LoopPauseClient() # reads LOOPPAUSE_API_KEY / LOOPPAUSE_SIGNING_SECRET
198
+
199
+ gate = make_interrupt_gate(
200
+ client,
201
+ agent_id="procurement-agent",
202
+ action_builder=lambda s: {
203
+ "type": "payment_approval",
204
+ "description": f"Approve payment of {s['invoice']['amount']} {s['invoice']['currency']}",
205
+ "details": s["invoice"],
206
+ },
207
+ recipients=[{"channel": "slack", "target": "#procurement-approvals",
208
+ "fallback_email": "approvers@acme.eu"}],
209
+ webhook_url="https://acme.eu/webhooks/looppause",
210
+ require_human=True, # irreversible action: system_fallback will NOT pass
211
+ escalation={"after_hours": 4, "escalate_to": "manager@acme.eu",
212
+ "fallback_action": "auto_reject"},
213
+ )
214
+
215
+ def execute_payment(state: S) -> dict:
216
+ return {"result": f"PAID — approved by {state['looppause']['responder']}"}
217
+
218
+ def halted(state: S) -> dict:
219
+ return {"result": f"BLOCKED — {state['looppause']['halt_reason']}"}
220
+
221
+ g = StateGraph(S)
222
+ g.add_node("approval_gate", gate)
223
+ g.add_node("execute_payment", execute_payment)
224
+ g.add_node("halted", halted)
225
+ g.add_edge(START, "approval_gate")
226
+ g.add_conditional_edges("approval_gate", route_on_gate(),
227
+ {"approved": "execute_payment", "halt": "halted"})
228
+ g.add_edge("execute_payment", END)
229
+ g.add_edge("halted", END)
230
+
231
+ app = g.compile(checkpointer=MemorySaver())
232
+ # First run suspends at the gate; resume_thread(app, thread_id, proof) continues it.