open-agent-control-panel 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.
oacp/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Open Agent Control Panel — a registry and control plane for A2A agents.
2
+
3
+ The package implements the A2A Discover Protocol (ADP) Core v1 operations so any
4
+ A2A agent can register itself, plus an A2A JSON-RPC client and a small web UI for
5
+ submitting tasks to those agents and following their state.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __version__ = "0.1.0"
11
+ __all__ = ["__version__"]
oacp/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Allow ``python -m oacp`` to behave like the ``oacp`` script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
oacp/a2a/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """The A2A client used by the panel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .client import A2AClient, A2AError, CallResult
6
+
7
+ __all__ = ["A2AClient", "A2AError", "CallResult"]
oacp/a2a/client.py ADDED
@@ -0,0 +1,317 @@
1
+ """A2A JSON-RPC client.
2
+
3
+ The panel must talk to agents that were built across two A2A generations:
4
+
5
+ * 0.3 cards advertise ``url`` + ``protocolVersion`` and use the
6
+ ``message/send`` / ``tasks/get`` / ``tasks/cancel`` methods with a ``role`` of
7
+ ``user`` and parts tagged with ``kind``;
8
+ * newer cards advertise ``supportedInterfaces`` and use ``SendMessage`` /
9
+ ``GetTask`` / ``CancelTask`` with proto-style field names and ``ROLE_USER``.
10
+
11
+ Rather than asking the operator to care, the client derives the dialect from the
12
+ card and, when the agent answers "method not found" or "invalid params", retries
13
+ once with the other dialect. Both answers are normalized before they leave this
14
+ module.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ import time
22
+ import uuid
23
+ from dataclasses import dataclass
24
+ from typing import Any, Callable
25
+
26
+ import httpx
27
+
28
+ from .. import net
29
+ from ..adp import card as cardmod
30
+
31
+ log = logging.getLogger(__name__)
32
+
33
+ METHODS: dict[str, dict[str, str]] = {
34
+ "v03": {"send": "message/send", "get": "tasks/get", "cancel": "tasks/cancel"},
35
+ "v1": {"send": "SendMessage", "get": "GetTask", "cancel": "CancelTask"},
36
+ }
37
+
38
+ _RETRYABLE_CODES = {-32601, -32602, -32700}
39
+ _RETRYABLE_STATUSES = {400, 404, 405, 415}
40
+
41
+
42
+ class A2AError(Exception):
43
+ """A failure while speaking A2A: transport, JSON-RPC or protocol shape."""
44
+
45
+ def __init__(
46
+ self,
47
+ message: str,
48
+ *,
49
+ code: int | str | None = None,
50
+ status: int | None = None,
51
+ payload: Any = None,
52
+ retryable: bool = False,
53
+ ) -> None:
54
+ super().__init__(message)
55
+ self.message = message
56
+ self.code = code
57
+ self.status = status
58
+ self.payload = payload
59
+ self.retryable = retryable
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class CallResult:
64
+ """A successful JSON-RPC result plus the details worth recording."""
65
+
66
+ result: Any
67
+ endpoint: str
68
+ style: str
69
+ method: str
70
+ request_id: str
71
+ latency_ms: int
72
+
73
+
74
+ class A2AClient:
75
+ """Calls agents over JSON-RPC, with the SSRF guard still switched on."""
76
+
77
+ def __init__(
78
+ self,
79
+ *,
80
+ allow_private: bool = False,
81
+ timeout: float = 60.0,
82
+ user_agent: str = "open-agent-control-panel",
83
+ transport: httpx.BaseTransport | None = None,
84
+ ) -> None:
85
+ self.allow_private = allow_private
86
+ self.timeout = timeout
87
+ self.user_agent = user_agent
88
+ # Injectable for tests; when set, requests skip DNS pinning entirely.
89
+ self.transport = transport
90
+
91
+ # ------------------------------------------------------------------ public API
92
+
93
+ def send_message(
94
+ self,
95
+ agent_card: dict[str, Any],
96
+ text: str,
97
+ *,
98
+ context_id: str | None = None,
99
+ task_id: str | None = None,
100
+ metadata: dict[str, Any] | None = None,
101
+ ) -> CallResult:
102
+ """Send a user message; the agent may answer with a Task or a Message."""
103
+ endpoint, style = self._target(agent_card)
104
+ message_id = str(uuid.uuid4())
105
+
106
+ def build(dialect: str) -> dict[str, Any]:
107
+ params: dict[str, Any] = {
108
+ "message": _message(dialect, text, message_id, context_id, task_id)
109
+ }
110
+ if metadata:
111
+ params["metadata"] = metadata
112
+ return params
113
+
114
+ return self._call_with_fallback(endpoint, style, "send", build)
115
+
116
+ def get_task(
117
+ self,
118
+ agent_card: dict[str, Any],
119
+ task_id: str,
120
+ *,
121
+ history_length: int | None = None,
122
+ ) -> CallResult:
123
+ """Fetch the current state of a task."""
124
+ endpoint, style = self._target(agent_card)
125
+
126
+ def build(dialect: str) -> dict[str, Any]:
127
+ params: dict[str, Any] = {"id": task_id}
128
+ if history_length is not None:
129
+ params["historyLength"] = int(history_length)
130
+ return params
131
+
132
+ return self._call_with_fallback(endpoint, style, "get", build)
133
+
134
+ def cancel_task(self, agent_card: dict[str, Any], task_id: str) -> CallResult:
135
+ """Ask the agent to cancel a task."""
136
+ endpoint, style = self._target(agent_card)
137
+
138
+ def build(dialect: str) -> dict[str, Any]:
139
+ return {"id": task_id}
140
+
141
+ return self._call_with_fallback(endpoint, style, "cancel", build)
142
+
143
+ # ------------------------------------------------------------------ internals
144
+
145
+ def _target(self, agent_card: dict[str, Any]) -> tuple[str, str]:
146
+ endpoint = cardmod.pick_endpoint(agent_card)
147
+ if endpoint is None:
148
+ raise A2AError("the agent card does not advertise an A2A endpoint")
149
+ try:
150
+ url = net.validate_endpoint(endpoint["url"], allow_private=self.allow_private)
151
+ except net.UrlError as exc:
152
+ raise A2AError(f"the agent endpoint is unusable: {exc}") from exc
153
+ return url, endpoint["style"]
154
+
155
+ def _call_with_fallback(
156
+ self,
157
+ endpoint: str,
158
+ primary: str,
159
+ action: str,
160
+ build: Callable[[str], dict[str, Any]],
161
+ ) -> CallResult:
162
+ styles = [primary, "v1" if primary == "v03" else "v03"]
163
+ last_error: A2AError | None = None
164
+ for index, style in enumerate(styles):
165
+ try:
166
+ return self._rpc(endpoint, METHODS[style][action], build(style), style)
167
+ except A2AError as exc:
168
+ last_error = exc
169
+ if not exc.retryable or index == len(styles) - 1:
170
+ raise
171
+ log.info("retrying %s with the other A2A dialect (%s)", action, styles[1])
172
+ assert last_error is not None
173
+ raise last_error
174
+
175
+ def _rpc(self, endpoint: str, method: str, params: dict[str, Any], style: str) -> CallResult:
176
+ request_id = str(uuid.uuid4())
177
+ body = {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}
178
+ payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
179
+
180
+ started = time.monotonic()
181
+ status, raw = self._post(endpoint, payload)
182
+ elapsed = int((time.monotonic() - started) * 1000)
183
+
184
+ try:
185
+ envelope = json.loads(raw)
186
+ except ValueError as exc:
187
+ raise A2AError(
188
+ "the agent returned a non-JSON response",
189
+ status=status,
190
+ payload=raw[:500].decode("utf-8", "replace"),
191
+ ) from exc
192
+
193
+ if not isinstance(envelope, dict):
194
+ raise A2AError("the agent returned a JSON value that is not an object", status=status)
195
+
196
+ error = envelope.get("error")
197
+ if isinstance(error, dict):
198
+ code = error.get("code")
199
+ message = str(error.get("message") or "JSON-RPC error")
200
+ retryable = (isinstance(code, int) and code in _RETRYABLE_CODES) or (
201
+ status in _RETRYABLE_STATUSES
202
+ )
203
+ raise A2AError(
204
+ f"{message} (code {code})" if code is not None else message,
205
+ code=code if isinstance(code, (int, str)) else None,
206
+ status=status,
207
+ payload=error.get("data"),
208
+ retryable=retryable,
209
+ )
210
+
211
+ if status >= 400:
212
+ raise A2AError(
213
+ f"the agent returned HTTP {status}",
214
+ status=status,
215
+ payload=envelope,
216
+ retryable=status in _RETRYABLE_STATUSES,
217
+ )
218
+
219
+ if "result" not in envelope:
220
+ raise A2AError("the agent response has neither result nor error", status=status)
221
+ return CallResult(
222
+ result=envelope["result"],
223
+ endpoint=endpoint,
224
+ style=style,
225
+ method=method,
226
+ request_id=request_id,
227
+ latency_ms=elapsed,
228
+ )
229
+
230
+ def _post(self, endpoint: str, payload: bytes) -> tuple[int, bytes]:
231
+ """POST the JSON-RPC envelope, pinning the address unless a test transport is set."""
232
+ headers = {
233
+ "content-type": "application/json",
234
+ "accept": "application/json",
235
+ "user-agent": self.user_agent,
236
+ }
237
+ url = endpoint
238
+ extensions: dict[str, Any] = {}
239
+
240
+ if self.transport is None:
241
+ host, port = net.host_and_port(endpoint)
242
+ try:
243
+ addresses = _resolve(host, port, allow_private=self.allow_private)
244
+ except net.BlockedAddress as exc:
245
+ raise A2AError(f"the agent endpoint is not reachable: {exc}") from exc
246
+ url, host_header, server_hostname = net.pin_target(endpoint, addresses[0])
247
+ headers.update(host_header)
248
+ extensions["sni_hostname"] = server_hostname
249
+
250
+ timeout = httpx.Timeout(self.timeout, connect=min(10.0, self.timeout))
251
+ try:
252
+ with httpx.Client(
253
+ timeout=timeout,
254
+ verify=True,
255
+ trust_env=False,
256
+ follow_redirects=False,
257
+ transport=self.transport,
258
+ ) as client:
259
+ request = client.build_request(
260
+ "POST",
261
+ url,
262
+ content=payload,
263
+ headers=headers,
264
+ extensions=extensions,
265
+ )
266
+ response = client.send(request)
267
+ return response.status_code, response.content
268
+ except httpx.TimeoutException as exc:
269
+ raise A2AError("the agent did not answer before the timeout") from exc
270
+ except httpx.HTTPError as exc:
271
+ raise A2AError(f"the agent could not be reached: {exc}") from exc
272
+
273
+
274
+ def _resolve(host: str, port: int, *, allow_private: bool) -> list[str]:
275
+ if allow_private:
276
+ import socket
277
+
278
+ try:
279
+ infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
280
+ except socket.gaierror as exc:
281
+ raise net.BlockedAddress(f"DNS resolution failed for {host}: {exc}") from exc
282
+ addresses = []
283
+ for info in infos:
284
+ candidate = info[4][0]
285
+ if candidate not in addresses:
286
+ addresses.append(candidate)
287
+ if not addresses:
288
+ raise net.BlockedAddress(f"DNS returned no addresses for {host}")
289
+ return addresses
290
+ return net.resolve_public_ips(host, port)
291
+
292
+
293
+ def _message(
294
+ style: str,
295
+ text: str,
296
+ message_id: str,
297
+ context_id: str | None,
298
+ task_id: str | None,
299
+ ) -> dict[str, Any]:
300
+ """Build a Message in the dialect the agent speaks."""
301
+ if style == "v1":
302
+ message: dict[str, Any] = {
303
+ "messageId": message_id,
304
+ "role": "ROLE_USER",
305
+ "parts": [{"text": text}],
306
+ }
307
+ else:
308
+ message = {
309
+ "messageId": message_id,
310
+ "role": "user",
311
+ "parts": [{"kind": "text", "text": text}],
312
+ }
313
+ if context_id:
314
+ message["contextId"] = context_id
315
+ if task_id:
316
+ message["taskId"] = task_id
317
+ return message
oacp/adp/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """ADP Core implementation: canonicalization, card checks, fetching, registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .card import RELAXED, STRICT, card_summary, interpret_result, normalize_state, validate_card
6
+ from .fetcher import FetchError, GuardedFetcher
7
+ from .jcs import card_hash, canonical_json
8
+ from .service import Registration, RegistryError, RegistryService
9
+
10
+ __all__ = [
11
+ "RELAXED",
12
+ "STRICT",
13
+ "FetchError",
14
+ "GuardedFetcher",
15
+ "Registration",
16
+ "RegistryError",
17
+ "RegistryService",
18
+ "card_hash",
19
+ "card_summary",
20
+ "canonical_json",
21
+ "interpret_result",
22
+ "normalize_state",
23
+ "validate_card",
24
+ ]