agentconnect-cli 0.1.0a1__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.
- agentconnect/__init__.py +3 -0
- agentconnect/a2a_adapter/__init__.py +6 -0
- agentconnect/a2a_adapter/adapter.py +565 -0
- agentconnect/a2a_adapter/cards.py +149 -0
- agentconnect/a2a_adapter/convert.py +157 -0
- agentconnect/a2a_adapter/facade.py +560 -0
- agentconnect/a2a_adapter/seen.py +49 -0
- agentconnect/a2a_adapter/serve.py +67 -0
- agentconnect/adapter.py +319 -0
- agentconnect/adapters.py +52 -0
- agentconnect/apiclient.py +51 -0
- agentconnect/backoff.py +42 -0
- agentconnect/callback.py +314 -0
- agentconnect/cli.py +2324 -0
- agentconnect/cli_json.py +470 -0
- agentconnect/cli_support.py +444 -0
- agentconnect/clock.py +161 -0
- agentconnect/config.py +1213 -0
- agentconnect/daemon/__init__.py +41 -0
- agentconnect/deliveries.py +295 -0
- agentconnect/directory_client.py +255 -0
- agentconnect/failpoints.py +99 -0
- agentconnect/identity.py +111 -0
- agentconnect/isolation.py +345 -0
- agentconnect/keystore.py +279 -0
- agentconnect/limits.py +457 -0
- agentconnect/log.py +143 -0
- agentconnect/mcp/__init__.py +30 -0
- agentconnect/mcp/backend.py +405 -0
- agentconnect/mcp/server.py +382 -0
- agentconnect/mcp/session.py +373 -0
- agentconnect/mcp/tools.py +583 -0
- agentconnect/ownerfile.py +238 -0
- agentconnect/payloads.py +109 -0
- agentconnect/policy.py +1066 -0
- agentconnect/provision.py +239 -0
- agentconnect/py.typed +0 -0
- agentconnect/relay.py +405 -0
- agentconnect/runtime/__init__.py +46 -0
- agentconnect/runtime/agent.py +225 -0
- agentconnect/runtime/api.py +2043 -0
- agentconnect/runtime/engine.py +5359 -0
- agentconnect/runtime/errors.py +47 -0
- agentconnect/runtime/floor.py +649 -0
- agentconnect/runtime/groups.py +1351 -0
- agentconnect/runtime/knocks.py +1422 -0
- agentconnect/runtime/ledger.py +94 -0
- agentconnect/runtime/listing.py +1307 -0
- agentconnect/runtime/registry.py +53 -0
- agentconnect/runtime/runtime.py +158 -0
- agentconnect/runtime/sas.py +1002 -0
- agentconnect/sas_emoji.py +83 -0
- agentconnect/schemas.py +381 -0
- agentconnect/shapes.py +56 -0
- agentconnect/sockets.py +86 -0
- agentconnect/sse.py +234 -0
- agentconnect/store.py +3282 -0
- agentconnect/tasks.py +35 -0
- agentconnect/tokens.py +63 -0
- agentconnect/webhook.py +199 -0
- agentconnect_cli-0.1.0a1.dist-info/METADATA +103 -0
- agentconnect_cli-0.1.0a1.dist-info/RECORD +64 -0
- agentconnect_cli-0.1.0a1.dist-info/WHEEL +4 -0
- agentconnect_cli-0.1.0a1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Agent Cards: signature checks and the cards the facade serves.
|
|
2
|
+
|
|
3
|
+
Signatures (A2A v1.0 ``AgentCard.signatures``, JWS over the RFC 8785 form of
|
|
4
|
+
the card) are checked against keys the user pinned in a JWKS file
|
|
5
|
+
(``a2a.card_keys_file``). A key is never fetched from a URL a card names
|
|
6
|
+
(``jku``): for a peer's card that URL is peer-controlled.
|
|
7
|
+
|
|
8
|
+
* ``verified``: a signature checks out with a pinned key.
|
|
9
|
+
* ``unsigned``: the card has no signature.
|
|
10
|
+
* ``unverified``: signed, but with no pinned key for it.
|
|
11
|
+
* ``invalid``: signed with a pinned key, and no signature checks out. An
|
|
12
|
+
invalid card is not used.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from a2a.types import (
|
|
22
|
+
AgentCapabilities,
|
|
23
|
+
AgentCard,
|
|
24
|
+
AgentInterface,
|
|
25
|
+
AgentSkill,
|
|
26
|
+
HTTPAuthSecurityScheme,
|
|
27
|
+
SecurityRequirement,
|
|
28
|
+
SecurityScheme,
|
|
29
|
+
StringList,
|
|
30
|
+
)
|
|
31
|
+
from a2a.utils.signing import SignatureVerificationError, create_signature_verifier
|
|
32
|
+
from jwt import PyJWK
|
|
33
|
+
from jwt.exceptions import PyJWKError, PyJWTError
|
|
34
|
+
from jwt.utils import base64url_decode
|
|
35
|
+
|
|
36
|
+
# Asymmetric only: a symmetric (HS*) key in a JWKS would let anyone holding
|
|
37
|
+
# it forge cards, and accepting HS* invites algorithm confusion.
|
|
38
|
+
ALGORITHMS = ["ES256", "ES384", "ES512", "RS256", "RS384", "RS512", "PS256", "PS384", "EdDSA"]
|
|
39
|
+
BEARER = "agentconnectBearer"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_jwks(path: Path | None) -> dict[str, PyJWK]:
|
|
43
|
+
"""The pinned card-signing keys, by ``kid``."""
|
|
44
|
+
if path is None:
|
|
45
|
+
return {}
|
|
46
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
47
|
+
keys: dict[str, PyJWK] = {}
|
|
48
|
+
for jwk in data.get("keys", []) if isinstance(data, dict) else []:
|
|
49
|
+
if not isinstance(jwk, dict) or not isinstance(jwk.get("kid"), str):
|
|
50
|
+
continue
|
|
51
|
+
if jwk.get("kty") == "oct":
|
|
52
|
+
continue # symmetric keys are refused, see ALGORITHMS
|
|
53
|
+
try:
|
|
54
|
+
keys[jwk["kid"]] = PyJWK(jwk)
|
|
55
|
+
except PyJWTError:
|
|
56
|
+
continue
|
|
57
|
+
return keys
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _kids(card: AgentCard) -> list[str | None]:
|
|
61
|
+
kids: list[str | None] = []
|
|
62
|
+
for sig in card.signatures:
|
|
63
|
+
try:
|
|
64
|
+
header = json.loads(base64url_decode(sig.protected.encode("ascii")))
|
|
65
|
+
except (ValueError, UnicodeError):
|
|
66
|
+
kids.append(None)
|
|
67
|
+
continue
|
|
68
|
+
kid = header.get("kid") if isinstance(header, dict) else None
|
|
69
|
+
kids.append(kid if isinstance(kid, str) else None)
|
|
70
|
+
return kids
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def signature_status(card: AgentCard, keys: Mapping[str, PyJWK]) -> str:
|
|
74
|
+
"""``verified``, ``unsigned``, ``unverified`` or ``invalid`` (above)."""
|
|
75
|
+
if len(card.signatures) == 0:
|
|
76
|
+
return "unsigned"
|
|
77
|
+
if not any(kid in keys for kid in _kids(card) if kid is not None):
|
|
78
|
+
return "unverified"
|
|
79
|
+
|
|
80
|
+
def key_for(kid: str | None, _jku: str | None) -> PyJWK:
|
|
81
|
+
if kid is None or kid not in keys:
|
|
82
|
+
msg = "no pinned key"
|
|
83
|
+
raise PyJWKError(msg)
|
|
84
|
+
return keys[kid]
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
create_signature_verifier(key_for, ALGORITHMS)(card)
|
|
88
|
+
except SignatureVerificationError:
|
|
89
|
+
return "invalid"
|
|
90
|
+
return "verified"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _bearer(card: AgentCard) -> None:
|
|
94
|
+
card.security_schemes[BEARER].CopyFrom(
|
|
95
|
+
SecurityScheme(
|
|
96
|
+
http_auth_security_scheme=HTTPAuthSecurityScheme(
|
|
97
|
+
scheme="Bearer", description="The facade token (a2a-facade-token)."
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
card.security_requirements.append(SecurityRequirement(schemes={BEARER: StringList(list=[])}))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def facade_card(
|
|
105
|
+
url: str,
|
|
106
|
+
name: str,
|
|
107
|
+
description: str,
|
|
108
|
+
skills: list[AgentSkill],
|
|
109
|
+
source: AgentCard | None = None,
|
|
110
|
+
) -> AgentCard:
|
|
111
|
+
"""A card for the local facade at ``url``: ``source`` (a peer's card)
|
|
112
|
+
mirrored with the facade's interface and auth, or a composite one."""
|
|
113
|
+
card = AgentCard()
|
|
114
|
+
if source is not None:
|
|
115
|
+
card.CopyFrom(source)
|
|
116
|
+
# The peer's interfaces, auth and signatures don't apply here.
|
|
117
|
+
card.ClearField("supported_interfaces")
|
|
118
|
+
card.ClearField("security_schemes")
|
|
119
|
+
card.ClearField("security_requirements")
|
|
120
|
+
card.ClearField("signatures")
|
|
121
|
+
else:
|
|
122
|
+
card.name = name
|
|
123
|
+
card.description = description
|
|
124
|
+
card.version = "0"
|
|
125
|
+
card.default_input_modes.extend(["text/plain", "application/json"])
|
|
126
|
+
card.default_output_modes.extend(["text/plain", "application/json"])
|
|
127
|
+
card.skills.extend(skills)
|
|
128
|
+
card.supported_interfaces.append(
|
|
129
|
+
AgentInterface(url=url, protocol_binding="JSONRPC", protocol_version="1.0")
|
|
130
|
+
)
|
|
131
|
+
card.capabilities.CopyFrom(AgentCapabilities(streaming=True, push_notifications=True))
|
|
132
|
+
_bearer(card)
|
|
133
|
+
return card
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def composite_skills(cards: Mapping[str, AgentCard], names: Mapping[str, str]) -> list[AgentSkill]:
|
|
137
|
+
"""Every member's skills, ids prefixed by the member so they stay unique
|
|
138
|
+
and say whom to address (``metadata.to``)."""
|
|
139
|
+
out: list[AgentSkill] = []
|
|
140
|
+
for identity in sorted(cards):
|
|
141
|
+
for skill in cards[identity].skills:
|
|
142
|
+
s = AgentSkill()
|
|
143
|
+
s.CopyFrom(skill)
|
|
144
|
+
s.id = f"{identity}/{skill.id}"
|
|
145
|
+
label = names.get(identity) or identity
|
|
146
|
+
s.name = f"{label}: {skill.name}"
|
|
147
|
+
s.tags.append(f"member:{identity}")
|
|
148
|
+
out.append(s)
|
|
149
|
+
return out
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Conversions between inner-message bodies (A2A v1.0 JSON, SPEC §7.2) and
|
|
2
|
+
the ``a2a-sdk`` protobuf types.
|
|
3
|
+
|
|
4
|
+
Bodies are peer-controlled. Parsing ignores unknown fields and never raises:
|
|
5
|
+
a body that isn't a valid A2A ``Message`` becomes a message carrying its
|
|
6
|
+
``text`` (or the whole body as a ``data`` part), so a peer can't crash the
|
|
7
|
+
adapter with a malformed body.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from a2a.types import (
|
|
15
|
+
AgentCard,
|
|
16
|
+
CancelTaskRequest,
|
|
17
|
+
Message,
|
|
18
|
+
Part,
|
|
19
|
+
Role,
|
|
20
|
+
StreamResponse,
|
|
21
|
+
TaskArtifactUpdateEvent,
|
|
22
|
+
TaskState,
|
|
23
|
+
TaskStatusUpdateEvent,
|
|
24
|
+
)
|
|
25
|
+
from google.protobuf.json_format import MessageToDict, ParseDict, ParseError
|
|
26
|
+
from google.protobuf.message import Message as ProtoMessage
|
|
27
|
+
|
|
28
|
+
from agentconnect.limits import part_kind
|
|
29
|
+
|
|
30
|
+
TERMINAL = frozenset(
|
|
31
|
+
{
|
|
32
|
+
TaskState.TASK_STATE_COMPLETED,
|
|
33
|
+
TaskState.TASK_STATE_FAILED,
|
|
34
|
+
TaskState.TASK_STATE_CANCELED,
|
|
35
|
+
TaskState.TASK_STATE_REJECTED,
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
INTERRUPTED = frozenset({TaskState.TASK_STATE_INPUT_REQUIRED, TaskState.TASK_STATE_AUTH_REQUIRED})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def to_json(msg: ProtoMessage) -> dict[str, Any]:
|
|
42
|
+
"""The A2A v1.0 JSON form (camelCase) of a protobuf message."""
|
|
43
|
+
return MessageToDict(msg)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parse[T: ProtoMessage](body: Any, into: T) -> T | None:
|
|
47
|
+
if not isinstance(body, dict):
|
|
48
|
+
return None
|
|
49
|
+
try:
|
|
50
|
+
ParseDict(body, into, ignore_unknown_fields=True)
|
|
51
|
+
except (ParseError, TypeError, ValueError, RecursionError):
|
|
52
|
+
return None
|
|
53
|
+
return into
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_message(body: dict[str, Any]) -> Message:
|
|
57
|
+
"""A peer's ``a2a.message`` body as an A2A ``Message`` (never raises).
|
|
58
|
+
Malformed parts are left out (agentconnect.limits.part_kind): the daemon
|
|
59
|
+
has dropped any message carrying one, and this keeps it so here too."""
|
|
60
|
+
parts = body.get("parts")
|
|
61
|
+
if isinstance(parts, list):
|
|
62
|
+
msg = _parse({**body, "parts": [p for p in parts if part_kind(p) != "invalid"]}, Message())
|
|
63
|
+
return Message() if msg is None else msg
|
|
64
|
+
msg = _parse(body, Message())
|
|
65
|
+
if msg is not None and len(msg.parts) > 0:
|
|
66
|
+
return msg
|
|
67
|
+
out = Message() if msg is None else msg
|
|
68
|
+
text = body.get("text")
|
|
69
|
+
if isinstance(text, str):
|
|
70
|
+
out.parts.append(Part(text=text))
|
|
71
|
+
return out
|
|
72
|
+
fallback = _parse({"parts": [{"data": body}]}, Message())
|
|
73
|
+
if fallback is not None:
|
|
74
|
+
out.parts.extend(fallback.parts)
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_status(body: dict[str, Any]) -> TaskStatusUpdateEvent | None:
|
|
79
|
+
return _parse(body, TaskStatusUpdateEvent())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_artifact(body: dict[str, Any]) -> TaskArtifactUpdateEvent | None:
|
|
83
|
+
return _parse(body, TaskArtifactUpdateEvent())
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_card(body: dict[str, Any]) -> AgentCard | None:
|
|
87
|
+
return _parse(body, AgentCard())
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def stream_to_inner(resp: StreamResponse, group_id: str) -> list[tuple[str, dict[str, Any]]]:
|
|
91
|
+
"""What one event from the local agent becomes in the group: a list of
|
|
92
|
+
``(kind, body)``. ``contextId`` is always the group (SPEC §7.2)."""
|
|
93
|
+
out: list[tuple[str, dict[str, Any]]] = []
|
|
94
|
+
payload = resp.WhichOneof("payload")
|
|
95
|
+
if payload == "task":
|
|
96
|
+
task = resp.task
|
|
97
|
+
status = TaskStatusUpdateEvent(task_id=task.id, context_id=group_id)
|
|
98
|
+
status.status.CopyFrom(task.status)
|
|
99
|
+
out.append(("a2a.task_status", to_json(status)))
|
|
100
|
+
for artifact in task.artifacts:
|
|
101
|
+
event = TaskArtifactUpdateEvent(task_id=task.id, context_id=group_id)
|
|
102
|
+
event.artifact.CopyFrom(artifact)
|
|
103
|
+
out.append(("a2a.task_artifact", to_json(event)))
|
|
104
|
+
elif payload == "status_update":
|
|
105
|
+
event = TaskStatusUpdateEvent()
|
|
106
|
+
event.CopyFrom(resp.status_update)
|
|
107
|
+
event.context_id = group_id
|
|
108
|
+
out.append(("a2a.task_status", to_json(event)))
|
|
109
|
+
elif payload == "artifact_update":
|
|
110
|
+
event = TaskArtifactUpdateEvent()
|
|
111
|
+
event.CopyFrom(resp.artifact_update)
|
|
112
|
+
event.context_id = group_id
|
|
113
|
+
out.append(("a2a.task_artifact", to_json(event)))
|
|
114
|
+
elif payload == "message":
|
|
115
|
+
msg = Message()
|
|
116
|
+
msg.CopyFrom(resp.message)
|
|
117
|
+
msg.context_id = group_id
|
|
118
|
+
out.append(("a2a.message", to_json(msg)))
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def task_id_of(resp: StreamResponse) -> str:
|
|
123
|
+
payload = resp.WhichOneof("payload")
|
|
124
|
+
if payload == "task":
|
|
125
|
+
return resp.task.id
|
|
126
|
+
if payload == "status_update":
|
|
127
|
+
return resp.status_update.task_id
|
|
128
|
+
if payload == "artifact_update":
|
|
129
|
+
return resp.artifact_update.task_id
|
|
130
|
+
return ""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def is_final(resp: StreamResponse) -> bool:
|
|
134
|
+
"""Whether the stream has nothing more for this call."""
|
|
135
|
+
payload = resp.WhichOneof("payload")
|
|
136
|
+
if payload == "message":
|
|
137
|
+
return True
|
|
138
|
+
if payload == "task":
|
|
139
|
+
return resp.task.status.state in TERMINAL | INTERRUPTED
|
|
140
|
+
if payload == "status_update":
|
|
141
|
+
return resp.status_update.status.state in TERMINAL | INTERRUPTED
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def cancel_body(task_id: str) -> dict[str, Any]:
|
|
146
|
+
"""The ``a2a.task_cancel`` body (an A2A ``CancelTaskRequest``, SPEC 7.2)
|
|
147
|
+
a requester sends to cancel a bridged task."""
|
|
148
|
+
return to_json(CancelTaskRequest(id=task_id))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def parse_cancel(body: dict[str, Any]) -> CancelTaskRequest | None:
|
|
152
|
+
request = _parse(body, CancelTaskRequest())
|
|
153
|
+
return request if request is not None and request.id else None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def agent_text(text: str, group_id: str) -> Message:
|
|
157
|
+
return Message(role=Role.ROLE_AGENT, parts=[Part(text=text)], context_id=group_id)
|