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.
@@ -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
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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