agenttrafficcop 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agenttrafficcop/__init__.py +57 -0
- agenttrafficcop/client.py +263 -0
- agenttrafficcop/exceptions.py +143 -0
- agenttrafficcop/langgraph.py +139 -0
- agenttrafficcop/models.py +58 -0
- agenttrafficcop/py.typed +0 -0
- agenttrafficcop-0.1.0.dist-info/METADATA +194 -0
- agenttrafficcop-0.1.0.dist-info/RECORD +11 -0
- agenttrafficcop-0.1.0.dist-info/WHEEL +5 -0
- agenttrafficcop-0.1.0.dist-info/licenses/LICENSE +21 -0
- agenttrafficcop-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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))
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Exception hierarchy for the AgentTrafficCop SDK.
|
|
2
|
+
|
|
3
|
+
Every error the SDK raises inherits from `AgentTrafficCopError`, so a
|
|
4
|
+
caller can write:
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
await guard.check(...)
|
|
8
|
+
except AgentTrafficCopError as e:
|
|
9
|
+
log(e.error_code, e.session_id, e.diagnostics)
|
|
10
|
+
|
|
11
|
+
The subclasses exist so callers can react to *specific* failures:
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
await guard.check(...)
|
|
15
|
+
except LoopDetected:
|
|
16
|
+
halt_session()
|
|
17
|
+
except StepLimitExceeded:
|
|
18
|
+
alert_ops()
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any, Dict, Optional
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AgentTrafficCopError(Exception):
|
|
27
|
+
"""Base class for all SDK exceptions.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
message: Human-readable error message.
|
|
31
|
+
error_code: Machine-readable code from the server (e.g.
|
|
32
|
+
"REPEATED_PAYLOAD_LOOP"). None if the SDK raised
|
|
33
|
+
this locally without a server response.
|
|
34
|
+
session_id: Session the error relates to, if known.
|
|
35
|
+
diagnostics: Structured details from the server, if any.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
message: str,
|
|
41
|
+
*,
|
|
42
|
+
error_code: Optional[str] = None,
|
|
43
|
+
session_id: Optional[str] = None,
|
|
44
|
+
diagnostics: Optional[Dict[str, Any]] = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
self.message = message
|
|
48
|
+
self.error_code = error_code
|
|
49
|
+
self.session_id = session_id
|
|
50
|
+
self.diagnostics = diagnostics or {}
|
|
51
|
+
|
|
52
|
+
def __repr__(self) -> str:
|
|
53
|
+
return (
|
|
54
|
+
f"{self.__class__.__name__}("
|
|
55
|
+
f"error_code={self.error_code!r}, "
|
|
56
|
+
f"session_id={self.session_id!r}, "
|
|
57
|
+
f"message={self.message!r})"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Governance errors (server returned 422 with a known error code)
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LoopDetected(AgentTrafficCopError):
|
|
67
|
+
"""The circuit breaker tripped on a repeated or cyclic payload.
|
|
68
|
+
|
|
69
|
+
The server sets `error_code` to one of:
|
|
70
|
+
REPEATED_PAYLOAD_LOOP — same hash seen too many times consecutively
|
|
71
|
+
CYCLIC_PAYLOAD_LOOP — short repeating cycle detected (A↔B)
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class StepLimitExceeded(AgentTrafficCopError):
|
|
76
|
+
"""The session exceeded the configured maximum number of steps."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class SchemaRejected(AgentTrafficCopError):
|
|
80
|
+
"""The server rejected the message as failing schema validation.
|
|
81
|
+
|
|
82
|
+
This means the message didn't match the AgentMessage contract.
|
|
83
|
+
Usually a client-side bug — the payload was malformed before it
|
|
84
|
+
was even sent.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Transport / auth errors (server returned 401 or the request failed)
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class AuthError(AgentTrafficCopError):
|
|
94
|
+
"""The server rejected the API key (401).
|
|
95
|
+
|
|
96
|
+
Possible causes:
|
|
97
|
+
* No key configured on the server (fail-closed).
|
|
98
|
+
* Wrong key sent by the client.
|
|
99
|
+
* Malformed Authorization header.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ConnectionError(AgentTrafficCopError):
|
|
104
|
+
"""Could not reach the AgentTrafficCop server.
|
|
105
|
+
|
|
106
|
+
Wraps the underlying transport error (httpx.ConnectError,
|
|
107
|
+
httpx.TimeoutException, etc.) as an SDK-level exception.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# Internal helpers (used by the client to map server responses)
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Map server error codes -> SDK exception classes.
|
|
117
|
+
_ERROR_CODE_MAP = {
|
|
118
|
+
"REPEATED_PAYLOAD_LOOP": LoopDetected,
|
|
119
|
+
"CYCLIC_PAYLOAD_LOOP": LoopDetected,
|
|
120
|
+
"STEP_LIMIT_EXCEEDED": StepLimitExceeded,
|
|
121
|
+
"SCHEMA_VALIDATION_FAILED": SchemaRejected,
|
|
122
|
+
"CIRCUIT_BREAKER_TRIPPED": LoopDetected, # fallback for base breaker
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def exception_from_response(body: Dict[str, Any]) -> AgentTrafficCopError:
|
|
127
|
+
"""Build the right SDK exception from a 422 response body.
|
|
128
|
+
|
|
129
|
+
Falls back to the base class for unknown error codes so the SDK
|
|
130
|
+
never crashes on a server that adds new codes.
|
|
131
|
+
"""
|
|
132
|
+
error_code = body.get("error")
|
|
133
|
+
message = body.get("message", "AgentTrafficCop rejected the message.")
|
|
134
|
+
session_id = body.get("session_id")
|
|
135
|
+
diagnostics = body.get("diagnostics") or {}
|
|
136
|
+
|
|
137
|
+
exc_class = _ERROR_CODE_MAP.get(error_code, AgentTrafficCopError)
|
|
138
|
+
return exc_class(
|
|
139
|
+
message,
|
|
140
|
+
error_code=error_code,
|
|
141
|
+
session_id=session_id,
|
|
142
|
+
diagnostics=diagnostics,
|
|
143
|
+
)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""LangGraph integration for AgentTrafficCop.
|
|
2
|
+
|
|
3
|
+
Wraps a LangGraph node function so that every invocation first asks
|
|
4
|
+
AgentTrafficCop whether the current handoff is allowed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import inspect
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any, Awaitable, Callable, Dict, Optional, Union
|
|
13
|
+
|
|
14
|
+
from .client import Guard
|
|
15
|
+
from .exceptions import AgentTrafficCopError
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("agenttrafficcop.langgraph")
|
|
18
|
+
|
|
19
|
+
StateT = Dict[str, Any]
|
|
20
|
+
NodeFn = Callable[[StateT], Union[Optional[StateT], Awaitable[Optional[StateT]]]]
|
|
21
|
+
ValueOrExtractor = Union[str, Callable[[StateT], Any]]
|
|
22
|
+
|
|
23
|
+
_AUTO_PAYLOAD_EXCLUDE = frozenset(
|
|
24
|
+
{"messages", "history", "trace", "logs"}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resolve(value: ValueOrExtractor, state: StateT, *, name: str) -> Any:
|
|
29
|
+
if callable(value):
|
|
30
|
+
try:
|
|
31
|
+
return value(state)
|
|
32
|
+
except Exception as e:
|
|
33
|
+
raise RuntimeError(
|
|
34
|
+
f"AgentTrafficCop: {name} extractor raised on state: {e}"
|
|
35
|
+
) from e
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _default_payload(state: StateT) -> Dict[str, Any]:
|
|
40
|
+
return {k: v for k, v in state.items() if k not in _AUTO_PAYLOAD_EXCLUDE}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def guarded_node(
|
|
44
|
+
node_fn: NodeFn,
|
|
45
|
+
*,
|
|
46
|
+
guard: Guard,
|
|
47
|
+
sender: str,
|
|
48
|
+
recipient: str,
|
|
49
|
+
session_id: ValueOrExtractor = "session_id",
|
|
50
|
+
payload: Optional[ValueOrExtractor] = None,
|
|
51
|
+
intent: str = "handoff",
|
|
52
|
+
metadata: Optional[ValueOrExtractor] = None,
|
|
53
|
+
raise_on_trip: bool = True,
|
|
54
|
+
) -> NodeFn:
|
|
55
|
+
if not callable(node_fn):
|
|
56
|
+
raise TypeError("node_fn must be callable")
|
|
57
|
+
|
|
58
|
+
is_async = inspect.iscoroutinefunction(node_fn)
|
|
59
|
+
|
|
60
|
+
async def _check(state: StateT) -> bool:
|
|
61
|
+
"""Return True if the node should run, False if it should be skipped.
|
|
62
|
+
|
|
63
|
+
- With raise_on_trip=True, a trip raises out of this function.
|
|
64
|
+
- With raise_on_trip=False, a trip returns False and the caller
|
|
65
|
+
must skip the underlying node.
|
|
66
|
+
"""
|
|
67
|
+
sid = _resolve(session_id, state, name="session_id")
|
|
68
|
+
if payload is None:
|
|
69
|
+
pld = _default_payload(state)
|
|
70
|
+
else:
|
|
71
|
+
pld = _resolve(payload, state, name="payload")
|
|
72
|
+
md = _resolve(metadata, state, name="metadata") if metadata is not None else None
|
|
73
|
+
|
|
74
|
+
if not isinstance(sid, str) or not sid:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
"AgentTrafficCop: session_id resolved to an empty/invalid value"
|
|
77
|
+
)
|
|
78
|
+
if not isinstance(pld, dict):
|
|
79
|
+
raise ValueError(
|
|
80
|
+
"AgentTrafficCop: payload must resolve to a dict, got "
|
|
81
|
+
f"{type(pld).__name__}"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
await guard.check(
|
|
86
|
+
session_id=sid,
|
|
87
|
+
sender=sender,
|
|
88
|
+
recipient=recipient,
|
|
89
|
+
payload=pld,
|
|
90
|
+
intent=intent,
|
|
91
|
+
metadata=md,
|
|
92
|
+
)
|
|
93
|
+
return True
|
|
94
|
+
except AgentTrafficCopError as e:
|
|
95
|
+
if raise_on_trip:
|
|
96
|
+
logger.warning(
|
|
97
|
+
"langgraph_node_tripped node=%s session=%s error_code=%s",
|
|
98
|
+
getattr(node_fn, "__name__", repr(node_fn)),
|
|
99
|
+
sid,
|
|
100
|
+
e.error_code,
|
|
101
|
+
)
|
|
102
|
+
raise
|
|
103
|
+
logger.info(
|
|
104
|
+
"langgraph_node_skipped node=%s session=%s error_code=%s",
|
|
105
|
+
getattr(node_fn, "__name__", repr(node_fn)),
|
|
106
|
+
sid,
|
|
107
|
+
e.error_code,
|
|
108
|
+
)
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
if is_async:
|
|
112
|
+
async def _wrapped_async(state: StateT) -> Any:
|
|
113
|
+
should_run = await _check(state)
|
|
114
|
+
if not should_run:
|
|
115
|
+
return None
|
|
116
|
+
return await node_fn(state)
|
|
117
|
+
|
|
118
|
+
_wrapped_async.__name__ = getattr(node_fn, "__name__", "guarded_node")
|
|
119
|
+
_wrapped_async.__doc__ = node_fn.__doc__
|
|
120
|
+
return _wrapped_async
|
|
121
|
+
|
|
122
|
+
def _wrapped_sync(state: StateT) -> Any:
|
|
123
|
+
try:
|
|
124
|
+
asyncio.get_running_loop()
|
|
125
|
+
except RuntimeError:
|
|
126
|
+
should_run = asyncio.run(_check(state))
|
|
127
|
+
else:
|
|
128
|
+
raise RuntimeError(
|
|
129
|
+
"guarded_node: sync node called inside a running event loop. "
|
|
130
|
+
"Either make your node async, or use raise_on_trip=False. "
|
|
131
|
+
"AgentTrafficCop's Guard is async-only by design."
|
|
132
|
+
)
|
|
133
|
+
if not should_run:
|
|
134
|
+
return None
|
|
135
|
+
return node_fn(state)
|
|
136
|
+
|
|
137
|
+
_wrapped_sync.__name__ = getattr(node_fn, "__name__", "guarded_node")
|
|
138
|
+
_wrapped_sync.__doc__ = node_fn.__doc__
|
|
139
|
+
return _wrapped_sync
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Typed models returned by the AgentTrafficCop SDK.
|
|
2
|
+
|
|
3
|
+
These mirror the response schemas of the server's /v1/route endpoint,
|
|
4
|
+
but live in the SDK so callers get typed attributes and IDE
|
|
5
|
+
autocomplete instead of raw dicts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Dict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class RouteResult:
|
|
16
|
+
"""A successful approval returned by POST /v1/route.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
approved: Always True on success. Present for symmetry with
|
|
20
|
+
the server response.
|
|
21
|
+
session_id: The session this message belongs to.
|
|
22
|
+
step: 1-indexed step number this message was assigned.
|
|
23
|
+
payload_hash: SHA-256 hex digest of the payload (64 chars).
|
|
24
|
+
message: Human-readable confirmation from the server.
|
|
25
|
+
raw: The original response dict, in case a caller needs
|
|
26
|
+
a field the SDK doesn't model yet.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
approved: bool
|
|
30
|
+
session_id: str
|
|
31
|
+
step: int
|
|
32
|
+
payload_hash: str
|
|
33
|
+
message: str
|
|
34
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_response(cls, body: Dict[str, Any]) -> "RouteResult":
|
|
38
|
+
"""Build a RouteResult from a parsed JSON response body.
|
|
39
|
+
|
|
40
|
+
Raises ValueError if required fields are missing or malformed.
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
return cls(
|
|
44
|
+
approved=bool(body["approved"]),
|
|
45
|
+
session_id=str(body["session_id"]),
|
|
46
|
+
step=int(body["step"]),
|
|
47
|
+
payload_hash=str(body["payload_hash"]),
|
|
48
|
+
message=str(body.get("message", "")),
|
|
49
|
+
raw=dict(body),
|
|
50
|
+
)
|
|
51
|
+
except KeyError as e:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"Server response missing required field: {e}"
|
|
54
|
+
) from e
|
|
55
|
+
except (TypeError, ValueError) as e:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"Server response had an unexpected shape: {e}"
|
|
58
|
+
) from e
|
agenttrafficcop/py.typed
ADDED
|
File without changes
|
|
@@ -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,11 @@
|
|
|
1
|
+
agenttrafficcop/__init__.py,sha256=O4yl3qsrU5FSMgKxuynIRp0LHCMbtRcx6O4aXlULCE8,1537
|
|
2
|
+
agenttrafficcop/client.py,sha256=6q7VuxfAL3L_4tDPva50l-28nEn-NReakTOGbysn5aE,9316
|
|
3
|
+
agenttrafficcop/exceptions.py,sha256=g-hMi-zuBf7Co9miDG938PAhmofXGNoMfdHRl5B-bhg,4564
|
|
4
|
+
agenttrafficcop/langgraph.py,sha256=fzenKuN0H2Etq1RbF2AlEtjOOdqARBSBsr-qiAeugrU,4527
|
|
5
|
+
agenttrafficcop/models.py,sha256=IRD-ayh5acmZR5armeu1TYog23muJpOptrE1_8uKQCc,1993
|
|
6
|
+
agenttrafficcop/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
agenttrafficcop-0.1.0.dist-info/licenses/LICENSE,sha256=6xZONMHoPrlWkaq8qqcyvVX1ynl0MShd9irMuBCrrGo,1071
|
|
8
|
+
agenttrafficcop-0.1.0.dist-info/METADATA,sha256=bKtSDCEMyCkeQlNQAXAUwFoI5Oy-6IUVd0Lsho4CUfM,6244
|
|
9
|
+
agenttrafficcop-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
agenttrafficcop-0.1.0.dist-info/top_level.txt,sha256=b-yrJ9FhjQ4FHM21XFyMPUm7cW8aAZKlqvC19H2ArDI,16
|
|
11
|
+
agenttrafficcop-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
agenttrafficcop
|