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.
@@ -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))