letsping 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.
- letsping-0.1.0/LICENSE +21 -0
- letsping-0.1.0/MANIFEST.in +4 -0
- letsping-0.1.0/PKG-INFO +133 -0
- letsping-0.1.0/README.md +106 -0
- letsping-0.1.0/letsping.egg-info/PKG-INFO +133 -0
- letsping-0.1.0/letsping.egg-info/SOURCES.txt +12 -0
- letsping-0.1.0/letsping.egg-info/dependency_links.txt +1 -0
- letsping-0.1.0/letsping.egg-info/requires.txt +9 -0
- letsping-0.1.0/letsping.egg-info/top_level.txt +1 -0
- letsping-0.1.0/letsping.py +217 -0
- letsping-0.1.0/py.typed +0 -0
- letsping-0.1.0/pyproject.toml +43 -0
- letsping-0.1.0/setup.cfg +4 -0
- letsping-0.1.0/tests/test_core.py +66 -0
letsping-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cordia Labs
|
|
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.
|
letsping-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: letsping
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The Control Plane for Autonomous Agents. Add Human-in-the-Loop approval with one line of code.
|
|
5
|
+
Author-email: Cordia Labs <security@letsping.co>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://letsping.co
|
|
8
|
+
Project-URL: Documentation, https://letsping.co/docs
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: httpx>=0.23.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
22
|
+
Requires-Dist: respx>=0.20.0; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
24
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
25
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# LetsPing Python SDK
|
|
29
|
+
|
|
30
|
+
The official state management infrastructure for Human-in-the-Loop (HITL) AI agents.
|
|
31
|
+
|
|
32
|
+
LetsPing provides a durable "pause button" for autonomous agents, decoupling the agent's execution logic from the human's response time. It handles state serialization, secure polling, and notification routing (Slack, Email) automatically.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install letsping
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
Set your API key as an environment variable (recommended) or pass it directly.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
export LETSPING_API_KEY="lp_live_..."
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
### 1. The "Ask" Primitive (Blocking)
|
|
53
|
+
|
|
54
|
+
Use this when you want to pause a script until a human approves.
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from letsping import LetsPing
|
|
58
|
+
|
|
59
|
+
client = LetsPing()
|
|
60
|
+
|
|
61
|
+
# Pauses here for up to 24 hours (default)
|
|
62
|
+
decision = client.ask(
|
|
63
|
+
service="payments-agent",
|
|
64
|
+
action="transfer_funds",
|
|
65
|
+
payload={
|
|
66
|
+
"amount": 5000,
|
|
67
|
+
"currency": "USD",
|
|
68
|
+
"recipient": "acct_99"
|
|
69
|
+
},
|
|
70
|
+
priority="critical"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Execution resumes only after approval
|
|
74
|
+
print(f"Transfer approved by {decision['metadata']['actor_id']}")
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### 2. Async / Non-Blocking (FastAPI/LangGraph)
|
|
79
|
+
|
|
80
|
+
For high-concurrency environments or event loops.
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
import asyncio
|
|
84
|
+
from letsping import LetsPing
|
|
85
|
+
|
|
86
|
+
async def main():
|
|
87
|
+
client = LetsPing()
|
|
88
|
+
|
|
89
|
+
# Non-blocking wait
|
|
90
|
+
decision = await client.aask(
|
|
91
|
+
service="github-agent",
|
|
92
|
+
action="merge_pr",
|
|
93
|
+
payload={"pr_id": 42},
|
|
94
|
+
timeout=3600 # 1 hour timeout
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
asyncio.run(main())
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### 3. LangChain / Agent Integration
|
|
102
|
+
|
|
103
|
+
LetsPing provides a compliant tool interface that can be injected directly into LLM agent toolkits (LangChain, CrewAI, etc). This allows the LLM to *decide* when to ask for help.
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from letsping import LetsPing
|
|
107
|
+
|
|
108
|
+
client = LetsPing()
|
|
109
|
+
|
|
110
|
+
tools = [
|
|
111
|
+
# ... your other tools (search, calculator) ...
|
|
112
|
+
|
|
113
|
+
# Inject the human as a tool
|
|
114
|
+
client.tool(
|
|
115
|
+
service="research-agent",
|
|
116
|
+
action="review_draft",
|
|
117
|
+
priority="high"
|
|
118
|
+
)
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Error Handling
|
|
124
|
+
|
|
125
|
+
The SDK uses typed exceptions for control flow.
|
|
126
|
+
|
|
127
|
+
* `ApprovalRejectedError`: Raised when the human explicitly clicks "Reject".
|
|
128
|
+
* `TimeoutError`: Raised when the duration (default 24h) expires without a decision.
|
|
129
|
+
* `LetsPingError`: Base class for API or network failures.
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT
|
letsping-0.1.0/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# LetsPing Python SDK
|
|
2
|
+
|
|
3
|
+
The official state management infrastructure for Human-in-the-Loop (HITL) AI agents.
|
|
4
|
+
|
|
5
|
+
LetsPing provides a durable "pause button" for autonomous agents, decoupling the agent's execution logic from the human's response time. It handles state serialization, secure polling, and notification routing (Slack, Email) automatically.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install letsping
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Configuration
|
|
15
|
+
|
|
16
|
+
Set your API key as an environment variable (recommended) or pass it directly.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
export LETSPING_API_KEY="lp_live_..."
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
### 1. The "Ask" Primitive (Blocking)
|
|
26
|
+
|
|
27
|
+
Use this when you want to pause a script until a human approves.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from letsping import LetsPing
|
|
31
|
+
|
|
32
|
+
client = LetsPing()
|
|
33
|
+
|
|
34
|
+
# Pauses here for up to 24 hours (default)
|
|
35
|
+
decision = client.ask(
|
|
36
|
+
service="payments-agent",
|
|
37
|
+
action="transfer_funds",
|
|
38
|
+
payload={
|
|
39
|
+
"amount": 5000,
|
|
40
|
+
"currency": "USD",
|
|
41
|
+
"recipient": "acct_99"
|
|
42
|
+
},
|
|
43
|
+
priority="critical"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Execution resumes only after approval
|
|
47
|
+
print(f"Transfer approved by {decision['metadata']['actor_id']}")
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 2. Async / Non-Blocking (FastAPI/LangGraph)
|
|
52
|
+
|
|
53
|
+
For high-concurrency environments or event loops.
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import asyncio
|
|
57
|
+
from letsping import LetsPing
|
|
58
|
+
|
|
59
|
+
async def main():
|
|
60
|
+
client = LetsPing()
|
|
61
|
+
|
|
62
|
+
# Non-blocking wait
|
|
63
|
+
decision = await client.aask(
|
|
64
|
+
service="github-agent",
|
|
65
|
+
action="merge_pr",
|
|
66
|
+
payload={"pr_id": 42},
|
|
67
|
+
timeout=3600 # 1 hour timeout
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
asyncio.run(main())
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 3. LangChain / Agent Integration
|
|
75
|
+
|
|
76
|
+
LetsPing provides a compliant tool interface that can be injected directly into LLM agent toolkits (LangChain, CrewAI, etc). This allows the LLM to *decide* when to ask for help.
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from letsping import LetsPing
|
|
80
|
+
|
|
81
|
+
client = LetsPing()
|
|
82
|
+
|
|
83
|
+
tools = [
|
|
84
|
+
# ... your other tools (search, calculator) ...
|
|
85
|
+
|
|
86
|
+
# Inject the human as a tool
|
|
87
|
+
client.tool(
|
|
88
|
+
service="research-agent",
|
|
89
|
+
action="review_draft",
|
|
90
|
+
priority="high"
|
|
91
|
+
)
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Error Handling
|
|
97
|
+
|
|
98
|
+
The SDK uses typed exceptions for control flow.
|
|
99
|
+
|
|
100
|
+
* `ApprovalRejectedError`: Raised when the human explicitly clicks "Reject".
|
|
101
|
+
* `TimeoutError`: Raised when the duration (default 24h) expires without a decision.
|
|
102
|
+
* `LetsPingError`: Base class for API or network failures.
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: letsping
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The Control Plane for Autonomous Agents. Add Human-in-the-Loop approval with one line of code.
|
|
5
|
+
Author-email: Cordia Labs <security@letsping.co>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://letsping.co
|
|
8
|
+
Project-URL: Documentation, https://letsping.co/docs
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: httpx>=0.23.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
22
|
+
Requires-Dist: respx>=0.20.0; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
24
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
25
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# LetsPing Python SDK
|
|
29
|
+
|
|
30
|
+
The official state management infrastructure for Human-in-the-Loop (HITL) AI agents.
|
|
31
|
+
|
|
32
|
+
LetsPing provides a durable "pause button" for autonomous agents, decoupling the agent's execution logic from the human's response time. It handles state serialization, secure polling, and notification routing (Slack, Email) automatically.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install letsping
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
Set your API key as an environment variable (recommended) or pass it directly.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
export LETSPING_API_KEY="lp_live_..."
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
### 1. The "Ask" Primitive (Blocking)
|
|
53
|
+
|
|
54
|
+
Use this when you want to pause a script until a human approves.
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from letsping import LetsPing
|
|
58
|
+
|
|
59
|
+
client = LetsPing()
|
|
60
|
+
|
|
61
|
+
# Pauses here for up to 24 hours (default)
|
|
62
|
+
decision = client.ask(
|
|
63
|
+
service="payments-agent",
|
|
64
|
+
action="transfer_funds",
|
|
65
|
+
payload={
|
|
66
|
+
"amount": 5000,
|
|
67
|
+
"currency": "USD",
|
|
68
|
+
"recipient": "acct_99"
|
|
69
|
+
},
|
|
70
|
+
priority="critical"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Execution resumes only after approval
|
|
74
|
+
print(f"Transfer approved by {decision['metadata']['actor_id']}")
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### 2. Async / Non-Blocking (FastAPI/LangGraph)
|
|
79
|
+
|
|
80
|
+
For high-concurrency environments or event loops.
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
import asyncio
|
|
84
|
+
from letsping import LetsPing
|
|
85
|
+
|
|
86
|
+
async def main():
|
|
87
|
+
client = LetsPing()
|
|
88
|
+
|
|
89
|
+
# Non-blocking wait
|
|
90
|
+
decision = await client.aask(
|
|
91
|
+
service="github-agent",
|
|
92
|
+
action="merge_pr",
|
|
93
|
+
payload={"pr_id": 42},
|
|
94
|
+
timeout=3600 # 1 hour timeout
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
asyncio.run(main())
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### 3. LangChain / Agent Integration
|
|
102
|
+
|
|
103
|
+
LetsPing provides a compliant tool interface that can be injected directly into LLM agent toolkits (LangChain, CrewAI, etc). This allows the LLM to *decide* when to ask for help.
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from letsping import LetsPing
|
|
107
|
+
|
|
108
|
+
client = LetsPing()
|
|
109
|
+
|
|
110
|
+
tools = [
|
|
111
|
+
# ... your other tools (search, calculator) ...
|
|
112
|
+
|
|
113
|
+
# Inject the human as a tool
|
|
114
|
+
client.tool(
|
|
115
|
+
service="research-agent",
|
|
116
|
+
action="review_draft",
|
|
117
|
+
priority="high"
|
|
118
|
+
)
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Error Handling
|
|
124
|
+
|
|
125
|
+
The SDK uses typed exceptions for control flow.
|
|
126
|
+
|
|
127
|
+
* `ApprovalRejectedError`: Raised when the human explicitly clicks "Reject".
|
|
128
|
+
* `TimeoutError`: Raised when the duration (default 24h) expires without a decision.
|
|
129
|
+
* `LetsPingError`: Base class for API or network failures.
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
letsping.py
|
|
5
|
+
py.typed
|
|
6
|
+
pyproject.toml
|
|
7
|
+
letsping.egg-info/PKG-INFO
|
|
8
|
+
letsping.egg-info/SOURCES.txt
|
|
9
|
+
letsping.egg-info/dependency_links.txt
|
|
10
|
+
letsping.egg-info/requires.txt
|
|
11
|
+
letsping.egg-info/top_level.txt
|
|
12
|
+
tests/test_core.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
letsping
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Optional, Dict, Any, Literal, TypedDict, Callable
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("letsping")
|
|
11
|
+
|
|
12
|
+
DEFAULT_BASE_URL = "https://letsping.co/api"
|
|
13
|
+
VERSION = "0.1.0"
|
|
14
|
+
|
|
15
|
+
Priority = Literal["low", "medium", "high", "critical"]
|
|
16
|
+
Status = Literal["APPROVED", "REJECTED", "PENDING"]
|
|
17
|
+
|
|
18
|
+
class Decision(TypedDict):
|
|
19
|
+
status: Status
|
|
20
|
+
payload: Dict[str, Any]
|
|
21
|
+
patched_payload: Optional[Dict[str, Any]]
|
|
22
|
+
metadata: Dict[str, Any]
|
|
23
|
+
|
|
24
|
+
class LetsPingError(Exception):
|
|
25
|
+
"""Base class for all LetsPing SDK errors."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
class AuthenticationError(LetsPingError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
class ApprovalRejectedError(LetsPingError):
|
|
32
|
+
def __init__(self, reason: str):
|
|
33
|
+
super().__init__(f"Request rejected: {reason}")
|
|
34
|
+
self.reason = reason
|
|
35
|
+
|
|
36
|
+
class TimeoutError(LetsPingError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
class LetsPing:
|
|
40
|
+
"""
|
|
41
|
+
The official state management client for Human-in-the-Loop AI agents.
|
|
42
|
+
Thread-safe and async-native.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
api_key: Optional[str] = None,
|
|
48
|
+
base_url: Optional[str] = None,
|
|
49
|
+
timeout: float = 30.0
|
|
50
|
+
):
|
|
51
|
+
self._api_key = api_key or os.getenv("LETSPING_API_KEY")
|
|
52
|
+
if not self._api_key:
|
|
53
|
+
raise ValueError("LetsPing API Key must be provided via arg or LETSPING_API_KEY env var.")
|
|
54
|
+
|
|
55
|
+
self._base_url = (base_url or os.getenv("LETSPING_BASE_URL", DEFAULT_BASE_URL)).rstrip('/')
|
|
56
|
+
self._timeout = timeout
|
|
57
|
+
self._headers = {
|
|
58
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
59
|
+
"Content-Type": "application/json",
|
|
60
|
+
"User-Agent": f"letsping-python/{VERSION}",
|
|
61
|
+
"Accept": "application/json"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
def ask(
|
|
65
|
+
self,
|
|
66
|
+
service: str,
|
|
67
|
+
action: str,
|
|
68
|
+
payload: Dict[str, Any],
|
|
69
|
+
priority: Priority = "medium",
|
|
70
|
+
timeout: int = 86400
|
|
71
|
+
) -> Decision:
|
|
72
|
+
"""Blocking call: Pauses execution until a human decision is rendered."""
|
|
73
|
+
request_id = self.defer(service, action, payload, priority)
|
|
74
|
+
return self.wait(request_id, timeout=timeout)
|
|
75
|
+
|
|
76
|
+
def defer(
|
|
77
|
+
self,
|
|
78
|
+
service: str,
|
|
79
|
+
action: str,
|
|
80
|
+
payload: Dict[str, Any],
|
|
81
|
+
priority: Priority = "medium",
|
|
82
|
+
callback_url: Optional[str] = None
|
|
83
|
+
) -> str:
|
|
84
|
+
"""Non-blocking: Registers the request and returns the Request ID immediately."""
|
|
85
|
+
body = {
|
|
86
|
+
"service": service,
|
|
87
|
+
"action": action,
|
|
88
|
+
"payload": payload,
|
|
89
|
+
"priority": priority,
|
|
90
|
+
"metadata": {"sdk": "python", "callback_url": callback_url} if callback_url else {"sdk": "python"}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
with httpx.Client(base_url=self._base_url, headers=self._headers, timeout=self._timeout) as client:
|
|
94
|
+
resp = self._handle_response(client.post("/ingest", json=body))
|
|
95
|
+
return resp["id"]
|
|
96
|
+
|
|
97
|
+
def wait(self, request_id: str, timeout: int = 86400) -> Decision:
|
|
98
|
+
"""Resumes waiting for an existing request ID."""
|
|
99
|
+
start_time = time.time()
|
|
100
|
+
attempt = 0
|
|
101
|
+
|
|
102
|
+
with httpx.Client(base_url=self._base_url, headers=self._headers, timeout=self._timeout) as client:
|
|
103
|
+
while time.time() - start_time < timeout:
|
|
104
|
+
attempt += 1
|
|
105
|
+
try:
|
|
106
|
+
resp = client.get(f"/status/{request_id}")
|
|
107
|
+
if resp.status_code == 200:
|
|
108
|
+
decision = resp.json()
|
|
109
|
+
if decision["status"] in ("APPROVED", "REJECTED"):
|
|
110
|
+
return self._parse_decision(decision)
|
|
111
|
+
elif resp.status_code not in (404, 429):
|
|
112
|
+
self._handle_response(resp)
|
|
113
|
+
except (httpx.RequestError, json.JSONDecodeError) as e:
|
|
114
|
+
logger.warning(f"LetsPing polling transient error: {e}")
|
|
115
|
+
|
|
116
|
+
sleep_time = min(1.0 * (1.5 ** attempt), 10.0)
|
|
117
|
+
time.sleep(sleep_time)
|
|
118
|
+
|
|
119
|
+
raise TimeoutError(f"Wait timed out after {timeout}s for request {request_id}")
|
|
120
|
+
|
|
121
|
+
async def aask(
|
|
122
|
+
self,
|
|
123
|
+
service: str,
|
|
124
|
+
action: str,
|
|
125
|
+
payload: Dict[str, Any],
|
|
126
|
+
priority: Priority = "medium",
|
|
127
|
+
timeout: int = 86400
|
|
128
|
+
) -> Decision:
|
|
129
|
+
"""Async non-blocking wait. Compatible with asyncio event loops."""
|
|
130
|
+
request_id = await self.adefer(service, action, payload, priority)
|
|
131
|
+
return await self.await_(request_id, timeout=timeout)
|
|
132
|
+
|
|
133
|
+
async def adefer(
|
|
134
|
+
self,
|
|
135
|
+
service: str,
|
|
136
|
+
action: str,
|
|
137
|
+
payload: Dict[str, Any],
|
|
138
|
+
priority: Priority = "medium"
|
|
139
|
+
) -> str:
|
|
140
|
+
body = {
|
|
141
|
+
"service": service,
|
|
142
|
+
"action": action,
|
|
143
|
+
"payload": payload,
|
|
144
|
+
"priority": priority,
|
|
145
|
+
"metadata": {"sdk": "python"}
|
|
146
|
+
}
|
|
147
|
+
async with httpx.AsyncClient(base_url=self._base_url, headers=self._headers, timeout=self._timeout) as client:
|
|
148
|
+
resp = await client.post("/ingest", json=body)
|
|
149
|
+
data = self._handle_response(resp)
|
|
150
|
+
return data["id"]
|
|
151
|
+
|
|
152
|
+
async def await_(self, request_id: str, timeout: int = 86400) -> Decision:
|
|
153
|
+
start_time = time.time()
|
|
154
|
+
attempt = 0
|
|
155
|
+
|
|
156
|
+
async with httpx.AsyncClient(base_url=self._base_url, headers=self._headers, timeout=self._timeout) as client:
|
|
157
|
+
while time.time() - start_time < timeout:
|
|
158
|
+
attempt += 1
|
|
159
|
+
try:
|
|
160
|
+
resp = await client.get(f"/status/{request_id}")
|
|
161
|
+
if resp.status_code == 200:
|
|
162
|
+
decision = resp.json()
|
|
163
|
+
if decision["status"] in ("APPROVED", "REJECTED"):
|
|
164
|
+
return self._parse_decision(decision)
|
|
165
|
+
except (httpx.RequestError, json.JSONDecodeError):
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
sleep_time = min(1.0 * (1.5 ** attempt), 10.0)
|
|
169
|
+
await asyncio.sleep(sleep_time)
|
|
170
|
+
|
|
171
|
+
raise TimeoutError(f"Async wait timed out after {timeout}s for request {request_id}")
|
|
172
|
+
|
|
173
|
+
def tool(self, service: str, action: str, priority: Priority = "medium") -> Callable:
|
|
174
|
+
"""Returns a callable 'Tool' compatible with LangChain/CrewAI."""
|
|
175
|
+
def human_approval_tool(context: str) -> str:
|
|
176
|
+
try:
|
|
177
|
+
payload = json.loads(context)
|
|
178
|
+
except json.JSONDecodeError:
|
|
179
|
+
payload = {"raw_context": context}
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
result = self.ask(service, action, payload, priority)
|
|
183
|
+
return json.dumps(result["approved_payload"])
|
|
184
|
+
except ApprovalRejectedError as e:
|
|
185
|
+
return f"ACTION_REJECTED: {e.reason}"
|
|
186
|
+
except Exception as e:
|
|
187
|
+
return f"ERROR: {str(e)}"
|
|
188
|
+
|
|
189
|
+
return human_approval_tool
|
|
190
|
+
|
|
191
|
+
def _handle_response(self, response: httpx.Response) -> Dict[str, Any]:
|
|
192
|
+
if response.status_code == 401 or response.status_code == 403:
|
|
193
|
+
raise AuthenticationError("Invalid API Key or unauthorized access.")
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
response.raise_for_status()
|
|
197
|
+
return response.json()
|
|
198
|
+
except httpx.HTTPStatusError as e:
|
|
199
|
+
error_msg = response.text
|
|
200
|
+
try:
|
|
201
|
+
error_data = response.json()
|
|
202
|
+
error_msg = error_data.get("message", response.text)
|
|
203
|
+
except:
|
|
204
|
+
pass
|
|
205
|
+
raise LetsPingError(f"API Error {response.status_code}: {error_msg}") from e
|
|
206
|
+
|
|
207
|
+
def _parse_decision(self, data: Dict[str, Any]) -> Decision:
|
|
208
|
+
status = data.get("status")
|
|
209
|
+
if status == "REJECTED":
|
|
210
|
+
raise ApprovalRejectedError(data.get("reason", "No reason provided"))
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
"status": "APPROVED",
|
|
214
|
+
"payload": data.get("payload", {}),
|
|
215
|
+
"patched_payload": data.get("patched_payload"),
|
|
216
|
+
"metadata": data.get("metadata", {})
|
|
217
|
+
}
|
letsping-0.1.0/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "letsping"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "The Control Plane for Autonomous Agents. Add Human-in-the-Loop approval with one line of code."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [{ name = "Cordia Labs", email = "security@letsping.co" }]
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
19
|
+
]
|
|
20
|
+
requires-python = ">=3.8"
|
|
21
|
+
dependencies = [
|
|
22
|
+
"httpx>=0.23.0"
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dev = [
|
|
27
|
+
"pytest>=7.0",
|
|
28
|
+
"pytest-asyncio>=0.21.0",
|
|
29
|
+
"respx>=0.20.0",
|
|
30
|
+
"pytest-cov>=4.0",
|
|
31
|
+
"mypy>=1.0",
|
|
32
|
+
"ruff>=0.1.0"
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
"Homepage" = "https://letsping.co"
|
|
37
|
+
"Documentation" = "https://letsping.co/docs"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools]
|
|
40
|
+
py-modules = ["letsping"]
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.package-data]
|
|
43
|
+
"*" = ["py.typed"]
|
letsping-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import respx
|
|
3
|
+
from httpx import Response
|
|
4
|
+
from letsping import LetsPing, ApprovalRejectedError, DEFAULT_BASE_URL
|
|
5
|
+
|
|
6
|
+
MOCK_API_KEY = "lp_test_mock_key"
|
|
7
|
+
|
|
8
|
+
@pytest.fixture
|
|
9
|
+
def client():
|
|
10
|
+
return LetsPing(api_key=MOCK_API_KEY)
|
|
11
|
+
|
|
12
|
+
@respx.mock
|
|
13
|
+
def test_ask_approval_flow(client):
|
|
14
|
+
"""Verifies the standard approval lifecycle (Ingest -> Pending -> Approved)."""
|
|
15
|
+
ingest_route = respx.post(f"{DEFAULT_BASE_URL}/ingest")
|
|
16
|
+
ingest_route.mock(return_value=Response(200, json={"id": "req_123"}))
|
|
17
|
+
|
|
18
|
+
status_route = respx.get(f"{DEFAULT_BASE_URL}/status/req_123")
|
|
19
|
+
status_route.side_effect = [
|
|
20
|
+
Response(200, json={"status": "PENDING"}),
|
|
21
|
+
Response(200, json={
|
|
22
|
+
"status": "APPROVED",
|
|
23
|
+
"payload": {"amount": 100},
|
|
24
|
+
"patched_payload": {"amount": 100},
|
|
25
|
+
"metadata": {"actor_id": "user_1", "resolved_at": "2024-01-01"}
|
|
26
|
+
})
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
result = client.ask("test-service", "test-action", {"amount": 100}, timeout=2)
|
|
30
|
+
|
|
31
|
+
assert result["status"] == "APPROVED"
|
|
32
|
+
assert result["metadata"]["actor_id"] == "user_1"
|
|
33
|
+
assert ingest_route.called
|
|
34
|
+
assert status_route.call_count == 2
|
|
35
|
+
|
|
36
|
+
@respx.mock
|
|
37
|
+
def test_rejection_error(client):
|
|
38
|
+
"""Verifies that human rejection raises the specific exception."""
|
|
39
|
+
respx.post(f"{DEFAULT_BASE_URL}/ingest").mock(return_value=Response(200, json={"id": "req_999"}))
|
|
40
|
+
|
|
41
|
+
respx.get(f"{DEFAULT_BASE_URL}/status/req_999").mock(return_value=Response(200, json={
|
|
42
|
+
"status": "REJECTED",
|
|
43
|
+
"reason": "Risk score too high",
|
|
44
|
+
"metadata": {}
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
with pytest.raises(ApprovalRejectedError) as exc:
|
|
48
|
+
client.ask("test", "test", {}, timeout=2)
|
|
49
|
+
|
|
50
|
+
assert "Risk score too high" in str(exc.value)
|
|
51
|
+
|
|
52
|
+
@respx.mock
|
|
53
|
+
@pytest.mark.asyncio
|
|
54
|
+
async def test_async_flow():
|
|
55
|
+
"""Verifies the async/await implementation works correctly."""
|
|
56
|
+
client = LetsPing(api_key=MOCK_API_KEY)
|
|
57
|
+
|
|
58
|
+
respx.post(f"{DEFAULT_BASE_URL}/ingest").mock(return_value=Response(200, json={"id": "req_async"}))
|
|
59
|
+
respx.get(f"{DEFAULT_BASE_URL}/status/req_async").mock(return_value=Response(200, json={
|
|
60
|
+
"status": "APPROVED",
|
|
61
|
+
"payload": {},
|
|
62
|
+
"metadata": {}
|
|
63
|
+
}))
|
|
64
|
+
|
|
65
|
+
result = await client.aask("async-service", "run", {})
|
|
66
|
+
assert result["status"] == "APPROVED"
|