looppause 0.1.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.
- looppause-0.1.0/.gitignore +17 -0
- looppause-0.1.0/PKG-INFO +73 -0
- looppause-0.1.0/README.md +39 -0
- looppause-0.1.0/looppause_client.py +229 -0
- looppause-0.1.0/looppause_crewai.py +135 -0
- looppause-0.1.0/looppause_langgraph.py +232 -0
- looppause-0.1.0/pyproject.toml +94 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
node_modules
|
|
2
|
+
dist
|
|
3
|
+
.next
|
|
4
|
+
.turbo
|
|
5
|
+
*.env
|
|
6
|
+
.env.local
|
|
7
|
+
.env.production
|
|
8
|
+
.env.development
|
|
9
|
+
!.env.example
|
|
10
|
+
coverage
|
|
11
|
+
.DS_Store
|
|
12
|
+
*.log
|
|
13
|
+
/prisma/migrations
|
|
14
|
+
.env
|
|
15
|
+
# Database backup files — never commit dumps
|
|
16
|
+
*.dump
|
|
17
|
+
looppause_backup.sql
|
looppause-0.1.0/PKG-INFO
ADDED
|
@@ -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,39 @@
|
|
|
1
|
+
# looppause
|
|
2
|
+
|
|
3
|
+
Python client for the [LoopPause](https://looppause.com) human-in-the-loop API.
|
|
4
|
+
|
|
5
|
+
Pause any agent action, route approval to Slack or email, and resume with an HMAC-SHA256 signed proof.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install looppause # base client
|
|
11
|
+
pip install looppause[langgraph] # + LangGraph integration
|
|
12
|
+
pip install looppause[crewai] # + CrewAI integration
|
|
13
|
+
pip install looppause[langgraph,crewai] # everything
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from looppause_client import LoopPauseClient
|
|
20
|
+
|
|
21
|
+
client = LoopPauseClient() # reads LOOPPAUSE_API_KEY + LOOPPAUSE_SIGNING_SECRET
|
|
22
|
+
|
|
23
|
+
pause_id = client.create_pause(
|
|
24
|
+
agent_id="billing-agent",
|
|
25
|
+
action={"type": "payment", "description": "Pay Acme Corp $12,450"},
|
|
26
|
+
recipients=[{"channel": "slack", "target": "#finance-approvals"}],
|
|
27
|
+
idempotency_key="idem_abc123",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
proof = client.wait_for_proof(pause_id)
|
|
31
|
+
|
|
32
|
+
if proof.human_authorized:
|
|
33
|
+
# proceed
|
|
34
|
+
pass
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Early access
|
|
38
|
+
|
|
39
|
+
LoopPause is in early access. Request access: hello@looppause.com
|
|
@@ -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)
|
|
@@ -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
|
+
})
|
|
@@ -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.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
# ── Base package: looppause ──────────────────────────────────────────────────
|
|
6
|
+
# pip install looppause
|
|
7
|
+
# pip install looppause[langgraph] -- adds langgraph
|
|
8
|
+
# pip install looppause[crewai] -- adds crewai
|
|
9
|
+
# pip install looppause[langgraph,crewai]
|
|
10
|
+
|
|
11
|
+
[project]
|
|
12
|
+
name = "looppause"
|
|
13
|
+
version = "0.1.0"
|
|
14
|
+
description = "Human-in-the-loop API client for LoopPause. Includes LangGraph and CrewAI integrations."
|
|
15
|
+
readme = "README.md"
|
|
16
|
+
license = { text = "MIT" }
|
|
17
|
+
requires-python = ">=3.10"
|
|
18
|
+
keywords = ["hitl", "human-in-the-loop", "ai-agents", "langgraph", "crewai", "looppause"]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# Core dependencies (base client only)
|
|
31
|
+
dependencies = [
|
|
32
|
+
"httpx>=0.25",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
# pip install looppause[langgraph]
|
|
37
|
+
langgraph = [
|
|
38
|
+
"langgraph>=0.2",
|
|
39
|
+
]
|
|
40
|
+
# pip install looppause[crewai]
|
|
41
|
+
crewai = [
|
|
42
|
+
"crewai>=0.28",
|
|
43
|
+
]
|
|
44
|
+
# pip install looppause[all]
|
|
45
|
+
all = [
|
|
46
|
+
"langgraph>=0.2",
|
|
47
|
+
"crewai>=0.28",
|
|
48
|
+
]
|
|
49
|
+
# Development extras
|
|
50
|
+
dev = [
|
|
51
|
+
"pytest>=7",
|
|
52
|
+
"pytest-asyncio>=0.23",
|
|
53
|
+
"mypy>=1.8",
|
|
54
|
+
"ruff>=0.3",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
[project.urls]
|
|
58
|
+
Homepage = "https://looppause.com"
|
|
59
|
+
Documentation = "https://looppause.com/docs"
|
|
60
|
+
Repository = "https://github.com/looppause/looppause"
|
|
61
|
+
"Bug Tracker" = "https://github.com/looppause/looppause/issues"
|
|
62
|
+
|
|
63
|
+
# ── Build configuration ───────────────────────────────────────────────────────
|
|
64
|
+
# Flat layout: looppause_client.py, looppause_langgraph.py, looppause_crewai.py
|
|
65
|
+
# are installed as top-level modules so `from looppause_client import ...` works.
|
|
66
|
+
[tool.hatch.build.targets.wheel]
|
|
67
|
+
include = [
|
|
68
|
+
"looppause_client.py",
|
|
69
|
+
"looppause_langgraph.py",
|
|
70
|
+
"looppause_crewai.py",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
[tool.hatch.build.targets.sdist]
|
|
74
|
+
include = [
|
|
75
|
+
"looppause_client.py",
|
|
76
|
+
"looppause_langgraph.py",
|
|
77
|
+
"looppause_crewai.py",
|
|
78
|
+
"pyproject.toml",
|
|
79
|
+
"README.md",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
# ── Linting ───────────────────────────────────────────────────────────────────
|
|
83
|
+
[tool.ruff]
|
|
84
|
+
target-version = "py310"
|
|
85
|
+
line-length = 100
|
|
86
|
+
|
|
87
|
+
[tool.ruff.lint]
|
|
88
|
+
select = ["E", "F", "I", "UP"]
|
|
89
|
+
|
|
90
|
+
# ── Type checking ─────────────────────────────────────────────────────────────
|
|
91
|
+
[tool.mypy]
|
|
92
|
+
python_version = "3.10"
|
|
93
|
+
strict = true
|
|
94
|
+
ignore_missing_imports = true
|