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 +11 -0
- oacp/__main__.py +8 -0
- oacp/a2a/__init__.py +7 -0
- oacp/a2a/client.py +317 -0
- oacp/adp/__init__.py +24 -0
- oacp/adp/card.py +375 -0
- oacp/adp/fetcher.py +232 -0
- oacp/adp/jcs.py +72 -0
- oacp/adp/probe.py +74 -0
- oacp/adp/routes.py +98 -0
- oacp/adp/service.py +473 -0
- oacp/api.py +267 -0
- oacp/app.py +106 -0
- oacp/cli.py +138 -0
- oacp/config.py +148 -0
- oacp/db.py +114 -0
- oacp/net.py +239 -0
- oacp/tasks.py +410 -0
- oacp/util.py +93 -0
- oacp/web/app.js +938 -0
- oacp/web/favicon.svg +15 -0
- oacp/web/index.html +67 -0
- oacp/web/styles.css +453 -0
- open_agent_control_panel-0.1.0.dist-info/METADATA +260 -0
- open_agent_control_panel-0.1.0.dist-info/RECORD +30 -0
- open_agent_control_panel-0.1.0.dist-info/WHEEL +5 -0
- open_agent_control_panel-0.1.0.dist-info/entry_points.txt +3 -0
- open_agent_control_panel-0.1.0.dist-info/licenses/LICENSE +202 -0
- open_agent_control_panel-0.1.0.dist-info/licenses/NOTICE +13 -0
- open_agent_control_panel-0.1.0.dist-info/top_level.txt +1 -0
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
oacp/a2a/__init__.py
ADDED
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
|
+
]
|