agenttrafficcop 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.
- agenttrafficcop-0.1.0/LICENSE +21 -0
- agenttrafficcop-0.1.0/PKG-INFO +194 -0
- agenttrafficcop-0.1.0/README.md +161 -0
- agenttrafficcop-0.1.0/agenttrafficcop/__init__.py +57 -0
- agenttrafficcop-0.1.0/agenttrafficcop/client.py +263 -0
- agenttrafficcop-0.1.0/agenttrafficcop/exceptions.py +143 -0
- agenttrafficcop-0.1.0/agenttrafficcop/langgraph.py +139 -0
- agenttrafficcop-0.1.0/agenttrafficcop/models.py +58 -0
- agenttrafficcop-0.1.0/agenttrafficcop/py.typed +0 -0
- agenttrafficcop-0.1.0/agenttrafficcop.egg-info/PKG-INFO +194 -0
- agenttrafficcop-0.1.0/agenttrafficcop.egg-info/SOURCES.txt +22 -0
- agenttrafficcop-0.1.0/agenttrafficcop.egg-info/dependency_links.txt +1 -0
- agenttrafficcop-0.1.0/agenttrafficcop.egg-info/requires.txt +8 -0
- agenttrafficcop-0.1.0/agenttrafficcop.egg-info/top_level.txt +1 -0
- agenttrafficcop-0.1.0/pyproject.toml +68 -0
- agenttrafficcop-0.1.0/setup.cfg +4 -0
- agenttrafficcop-0.1.0/tests/test_client.py +175 -0
- agenttrafficcop-0.1.0/tests/test_langgraph.py +147 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AgentTrafficCop
|
|
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,194 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agenttrafficcop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for AgentTrafficCop — a runtime circuit breaker and schema shield for multi-agent systems.
|
|
5
|
+
Author: AgentTrafficCop
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/belalshafeq/Agent_Cop
|
|
8
|
+
Project-URL: Repository, https://github.com/belalshafeq/Agent_Cop
|
|
9
|
+
Project-URL: Issues, https://github.com/belalshafeq/Agent_Cop/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/belalshafeq/Agent_Cop/blob/main/sdk/CHANGELOG.md
|
|
11
|
+
Keywords: ai,agents,multi-agent,llm,governance,orchestration,circuit-breaker,langgraph,observability
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: httpx<0.28.0,>=0.27.0
|
|
27
|
+
Provides-Extra: langgraph
|
|
28
|
+
Requires-Dist: langgraph<2.0.0,>=1.2.0; extra == "langgraph"
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
pip install "agenttrafficcop[langgraph]"
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import asyncio
|
|
40
|
+
from agenttrafficcop import Guard, LoopDetected
|
|
41
|
+
|
|
42
|
+
async def main():
|
|
43
|
+
async with Guard(
|
|
44
|
+
url="http://localhost:8000",
|
|
45
|
+
api_key="your-api-key",
|
|
46
|
+
) as guard:
|
|
47
|
+
try:
|
|
48
|
+
result = await guard.check(
|
|
49
|
+
session_id="run-42",
|
|
50
|
+
sender="coder",
|
|
51
|
+
recipient="reviewer",
|
|
52
|
+
payload={"diff": "fix typo", "file": "app.py"},
|
|
53
|
+
)
|
|
54
|
+
print("approved at step", result.step)
|
|
55
|
+
except LoopDetected as e:
|
|
56
|
+
print("loop caught:", e.error_code)
|
|
57
|
+
print(" session:", e.session_id)
|
|
58
|
+
print(" step:", e.diagnostics.get("step"))
|
|
59
|
+
|
|
60
|
+
asyncio.run(main())
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## API Reference
|
|
64
|
+
|
|
65
|
+
### `Guard(url, api_key, *, timeout=10.0)`
|
|
66
|
+
|
|
67
|
+
Async client. Use as a context manager so the underlying HTTP client
|
|
68
|
+
is closed cleanly.
|
|
69
|
+
|
|
70
|
+
| Method | Purpose |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `await guard.check(session_id, sender, recipient, payload, intent="handoff", metadata=None)` | Route one message. Returns `RouteResult` on success. |
|
|
73
|
+
| `await guard.health()` | Call `GET /health`. No auth. |
|
|
74
|
+
| `await guard.reset_session(session_id)` | Delete a session. Returns `bool`. |
|
|
75
|
+
| `await guard.close()` | Close the HTTP client. Called by `__aexit__`. |
|
|
76
|
+
|
|
77
|
+
### `RouteResult`
|
|
78
|
+
|
|
79
|
+
| Attribute | Type | Meaning |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `approved` | `bool` | Always `True` on success. |
|
|
82
|
+
| `session_id` | `str` | Session this message belongs to. |
|
|
83
|
+
| `step` | `int` | 1-indexed step number. |
|
|
84
|
+
| `payload_hash` | `str` | SHA-256 hex digest (64 chars). |
|
|
85
|
+
| `message` | `str` | Human-readable confirmation. |
|
|
86
|
+
| `raw` | `dict` | Original response body. |
|
|
87
|
+
|
|
88
|
+
### Exceptions
|
|
89
|
+
|
|
90
|
+
All inherit from `AgentTrafficCopError`:
|
|
91
|
+
|
|
92
|
+
| Exception | When |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `LoopDetected` | Repeated or cyclic payload. |
|
|
95
|
+
| `StepLimitExceeded` | Session hit the step cap. |
|
|
96
|
+
| `SchemaRejected` | Server rejected the message schema. |
|
|
97
|
+
| `AuthError` | Missing or invalid API key (401). |
|
|
98
|
+
| `ConnectionError` | Could not reach the server. |
|
|
99
|
+
| `AgentTrafficCopError` | Base class — catch this for everything. |
|
|
100
|
+
|
|
101
|
+
Every exception exposes `.error_code`, `.session_id`, and `.diagnostics`.
|
|
102
|
+
|
|
103
|
+
## Catching Everything
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
try:
|
|
107
|
+
await guard.check(...)
|
|
108
|
+
except AgentTrafficCopError as e:
|
|
109
|
+
log.error("governance: %s session=%s", e.error_code, e.session_id)
|
|
110
|
+
# ... decide what to do
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## LangGraph Integration
|
|
114
|
+
|
|
115
|
+
If you're using [LangGraph](https://langchain-ai.github.io/langgraph/),
|
|
116
|
+
the SDK ships an optional adapter that wraps any node so its handoff
|
|
117
|
+
passes through AgentTrafficCop before the node runs.
|
|
118
|
+
|
|
119
|
+
### Install the extra
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
pip install -e "./sdk[langgraph]"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Wrap any node in one line
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from agenttrafficcop import Guard
|
|
129
|
+
from agenttrafficcop.langgraph import guarded_node
|
|
130
|
+
|
|
131
|
+
guard = Guard(url="http://localhost:8000", api_key="your-key")
|
|
132
|
+
|
|
133
|
+
graph.add_node(
|
|
134
|
+
"reviewer",
|
|
135
|
+
guarded_node(
|
|
136
|
+
reviewer_fn,
|
|
137
|
+
guard=guard,
|
|
138
|
+
sender="coder",
|
|
139
|
+
recipient="reviewer",
|
|
140
|
+
# Both of these can be static values OR callables that take
|
|
141
|
+
# the LangGraph state and return the value.
|
|
142
|
+
session_id=lambda state: state["run_id"],
|
|
143
|
+
payload=lambda state: {"draft": state["draft"]},
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
That's it. Every time LangGraph invokes the `reviewer` node,
|
|
149
|
+
AgentTrafficCop sees a handoff. If the breaker trips, the node raises
|
|
150
|
+
before doing any work.
|
|
151
|
+
|
|
152
|
+
### What if I don't supply `payload=`?
|
|
153
|
+
|
|
154
|
+
The wrapper falls back to hashing the entire state dict (minus a small
|
|
155
|
+
denylist of known-noise keys like `messages`, `history`, `trace`). This
|
|
156
|
+
works for small graphs, but for real graphs you should supply an
|
|
157
|
+
explicit `payload=` extractor — otherwise a growing message history
|
|
158
|
+
could cause false-positive trips.
|
|
159
|
+
|
|
160
|
+
### Sync nodes
|
|
161
|
+
|
|
162
|
+
`guarded_node` supports sync and async node functions. Sync nodes are
|
|
163
|
+
called directly; async nodes are awaited. The guard call itself is
|
|
164
|
+
always async, so if you use a sync node you must call it from a sync
|
|
165
|
+
context (no running event loop). For anything async-first, make the
|
|
166
|
+
node `async def` and everything works.
|
|
167
|
+
|
|
168
|
+
### Handling trips without raising
|
|
169
|
+
|
|
170
|
+
By default, a breaker trip raises the SDK exception (e.g.
|
|
171
|
+
`LoopDetected`) and propagates out of the node. If you'd rather handle
|
|
172
|
+
trips via state — for example, to route to a human-review node — pass:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
guarded_node(..., raise_on_trip=False)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The node becomes a no-op on a trip, and the graph continues.
|
|
179
|
+
|
|
180
|
+
### Full example
|
|
181
|
+
|
|
182
|
+
See [`examples/langgraph_demo.py`](../examples/langgraph_demo.py)
|
|
183
|
+
for a runnable two-node graph that visibly trips the breaker.
|
|
184
|
+
|
|
185
|
+
## Compatibility
|
|
186
|
+
|
|
187
|
+
- Python 3.11+
|
|
188
|
+
- `httpx` 0.27.x
|
|
189
|
+
- LangGraph 1.2.x (optional)
|
|
190
|
+
- Works against any AgentTrafficCop server 1.0.0+
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
MIT
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
pip install "agenttrafficcop[langgraph]"
|
|
2
|
+
|
|
3
|
+
## Quick Start
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
import asyncio
|
|
7
|
+
from agenttrafficcop import Guard, LoopDetected
|
|
8
|
+
|
|
9
|
+
async def main():
|
|
10
|
+
async with Guard(
|
|
11
|
+
url="http://localhost:8000",
|
|
12
|
+
api_key="your-api-key",
|
|
13
|
+
) as guard:
|
|
14
|
+
try:
|
|
15
|
+
result = await guard.check(
|
|
16
|
+
session_id="run-42",
|
|
17
|
+
sender="coder",
|
|
18
|
+
recipient="reviewer",
|
|
19
|
+
payload={"diff": "fix typo", "file": "app.py"},
|
|
20
|
+
)
|
|
21
|
+
print("approved at step", result.step)
|
|
22
|
+
except LoopDetected as e:
|
|
23
|
+
print("loop caught:", e.error_code)
|
|
24
|
+
print(" session:", e.session_id)
|
|
25
|
+
print(" step:", e.diagnostics.get("step"))
|
|
26
|
+
|
|
27
|
+
asyncio.run(main())
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## API Reference
|
|
31
|
+
|
|
32
|
+
### `Guard(url, api_key, *, timeout=10.0)`
|
|
33
|
+
|
|
34
|
+
Async client. Use as a context manager so the underlying HTTP client
|
|
35
|
+
is closed cleanly.
|
|
36
|
+
|
|
37
|
+
| Method | Purpose |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `await guard.check(session_id, sender, recipient, payload, intent="handoff", metadata=None)` | Route one message. Returns `RouteResult` on success. |
|
|
40
|
+
| `await guard.health()` | Call `GET /health`. No auth. |
|
|
41
|
+
| `await guard.reset_session(session_id)` | Delete a session. Returns `bool`. |
|
|
42
|
+
| `await guard.close()` | Close the HTTP client. Called by `__aexit__`. |
|
|
43
|
+
|
|
44
|
+
### `RouteResult`
|
|
45
|
+
|
|
46
|
+
| Attribute | Type | Meaning |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `approved` | `bool` | Always `True` on success. |
|
|
49
|
+
| `session_id` | `str` | Session this message belongs to. |
|
|
50
|
+
| `step` | `int` | 1-indexed step number. |
|
|
51
|
+
| `payload_hash` | `str` | SHA-256 hex digest (64 chars). |
|
|
52
|
+
| `message` | `str` | Human-readable confirmation. |
|
|
53
|
+
| `raw` | `dict` | Original response body. |
|
|
54
|
+
|
|
55
|
+
### Exceptions
|
|
56
|
+
|
|
57
|
+
All inherit from `AgentTrafficCopError`:
|
|
58
|
+
|
|
59
|
+
| Exception | When |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `LoopDetected` | Repeated or cyclic payload. |
|
|
62
|
+
| `StepLimitExceeded` | Session hit the step cap. |
|
|
63
|
+
| `SchemaRejected` | Server rejected the message schema. |
|
|
64
|
+
| `AuthError` | Missing or invalid API key (401). |
|
|
65
|
+
| `ConnectionError` | Could not reach the server. |
|
|
66
|
+
| `AgentTrafficCopError` | Base class — catch this for everything. |
|
|
67
|
+
|
|
68
|
+
Every exception exposes `.error_code`, `.session_id`, and `.diagnostics`.
|
|
69
|
+
|
|
70
|
+
## Catching Everything
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
try:
|
|
74
|
+
await guard.check(...)
|
|
75
|
+
except AgentTrafficCopError as e:
|
|
76
|
+
log.error("governance: %s session=%s", e.error_code, e.session_id)
|
|
77
|
+
# ... decide what to do
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## LangGraph Integration
|
|
81
|
+
|
|
82
|
+
If you're using [LangGraph](https://langchain-ai.github.io/langgraph/),
|
|
83
|
+
the SDK ships an optional adapter that wraps any node so its handoff
|
|
84
|
+
passes through AgentTrafficCop before the node runs.
|
|
85
|
+
|
|
86
|
+
### Install the extra
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pip install -e "./sdk[langgraph]"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Wrap any node in one line
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from agenttrafficcop import Guard
|
|
96
|
+
from agenttrafficcop.langgraph import guarded_node
|
|
97
|
+
|
|
98
|
+
guard = Guard(url="http://localhost:8000", api_key="your-key")
|
|
99
|
+
|
|
100
|
+
graph.add_node(
|
|
101
|
+
"reviewer",
|
|
102
|
+
guarded_node(
|
|
103
|
+
reviewer_fn,
|
|
104
|
+
guard=guard,
|
|
105
|
+
sender="coder",
|
|
106
|
+
recipient="reviewer",
|
|
107
|
+
# Both of these can be static values OR callables that take
|
|
108
|
+
# the LangGraph state and return the value.
|
|
109
|
+
session_id=lambda state: state["run_id"],
|
|
110
|
+
payload=lambda state: {"draft": state["draft"]},
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
That's it. Every time LangGraph invokes the `reviewer` node,
|
|
116
|
+
AgentTrafficCop sees a handoff. If the breaker trips, the node raises
|
|
117
|
+
before doing any work.
|
|
118
|
+
|
|
119
|
+
### What if I don't supply `payload=`?
|
|
120
|
+
|
|
121
|
+
The wrapper falls back to hashing the entire state dict (minus a small
|
|
122
|
+
denylist of known-noise keys like `messages`, `history`, `trace`). This
|
|
123
|
+
works for small graphs, but for real graphs you should supply an
|
|
124
|
+
explicit `payload=` extractor — otherwise a growing message history
|
|
125
|
+
could cause false-positive trips.
|
|
126
|
+
|
|
127
|
+
### Sync nodes
|
|
128
|
+
|
|
129
|
+
`guarded_node` supports sync and async node functions. Sync nodes are
|
|
130
|
+
called directly; async nodes are awaited. The guard call itself is
|
|
131
|
+
always async, so if you use a sync node you must call it from a sync
|
|
132
|
+
context (no running event loop). For anything async-first, make the
|
|
133
|
+
node `async def` and everything works.
|
|
134
|
+
|
|
135
|
+
### Handling trips without raising
|
|
136
|
+
|
|
137
|
+
By default, a breaker trip raises the SDK exception (e.g.
|
|
138
|
+
`LoopDetected`) and propagates out of the node. If you'd rather handle
|
|
139
|
+
trips via state — for example, to route to a human-review node — pass:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
guarded_node(..., raise_on_trip=False)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The node becomes a no-op on a trip, and the graph continues.
|
|
146
|
+
|
|
147
|
+
### Full example
|
|
148
|
+
|
|
149
|
+
See [`examples/langgraph_demo.py`](../examples/langgraph_demo.py)
|
|
150
|
+
for a runnable two-node graph that visibly trips the breaker.
|
|
151
|
+
|
|
152
|
+
## Compatibility
|
|
153
|
+
|
|
154
|
+
- Python 3.11+
|
|
155
|
+
- `httpx` 0.27.x
|
|
156
|
+
- LangGraph 1.2.x (optional)
|
|
157
|
+
- Works against any AgentTrafficCop server 1.0.0+
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
MIT
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""AgentTrafficCop Python SDK.
|
|
2
|
+
|
|
3
|
+
A thin, typed client for the AgentTrafficCop governance middleware.
|
|
4
|
+
Use it to route agent-to-agent messages through a running cop instance
|
|
5
|
+
and receive typed exceptions when the circuit breaker trips.
|
|
6
|
+
|
|
7
|
+
Quick start:
|
|
8
|
+
|
|
9
|
+
from agenttrafficcop import Guard
|
|
10
|
+
|
|
11
|
+
guard = Guard(url="http://localhost:8000", api_key="your-key")
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
result = await guard.check(
|
|
15
|
+
session_id="run-42",
|
|
16
|
+
sender="coder",
|
|
17
|
+
recipient="reviewer",
|
|
18
|
+
payload={"diff": "fix typo"},
|
|
19
|
+
)
|
|
20
|
+
print("approved at step", result.step)
|
|
21
|
+
except LoopDetected as e:
|
|
22
|
+
print("loop caught:", e.error_code, e.diagnostics)
|
|
23
|
+
|
|
24
|
+
Public API:
|
|
25
|
+
Guard — the async client
|
|
26
|
+
RouteResult — the 200 response shape
|
|
27
|
+
AgentTrafficCopError — base exception
|
|
28
|
+
LoopDetected — repeated or cyclic payload detected
|
|
29
|
+
StepLimitExceeded — session hit the step cap
|
|
30
|
+
SchemaRejected — server rejected the message schema
|
|
31
|
+
AuthError — invalid or missing API key
|
|
32
|
+
ConnectionError — could not reach the server
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from .client import Guard, RouteResult
|
|
36
|
+
from .exceptions import (
|
|
37
|
+
AgentTrafficCopError,
|
|
38
|
+
AuthError,
|
|
39
|
+
ConnectionError,
|
|
40
|
+
LoopDetected,
|
|
41
|
+
SchemaRejected,
|
|
42
|
+
StepLimitExceeded,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__version__ = "0.1.0"
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"__version__",
|
|
49
|
+
"Guard",
|
|
50
|
+
"RouteResult",
|
|
51
|
+
"AgentTrafficCopError",
|
|
52
|
+
"LoopDetected",
|
|
53
|
+
"StepLimitExceeded",
|
|
54
|
+
"SchemaRejected",
|
|
55
|
+
"AuthError",
|
|
56
|
+
"ConnectionError",
|
|
57
|
+
]
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""The Guard client — the primary user-facing API of the SDK.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
|
|
5
|
+
from agenttrafficcop import Guard, LoopDetected
|
|
6
|
+
|
|
7
|
+
async with Guard(url="http://localhost:8000", api_key="...") as guard:
|
|
8
|
+
try:
|
|
9
|
+
result = await guard.check(
|
|
10
|
+
session_id="run-42",
|
|
11
|
+
sender="coder",
|
|
12
|
+
recipient="reviewer",
|
|
13
|
+
payload={"diff": "fix typo"},
|
|
14
|
+
)
|
|
15
|
+
print("approved at step", result.step)
|
|
16
|
+
except LoopDetected as e:
|
|
17
|
+
print("loop caught:", e.error_code, e.diagnostics)
|
|
18
|
+
|
|
19
|
+
The client is async-first. A sync wrapper is intentionally not provided
|
|
20
|
+
in v0.1.0 — agent orchestrators are almost always async, and adding a
|
|
21
|
+
sync path doubles the surface area for bugs.
|
|
22
|
+
|
|
23
|
+
Multi-loop safety:
|
|
24
|
+
Some frameworks (notably LangGraph) create child event loops. If a
|
|
25
|
+
Guard instance is reused across loops, the cached httpx.AsyncClient
|
|
26
|
+
would be bound to the wrong loop. We detect this and transparently
|
|
27
|
+
rebuild the client for the currently running loop.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import asyncio
|
|
33
|
+
from typing import Any, Dict, Optional
|
|
34
|
+
|
|
35
|
+
import httpx
|
|
36
|
+
|
|
37
|
+
from .exceptions import (
|
|
38
|
+
AgentTrafficCopError,
|
|
39
|
+
AuthError,
|
|
40
|
+
ConnectionError,
|
|
41
|
+
exception_from_response,
|
|
42
|
+
)
|
|
43
|
+
from .models import RouteResult
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Guard:
|
|
47
|
+
"""Async client for the AgentTrafficCop governance middleware.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
url: Base URL of the AgentTrafficCop server, e.g.
|
|
51
|
+
"http://localhost:8000". No trailing slash required.
|
|
52
|
+
api_key: The Bearer token. Must match one of the keys the
|
|
53
|
+
server has configured in ATC_API_KEYS.
|
|
54
|
+
timeout: Per-request timeout in seconds. Default 10.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
url: str,
|
|
60
|
+
api_key: str,
|
|
61
|
+
*,
|
|
62
|
+
timeout: float = 10.0,
|
|
63
|
+
) -> None:
|
|
64
|
+
if not url:
|
|
65
|
+
raise ValueError("url must not be empty")
|
|
66
|
+
if not api_key:
|
|
67
|
+
raise ValueError("api_key must not be empty")
|
|
68
|
+
if timeout <= 0:
|
|
69
|
+
raise ValueError("timeout must be positive")
|
|
70
|
+
|
|
71
|
+
self._base_url = url.rstrip("/")
|
|
72
|
+
self._api_key = api_key
|
|
73
|
+
self._timeout = timeout
|
|
74
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
75
|
+
self._client_loop: Optional[asyncio.AbstractEventLoop] = None
|
|
76
|
+
|
|
77
|
+
# --- Lifecycle ----------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
async def __aenter__(self) -> "Guard":
|
|
80
|
+
await self._ensure_client()
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
async def __aexit__(self, *exc_info: Any) -> None:
|
|
84
|
+
await self.close()
|
|
85
|
+
|
|
86
|
+
async def _ensure_client(self) -> httpx.AsyncClient:
|
|
87
|
+
"""Return an AsyncClient bound to the *current* event loop.
|
|
88
|
+
|
|
89
|
+
We detect a loop mismatch (LangGraph and some frameworks create
|
|
90
|
+
child event loops) and transparently create a fresh client. This
|
|
91
|
+
keeps the "one client per Guard" optimization for the common case
|
|
92
|
+
while remaining correct under nested/multiple event loops.
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
current_loop = asyncio.get_running_loop()
|
|
96
|
+
except RuntimeError:
|
|
97
|
+
# No running loop — we're being called from sync code. Create
|
|
98
|
+
# a client anyway; the caller will hit an error on first use
|
|
99
|
+
# with a clearer message than ours would be.
|
|
100
|
+
current_loop = None
|
|
101
|
+
|
|
102
|
+
if (
|
|
103
|
+
self._client is not None
|
|
104
|
+
and not self._client.is_closed
|
|
105
|
+
and self._client_loop is current_loop
|
|
106
|
+
):
|
|
107
|
+
return self._client
|
|
108
|
+
|
|
109
|
+
# Either no client yet, or it's bound to a different loop.
|
|
110
|
+
# Close the old one if it's still open and its loop is alive.
|
|
111
|
+
old = self._client
|
|
112
|
+
if old is not None and not old.is_closed:
|
|
113
|
+
try:
|
|
114
|
+
await old.aclose()
|
|
115
|
+
except RuntimeError:
|
|
116
|
+
# Old client was bound to a dead loop; drop it.
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
self._client = httpx.AsyncClient(timeout=self._timeout)
|
|
120
|
+
self._client_loop = current_loop
|
|
121
|
+
return self._client
|
|
122
|
+
|
|
123
|
+
async def close(self) -> None:
|
|
124
|
+
"""Close the underlying HTTP client. Safe to call multiple times
|
|
125
|
+
and from any event loop context."""
|
|
126
|
+
client = self._client
|
|
127
|
+
self._client = None
|
|
128
|
+
self._client_loop = None
|
|
129
|
+
if client is None or client.is_closed:
|
|
130
|
+
return
|
|
131
|
+
try:
|
|
132
|
+
await client.aclose()
|
|
133
|
+
except RuntimeError:
|
|
134
|
+
# Client was bound to a loop that has since closed.
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
# --- Public API ---------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
async def check(
|
|
140
|
+
self,
|
|
141
|
+
*,
|
|
142
|
+
session_id: str,
|
|
143
|
+
sender: str,
|
|
144
|
+
recipient: str,
|
|
145
|
+
payload: Dict[str, Any],
|
|
146
|
+
intent: str = "handoff",
|
|
147
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
148
|
+
) -> RouteResult:
|
|
149
|
+
"""Send one agent-to-agent message through the cop."""
|
|
150
|
+
body: Dict[str, Any] = {
|
|
151
|
+
"session_id": session_id,
|
|
152
|
+
"sender": sender,
|
|
153
|
+
"recipient": recipient,
|
|
154
|
+
"intent": intent,
|
|
155
|
+
"payload": payload,
|
|
156
|
+
}
|
|
157
|
+
if metadata is not None:
|
|
158
|
+
body["metadata"] = metadata
|
|
159
|
+
|
|
160
|
+
client = await self._ensure_client()
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
response = await client.post(
|
|
164
|
+
f"{self._base_url}/v1/route",
|
|
165
|
+
headers={"authorization": f"Bearer {self._api_key}"},
|
|
166
|
+
json=body,
|
|
167
|
+
)
|
|
168
|
+
except httpx.RequestError as e:
|
|
169
|
+
raise ConnectionError(
|
|
170
|
+
f"Could not reach AgentTrafficCop at {self._base_url}: {e}",
|
|
171
|
+
error_code="CONNECTION_FAILED",
|
|
172
|
+
session_id=session_id,
|
|
173
|
+
diagnostics={"underlying": e.__class__.__name__},
|
|
174
|
+
) from e
|
|
175
|
+
|
|
176
|
+
if response.status_code == 200:
|
|
177
|
+
try:
|
|
178
|
+
parsed = response.json()
|
|
179
|
+
except ValueError as e:
|
|
180
|
+
raise AgentTrafficCopError(
|
|
181
|
+
"Server returned 200 with a non-JSON body.",
|
|
182
|
+
error_code="MALFORMED_RESPONSE",
|
|
183
|
+
session_id=session_id,
|
|
184
|
+
diagnostics={"body_preview": response.text[:200]},
|
|
185
|
+
) from e
|
|
186
|
+
try:
|
|
187
|
+
return RouteResult.from_response(parsed)
|
|
188
|
+
except ValueError as e:
|
|
189
|
+
raise AgentTrafficCopError(
|
|
190
|
+
str(e),
|
|
191
|
+
error_code="MALFORMED_RESPONSE",
|
|
192
|
+
session_id=session_id,
|
|
193
|
+
diagnostics={"body_preview": response.text[:200]},
|
|
194
|
+
) from e
|
|
195
|
+
|
|
196
|
+
if response.status_code == 401:
|
|
197
|
+
raise AuthError(
|
|
198
|
+
"AgentTrafficCop rejected the API key.",
|
|
199
|
+
error_code="AUTH_INVALID",
|
|
200
|
+
session_id=session_id,
|
|
201
|
+
diagnostics={"body_preview": response.text[:200]},
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if response.status_code == 422:
|
|
205
|
+
try:
|
|
206
|
+
parsed = response.json()
|
|
207
|
+
except ValueError:
|
|
208
|
+
parsed = {
|
|
209
|
+
"error": "UNPARSEABLE_422",
|
|
210
|
+
"message": "Server returned 422 with a non-JSON body.",
|
|
211
|
+
"session_id": session_id,
|
|
212
|
+
"diagnostics": {"body_preview": response.text[:200]},
|
|
213
|
+
}
|
|
214
|
+
raise exception_from_response(parsed)
|
|
215
|
+
|
|
216
|
+
raise AgentTrafficCopError(
|
|
217
|
+
f"AgentTrafficCop returned unexpected HTTP "
|
|
218
|
+
f"{response.status_code}.",
|
|
219
|
+
error_code="UNEXPECTED_STATUS",
|
|
220
|
+
session_id=session_id,
|
|
221
|
+
diagnostics={
|
|
222
|
+
"status_code": response.status_code,
|
|
223
|
+
"body_preview": response.text[:200],
|
|
224
|
+
},
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# --- Admin utilities ----------------------------------------------------
|
|
228
|
+
|
|
229
|
+
async def health(self) -> Dict[str, Any]:
|
|
230
|
+
"""Call GET /health and return the parsed body. No auth required."""
|
|
231
|
+
client = await self._ensure_client()
|
|
232
|
+
try:
|
|
233
|
+
response = await client.get(f"{self._base_url}/health")
|
|
234
|
+
except httpx.RequestError as e:
|
|
235
|
+
raise ConnectionError(
|
|
236
|
+
f"Could not reach AgentTrafficCop at {self._base_url}: {e}",
|
|
237
|
+
error_code="CONNECTION_FAILED",
|
|
238
|
+
) from e
|
|
239
|
+
response.raise_for_status()
|
|
240
|
+
return response.json()
|
|
241
|
+
|
|
242
|
+
async def reset_session(self, session_id: str) -> bool:
|
|
243
|
+
"""Call DELETE /v1/sessions/{id}. Returns True if the session existed."""
|
|
244
|
+
client = await self._ensure_client()
|
|
245
|
+
try:
|
|
246
|
+
response = await client.delete(
|
|
247
|
+
f"{self._base_url}/v1/sessions/{session_id}",
|
|
248
|
+
headers={"authorization": f"Bearer {self._api_key}"},
|
|
249
|
+
)
|
|
250
|
+
except httpx.RequestError as e:
|
|
251
|
+
raise ConnectionError(
|
|
252
|
+
f"Could not reach AgentTrafficCop at {self._base_url}: {e}",
|
|
253
|
+
error_code="CONNECTION_FAILED",
|
|
254
|
+
session_id=session_id,
|
|
255
|
+
) from e
|
|
256
|
+
if response.status_code == 401:
|
|
257
|
+
raise AuthError(
|
|
258
|
+
"AgentTrafficCop rejected the API key.",
|
|
259
|
+
error_code="AUTH_INVALID",
|
|
260
|
+
session_id=session_id,
|
|
261
|
+
)
|
|
262
|
+
response.raise_for_status()
|
|
263
|
+
return bool(response.json().get("removed", False))
|