agent-protocols 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,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-protocols
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
5
+ Author: LDCLabs
6
+ License: MIT
7
+ Keywords: agent,protocol,ed25519,sdk
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: base58<3,>=2.1
11
+ Requires-Dist: cryptography>=42
12
+ Requires-Dist: rfc8785<0.2,>=0.1.4
13
+ Provides-Extra: http
14
+ Requires-Dist: requests<3,>=2.31; extra == "http"
15
+
16
+ # agent-protocols Python SDK
17
+
18
+ Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse protocols.
19
+
20
+ ## Modules
21
+
22
+ - `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event IDs, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
23
+ - `agent_protocols.profile`: `profile.update` payload helpers, validation, materialization.
24
+ - `agent_protocols.discourse`: ADP event constants, room helpers, room-path checks, permission and state helpers.
25
+ - `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
26
+
27
+ ## Example
28
+
29
+ ```python
30
+ from agent_protocols import AgentSigner, materialize_profile, profile_update_event, unix_time_millis
31
+
32
+ signer = AgentSigner.generate()
33
+ event = profile_update_event(
34
+ signer.agent_id(),
35
+ unix_time_millis(),
36
+ "n_01J8Z6",
37
+ {"agent_id": signer.agent_id(), "name": "ResearchAgent-v3"},
38
+ )
39
+ envelope = signer.sign_event(event)
40
+ profile = materialize_profile(envelope)
41
+ ```
@@ -0,0 +1,26 @@
1
+ # agent-protocols Python SDK
2
+
3
+ Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse protocols.
4
+
5
+ ## Modules
6
+
7
+ - `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event IDs, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
8
+ - `agent_protocols.profile`: `profile.update` payload helpers, validation, materialization.
9
+ - `agent_protocols.discourse`: ADP event constants, room helpers, room-path checks, permission and state helpers.
10
+ - `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
11
+
12
+ ## Example
13
+
14
+ ```python
15
+ from agent_protocols import AgentSigner, materialize_profile, profile_update_event, unix_time_millis
16
+
17
+ signer = AgentSigner.generate()
18
+ event = profile_update_event(
19
+ signer.agent_id(),
20
+ unix_time_millis(),
21
+ "n_01J8Z6",
22
+ {"agent_id": signer.agent_id(), "name": "ResearchAgent-v3"},
23
+ )
24
+ envelope = signer.sign_event(event)
25
+ profile = materialize_profile(envelope)
26
+ ```
@@ -0,0 +1,20 @@
1
+ [project]
2
+ name = "agent-protocols"
3
+ version = "0.1.0"
4
+ description = "Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "LDCLabs" }]
9
+ keywords = ["agent", "protocol", "ed25519", "sdk"]
10
+ dependencies = ["base58>=2.1,<3", "cryptography>=42", "rfc8785>=0.1.4,<0.2"]
11
+
12
+ [project.optional-dependencies]
13
+ http = ["requests>=2.31,<3"]
14
+
15
+ [build-system]
16
+ requires = ["setuptools>=68"]
17
+ build-backend = "setuptools.build_meta"
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,61 @@
1
+ from .errors import AgentProtocolError
2
+ from .identity import (
3
+ AGENT_ID_PREFIX,
4
+ EVENT_ID_PREFIX,
5
+ AgentSigner,
6
+ MemoryNonceStore,
7
+ RequestBinding,
8
+ agent_id_from_public_key,
9
+ canonical_event_bytes,
10
+ create_event,
11
+ create_request_jwt_claims,
12
+ event_id,
13
+ public_key_bytes,
14
+ random_nonce,
15
+ sign_event,
16
+ unix_time_millis,
17
+ unix_time_secs,
18
+ validate_agent_id,
19
+ verify_envelope,
20
+ verify_event_id,
21
+ verify_live_envelope,
22
+ verify_request_jwt,
23
+ verify_request_jwt_live,
24
+ verify_signature,
25
+ verify_timestamp,
26
+ with_room_id,
27
+ )
28
+ from .profile import PROFILE_PROTOCOL, PROFILE_UPDATE, materialize_profile, profile_update_event, validate_profile_update
29
+
30
+ __all__ = [
31
+ "AGENT_ID_PREFIX",
32
+ "EVENT_ID_PREFIX",
33
+ "PROFILE_PROTOCOL",
34
+ "PROFILE_UPDATE",
35
+ "AgentProtocolError",
36
+ "AgentSigner",
37
+ "MemoryNonceStore",
38
+ "RequestBinding",
39
+ "agent_id_from_public_key",
40
+ "canonical_event_bytes",
41
+ "create_event",
42
+ "create_request_jwt_claims",
43
+ "event_id",
44
+ "materialize_profile",
45
+ "profile_update_event",
46
+ "public_key_bytes",
47
+ "random_nonce",
48
+ "sign_event",
49
+ "unix_time_millis",
50
+ "unix_time_secs",
51
+ "validate_agent_id",
52
+ "validate_profile_update",
53
+ "verify_envelope",
54
+ "verify_event_id",
55
+ "verify_live_envelope",
56
+ "verify_request_jwt",
57
+ "verify_request_jwt_live",
58
+ "verify_signature",
59
+ "verify_timestamp",
60
+ "with_room_id",
61
+ ]
@@ -0,0 +1,190 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal, TypedDict
4
+
5
+ from .errors import AgentProtocolError
6
+ from .identity import AgentId, Envelope, Event, create_event, verify_envelope, with_room_id
7
+
8
+ DISCOURSE_PROTOCOL = "agent-discourse/1.0"
9
+ LEGACY_DISCOURSE_PROTOCOL = "adp/1.0"
10
+
11
+ ROOM_CREATE = "room.create"
12
+ ROOM_JOIN = "room.join"
13
+ ROOM_LEAVE = "room.leave"
14
+ ROOM_MEMBER_ROLE_UPDATE = "room.member.role.update"
15
+ ROOM_INVITE = "room.invite"
16
+ ROOM_INVITE_REVOKE = "room.invite.revoke"
17
+ ROOM_CLOSE = "room.close"
18
+ ROOM_CANCEL = "room.cancel"
19
+ MESSAGE_TEXT = "message.text"
20
+ MESSAGE_MARKDOWN = "message.markdown"
21
+ MESSAGE_DATA = "message.data"
22
+ REACTION_CREATE = "reaction.create"
23
+ PROPOSAL_CREATE = "proposal.create"
24
+ POLL_CREATE = "poll.create"
25
+ POLL_VOTE = "poll.vote"
26
+ RESOLUTION_CREATE = "resolution.create"
27
+ SOURCE_ADD = "source.add"
28
+ TURN_UPDATE = "turn.update"
29
+ QUESTION_GENERATE = "question.generate"
30
+ DISCOURSE_STEER = "discourse.steer"
31
+ MINDMAP_UPDATE = "mindmap.update"
32
+ REPORT_GENERATE = "report.generate"
33
+ SESSION_AUTH = "session.auth"
34
+
35
+ KNOWN_EVENT_TYPES = {
36
+ ROOM_CREATE,
37
+ ROOM_JOIN,
38
+ ROOM_LEAVE,
39
+ ROOM_MEMBER_ROLE_UPDATE,
40
+ ROOM_INVITE,
41
+ ROOM_INVITE_REVOKE,
42
+ ROOM_CLOSE,
43
+ ROOM_CANCEL,
44
+ MESSAGE_TEXT,
45
+ MESSAGE_MARKDOWN,
46
+ MESSAGE_DATA,
47
+ REACTION_CREATE,
48
+ PROPOSAL_CREATE,
49
+ POLL_CREATE,
50
+ POLL_VOTE,
51
+ RESOLUTION_CREATE,
52
+ SOURCE_ADD,
53
+ TURN_UPDATE,
54
+ QUESTION_GENERATE,
55
+ DISCOURSE_STEER,
56
+ MINDMAP_UPDATE,
57
+ REPORT_GENERATE,
58
+ SESSION_AUTH,
59
+ }
60
+
61
+ RoomState = Literal["scheduled", "active", "ended", "cancelled"]
62
+ Role = Literal["moderator", "expert", "participant", "observer"]
63
+
64
+
65
+ class PermissionContext(TypedDict, total=False):
66
+ role: Role
67
+ is_creator: bool
68
+ moderator_authorized: bool
69
+ expert_policy_allowed: bool
70
+ participant_policy_allowed: bool
71
+ observer_steering_allowed: bool
72
+ observer_poll_vote_allowed: bool
73
+
74
+
75
+ def room_create_event(actor: AgentId, created_at: int, nonce: str, payload: dict[str, Any]) -> Event:
76
+ return create_event(DISCOURSE_PROTOCOL, ROOM_CREATE, actor, created_at, nonce, payload)
77
+
78
+
79
+ def discourse_event(event_type: str, actor: AgentId, created_at: int, nonce: str, room_id: str, payload: Any) -> Event:
80
+ return with_room_id(create_event(DISCOURSE_PROTOCOL, event_type, actor, created_at, nonce, payload), room_id)
81
+
82
+
83
+ def validate_discourse_envelope(envelope: Envelope, accept_legacy_protocol: bool = False) -> None:
84
+ verify_envelope(envelope)
85
+ event = envelope["event"]
86
+ protocol = event["protocol"]
87
+ if protocol != DISCOURSE_PROTOCOL and not (accept_legacy_protocol and protocol == LEGACY_DISCOURSE_PROTOCOL):
88
+ raise AgentProtocolError("invalid_event_protocol", f"expected {DISCOURSE_PROTOCOL}, got {protocol}")
89
+ if event_requires_room_id(event["type"]) and "room_id" not in event:
90
+ raise AgentProtocolError("missing_room_id", "event requires a room_id")
91
+
92
+
93
+ def validate_room_path(envelope: Envelope, path_room_id: str) -> None:
94
+ actual = envelope["event"].get("room_id")
95
+ if actual is None:
96
+ raise AgentProtocolError("missing_room_id", "event requires a room_id")
97
+ if actual != path_room_id:
98
+ raise AgentProtocolError("room_id_mismatch", f"expected {path_room_id}, got {actual}")
99
+
100
+
101
+ def event_requires_room_id(event_type: str) -> bool:
102
+ return event_type != ROOM_CREATE
103
+
104
+
105
+ def can_submit_event(event_type: str, context: PermissionContext) -> bool:
106
+ if event_type in {ROOM_CREATE, ROOM_JOIN}:
107
+ return True
108
+ if context.get("is_creator"):
109
+ return event_type in KNOWN_EVENT_TYPES
110
+
111
+ role = context.get("role")
112
+ if role == "moderator":
113
+ return _moderator_can_submit(event_type, context.get("moderator_authorized", False))
114
+ if role == "expert":
115
+ return _speaker_can_submit(event_type, context.get("expert_policy_allowed", False))
116
+ if role == "participant":
117
+ return _speaker_can_submit(event_type, context.get("participant_policy_allowed", False))
118
+ if role == "observer":
119
+ return _observer_can_submit(event_type, context)
120
+ return False
121
+
122
+
123
+ def can_write_in_state(event_type: str, state: RoomState, *, post_end_reaction_allowed: bool = False) -> bool:
124
+ if state == "scheduled":
125
+ return event_type in {ROOM_JOIN, ROOM_INVITE, ROOM_INVITE_REVOKE, ROOM_CANCEL}
126
+ if state == "active":
127
+ return event_type not in {ROOM_CREATE, ROOM_CANCEL}
128
+ if state == "ended":
129
+ return post_end_reaction_allowed and event_type == REACTION_CREATE
130
+ if state == "cancelled":
131
+ return False
132
+ return False
133
+
134
+
135
+ def can_accept_room_write(event_type: str, state: RoomState, context: PermissionContext, *, post_end_reaction_allowed: bool = False) -> bool:
136
+ return can_submit_event(event_type, context) and can_write_in_state(event_type, state, post_end_reaction_allowed=post_end_reaction_allowed)
137
+
138
+
139
+ def validate_room_write(event_type: str, state: RoomState, context: PermissionContext, *, post_end_reaction_allowed: bool = False) -> None:
140
+ if not can_accept_room_write(event_type, state, context, post_end_reaction_allowed=post_end_reaction_allowed):
141
+ raise AgentProtocolError("permission_denied", "actor lacks permission or state is not writable")
142
+
143
+
144
+ def _moderator_can_submit(event_type: str, moderator_authorized: bool) -> bool:
145
+ allowed = {
146
+ ROOM_INVITE,
147
+ ROOM_INVITE_REVOKE,
148
+ ROOM_CLOSE,
149
+ MESSAGE_TEXT,
150
+ MESSAGE_MARKDOWN,
151
+ MESSAGE_DATA,
152
+ SOURCE_ADD,
153
+ TURN_UPDATE,
154
+ QUESTION_GENERATE,
155
+ DISCOURSE_STEER,
156
+ MINDMAP_UPDATE,
157
+ REPORT_GENERATE,
158
+ PROPOSAL_CREATE,
159
+ POLL_CREATE,
160
+ POLL_VOTE,
161
+ RESOLUTION_CREATE,
162
+ REACTION_CREATE,
163
+ ROOM_LEAVE,
164
+ }
165
+ return event_type in allowed or (moderator_authorized and event_type in {ROOM_MEMBER_ROLE_UPDATE, ROOM_CANCEL})
166
+
167
+
168
+ def _speaker_can_submit(event_type: str, policy_allowed: bool) -> bool:
169
+ allowed = {
170
+ MESSAGE_TEXT,
171
+ MESSAGE_MARKDOWN,
172
+ MESSAGE_DATA,
173
+ SOURCE_ADD,
174
+ DISCOURSE_STEER,
175
+ PROPOSAL_CREATE,
176
+ POLL_CREATE,
177
+ POLL_VOTE,
178
+ REACTION_CREATE,
179
+ ROOM_LEAVE,
180
+ }
181
+ policy_events = {QUESTION_GENERATE, MINDMAP_UPDATE, REPORT_GENERATE, RESOLUTION_CREATE}
182
+ return event_type in allowed or (policy_allowed and event_type in policy_events)
183
+
184
+
185
+ def _observer_can_submit(event_type: str, context: PermissionContext) -> bool:
186
+ return (
187
+ event_type in {REACTION_CREATE, ROOM_LEAVE}
188
+ or (context.get("observer_steering_allowed", False) and event_type == DISCOURSE_STEER)
189
+ or (context.get("observer_poll_vote_allowed", False) and event_type == POLL_VOTE)
190
+ )
@@ -0,0 +1,4 @@
1
+ class AgentProtocolError(ValueError):
2
+ def __init__(self, code: str, message: str):
3
+ super().__init__(message)
4
+ self.code = code
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ try:
6
+ import requests
7
+ except ImportError: # pragma: no cover
8
+ requests = None # type: ignore[assignment]
9
+
10
+ from .identity import AgentId, Envelope
11
+
12
+
13
+ class ProfileClient:
14
+ def __init__(self, base_url: str, session: Any | None = None):
15
+ self.base_url = base_url.rstrip("/")
16
+ self.session = session or _requests_session()
17
+
18
+ def get_profile(self, agent_id: AgentId) -> dict[str, Any]:
19
+ return self._get(f"/v1/profiles/{agent_id}")
20
+
21
+ def submit_profile_update(self, envelope: Envelope) -> dict[str, Any]:
22
+ return self._post("/v1/profiles", envelope)
23
+
24
+ def _get(self, path: str) -> dict[str, Any]:
25
+ response = self.session.get(self.base_url + path)
26
+ response.raise_for_status()
27
+ return response.json()
28
+
29
+ def _post(self, path: str, body: Any) -> dict[str, Any]:
30
+ response = self.session.post(self.base_url + path, json=body)
31
+ response.raise_for_status()
32
+ return response.json()
33
+
34
+
35
+ class DiscourseClient:
36
+ def __init__(self, base_url: str, session: Any | None = None):
37
+ self.base_url = base_url.rstrip("/")
38
+ self.session = session or _requests_session()
39
+
40
+ def protocol(self) -> dict[str, Any]:
41
+ return self._get("/v1/protocol")
42
+
43
+ def create_room(self, envelope: Envelope) -> dict[str, Any]:
44
+ return self._post("/v1/rooms", envelope)
45
+
46
+ def join_room(self, room_id: str, envelope: Envelope) -> dict[str, Any]:
47
+ return self._post(f"/v1/rooms/{room_id}/join", envelope)
48
+
49
+ def leave_room(self, room_id: str, envelope: Envelope) -> dict[str, Any]:
50
+ return self._post(f"/v1/rooms/{room_id}/leave", envelope)
51
+
52
+ def submit_event(self, room_id: str, envelope: Envelope) -> dict[str, Any]:
53
+ return self._post(f"/v1/rooms/{room_id}/events", envelope)
54
+
55
+ def events(self, room_id: str) -> list[dict[str, Any]]:
56
+ return self._get(f"/v1/rooms/{room_id}/events")
57
+
58
+ def archive(self, room_id: str) -> dict[str, Any]:
59
+ return self._get(f"/v1/rooms/{room_id}/archive")
60
+
61
+ def _get(self, path: str) -> Any:
62
+ response = self.session.get(self.base_url + path)
63
+ response.raise_for_status()
64
+ return response.json()
65
+
66
+ def _post(self, path: str, body: Any) -> Any:
67
+ response = self.session.post(self.base_url + path, json=body)
68
+ response.raise_for_status()
69
+ return response.json()
70
+
71
+
72
+ def _requests_session() -> Any:
73
+ if requests is None:
74
+ raise RuntimeError("Install agent-protocols[http] to use HTTP clients")
75
+ return requests.Session()
@@ -0,0 +1,274 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import time
8
+ from dataclasses import dataclass
9
+ from typing import Any, MutableMapping, Protocol
10
+
11
+ import base58
12
+ import rfc8785
13
+ from cryptography.exceptions import InvalidSignature
14
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
15
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
16
+
17
+ from .errors import AgentProtocolError
18
+
19
+ AGENT_ID_PREFIX = "did:agent:"
20
+ EVENT_ID_PREFIX = "evt_"
21
+ DEFAULT_LIVE_WRITE_WINDOW_MS = 300_000
22
+ DEFAULT_REQUEST_JWT_TTL_SECS = 300
23
+ _REQUEST_AUTH_REPLAY_SCOPE = "agent-identity/request-auth"
24
+
25
+ Event = dict[str, Any]
26
+ Envelope = dict[str, Any]
27
+ AgentId = str
28
+
29
+
30
+ def agent_id_from_public_key(public_key: bytes) -> AgentId:
31
+ if len(public_key) != 32:
32
+ raise AgentProtocolError("invalid_public_key", f"public key must be 32 bytes, got {len(public_key)}")
33
+ return f"{AGENT_ID_PREFIX}{_base58btc_encode(public_key)}"
34
+
35
+
36
+ def public_key_bytes(agent_id: AgentId) -> bytes:
37
+ if not agent_id.startswith(AGENT_ID_PREFIX):
38
+ raise AgentProtocolError("invalid_agent_id", "agent id must start with did:agent:")
39
+ data = _base58btc_decode(agent_id[len(AGENT_ID_PREFIX):])
40
+ if len(data) != 32:
41
+ raise AgentProtocolError("invalid_public_key", f"agent id public key must be 32 bytes, got {len(data)}")
42
+ return data
43
+
44
+
45
+ def validate_agent_id(agent_id: AgentId) -> AgentId:
46
+ public_key_bytes(agent_id)
47
+ return agent_id
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class RequestBinding:
52
+ audience: str
53
+ method: str
54
+ host: str
55
+ path: str
56
+
57
+ @classmethod
58
+ def create(cls, audience: str, method: str, host: str, path: str) -> "RequestBinding":
59
+ return cls(audience=audience, method=method.upper(), host=host, path=path)
60
+
61
+
62
+ class AgentSigner:
63
+ def __init__(self, private_key: Ed25519PrivateKey):
64
+ self._private_key = private_key
65
+
66
+ @classmethod
67
+ def generate(cls) -> "AgentSigner":
68
+ return cls(Ed25519PrivateKey.generate())
69
+
70
+ @classmethod
71
+ def from_seed(cls, seed: bytes) -> "AgentSigner":
72
+ if len(seed) != 32:
73
+ raise AgentProtocolError("invalid_private_key", f"seed must be 32 bytes, got {len(seed)}")
74
+ return cls(Ed25519PrivateKey.from_private_bytes(seed))
75
+
76
+ def public_key(self) -> bytes:
77
+ return self._private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
78
+
79
+ def agent_id(self) -> AgentId:
80
+ return agent_id_from_public_key(self.public_key())
81
+
82
+ def sign_event(self, event: Event) -> Envelope:
83
+ return {
84
+ "event_id": event_id(event),
85
+ "event": event,
86
+ "signature": sign_event(self._private_key, event),
87
+ }
88
+
89
+ def sign_request_jwt(self, claims: dict[str, Any]) -> str:
90
+ agent_id = self.agent_id()
91
+ if claims.get("iss") != agent_id or claims.get("sub") != agent_id:
92
+ raise AgentProtocolError("invalid_jwt_claim", "iss and sub must match the signing agent id")
93
+ header = {"alg": "EdDSA", "typ": "JWT", "kid": agent_id}
94
+ encoded_header = _base64url_encode(json.dumps(header, separators=(",", ":")).encode())
95
+ encoded_payload = _base64url_encode(json.dumps(claims, separators=(",", ":")).encode())
96
+ signing_input = f"{encoded_header}.{encoded_payload}".encode()
97
+ signature = self._private_key.sign(signing_input)
98
+ return f"{encoded_header}.{encoded_payload}.{_base64url_encode(signature)}"
99
+
100
+
101
+ class NonceStore(Protocol):
102
+ def check_and_insert(self, scope: tuple[AgentId, str, str | None, str]) -> None: ...
103
+
104
+
105
+ class MemoryNonceStore:
106
+ def __init__(self) -> None:
107
+ self._seen: set[tuple[AgentId, str, str | None, str]] = set()
108
+
109
+ def check_and_insert(self, scope: tuple[AgentId, str, str | None, str]) -> None:
110
+ if scope in self._seen:
111
+ raise AgentProtocolError("nonce_reused", "nonce was already used in this replay scope")
112
+ self._seen.add(scope)
113
+
114
+
115
+ def create_event(protocol: str, event_type: str, actor: AgentId, created_at: int, nonce: str, payload: Any) -> Event:
116
+ validate_agent_id(actor)
117
+ return {
118
+ "protocol": protocol,
119
+ "type": event_type,
120
+ "actor": actor,
121
+ "created_at": created_at,
122
+ "nonce": nonce,
123
+ "payload": payload,
124
+ }
125
+
126
+
127
+ def with_room_id(event: Event, room_id: str) -> Event:
128
+ next_event = dict(event)
129
+ next_event["room_id"] = room_id
130
+ return next_event
131
+
132
+
133
+ def canonical_event_bytes(event: Event) -> bytes:
134
+ canonical = rfc8785.dumps(event)
135
+ return canonical if isinstance(canonical, bytes) else canonical.encode()
136
+
137
+
138
+ def event_id(event: Event) -> str:
139
+ digest = hashlib.sha256(canonical_event_bytes(event)).digest()
140
+ return f"{EVENT_ID_PREFIX}{_base58btc_encode(digest)}"
141
+
142
+
143
+ def sign_event(private_key: Ed25519PrivateKey, event: Event) -> str:
144
+ return _base64url_encode(private_key.sign(canonical_event_bytes(event)))
145
+
146
+
147
+ def verify_event_id(envelope: Envelope) -> None:
148
+ expected = event_id(envelope["event"])
149
+ actual = envelope["event_id"]
150
+ if expected != actual:
151
+ raise AgentProtocolError("invalid_event_id", f"invalid event id: expected {expected}, got {actual}")
152
+
153
+
154
+ def verify_signature(envelope: Envelope) -> None:
155
+ signature = _base64url_decode(envelope["signature"])
156
+ if len(signature) != 64:
157
+ raise AgentProtocolError("invalid_signature", f"signature must be 64 bytes, got {len(signature)}")
158
+ public_key = Ed25519PublicKey.from_public_bytes(public_key_bytes(envelope["event"]["actor"]))
159
+ try:
160
+ public_key.verify(signature, canonical_event_bytes(envelope["event"]))
161
+ except InvalidSignature as exc:
162
+ raise AgentProtocolError("invalid_signature", "signature verification failed") from exc
163
+
164
+
165
+ def verify_envelope(envelope: Envelope) -> None:
166
+ verify_event_id(envelope)
167
+ verify_signature(envelope)
168
+
169
+
170
+ def verify_timestamp(created_at: int, now_ms: int, window_ms: int) -> None:
171
+ if window_ms < 0 or abs(created_at - now_ms) > window_ms:
172
+ raise AgentProtocolError("timestamp_out_of_window", "timestamp is outside the allowed live-write window")
173
+
174
+
175
+ def nonce_scope_for_event(event: Event, kind: str = "actor_protocol") -> tuple[AgentId, str, str | None, str]:
176
+ room_id = event.get("room_id") if kind == "actor_room" else None
177
+ return (event["actor"], event["protocol"], room_id, event["nonce"])
178
+
179
+
180
+ def verify_live_envelope(envelope: Envelope, nonce_store: NonceStore, *, now_ms: int | None = None, window_ms: int = DEFAULT_LIVE_WRITE_WINDOW_MS, nonce_scope: str = "actor_protocol") -> None:
181
+ verify_envelope(envelope)
182
+ verify_timestamp(envelope["event"]["created_at"], now_ms if now_ms is not None else unix_time_millis(), window_ms)
183
+ nonce_store.check_and_insert(nonce_scope_for_event(envelope["event"], nonce_scope))
184
+
185
+
186
+ def create_request_jwt_claims(agent_id: AgentId, binding: RequestBinding, issued_at: int, ttl_secs: int, jti: str) -> dict[str, Any]:
187
+ return {
188
+ "iss": agent_id,
189
+ "sub": agent_id,
190
+ "aud": binding.audience,
191
+ "iat": issued_at,
192
+ "exp": issued_at + ttl_secs,
193
+ "jti": jti,
194
+ "method": binding.method.upper(),
195
+ "host": binding.host,
196
+ "path": binding.path,
197
+ }
198
+
199
+
200
+ def verify_request_jwt(token: str, *, audience: str, method: str, host: str, path: str, now_secs: int | None = None, max_ttl_secs: int = DEFAULT_REQUEST_JWT_TTL_SECS) -> dict[str, Any]:
201
+ parts = token.split(".")
202
+ if len(parts) != 3:
203
+ raise AgentProtocolError("invalid_jwt", "expected three compact JWS parts")
204
+ header = json.loads(_base64url_decode(parts[0]))
205
+ claims = json.loads(_base64url_decode(parts[1]))
206
+ signature = _base64url_decode(parts[2])
207
+ signing_input = f"{parts[0]}.{parts[1]}".encode()
208
+
209
+ if header.get("alg") != "EdDSA":
210
+ raise AgentProtocolError("invalid_jwt_claim", "alg must be EdDSA")
211
+ if header.get("typ") != "JWT":
212
+ raise AgentProtocolError("invalid_jwt_claim", "typ must be JWT")
213
+ if header.get("kid") != claims.get("iss") or claims.get("iss") != claims.get("sub"):
214
+ raise AgentProtocolError("invalid_jwt_claim", "kid, iss, and sub must identify the same Agent ID")
215
+
216
+ public_key = Ed25519PublicKey.from_public_bytes(public_key_bytes(header["kid"]))
217
+ try:
218
+ public_key.verify(signature, signing_input)
219
+ except InvalidSignature as exc:
220
+ raise AgentProtocolError("invalid_signature", "JWT signature verification failed") from exc
221
+
222
+ expected_method = method.upper()
223
+ if claims.get("aud") != audience:
224
+ raise AgentProtocolError("invalid_jwt_claim", "aud mismatch")
225
+ if claims.get("method") != expected_method:
226
+ raise AgentProtocolError("invalid_jwt_claim", "method mismatch")
227
+ if claims.get("host") != host:
228
+ raise AgentProtocolError("invalid_jwt_claim", "host mismatch")
229
+ if claims.get("path") != path:
230
+ raise AgentProtocolError("invalid_jwt_claim", "path mismatch")
231
+
232
+ now = now_secs if now_secs is not None else unix_time_secs()
233
+ if claims["iat"] > now or claims["exp"] < now:
234
+ raise AgentProtocolError("invalid_jwt_claim", "iat/exp outside valid time window")
235
+ if claims["exp"] - claims["iat"] > max_ttl_secs:
236
+ raise AgentProtocolError("invalid_jwt_claim", "JWT ttl exceeds maximum")
237
+ return claims
238
+
239
+
240
+ def verify_request_jwt_live(token: str, nonce_store: NonceStore, **context: Any) -> dict[str, Any]:
241
+ claims = verify_request_jwt(token, **context)
242
+ nonce_store.check_and_insert((claims["iss"], _REQUEST_AUTH_REPLAY_SCOPE, None, claims["jti"]))
243
+ return claims
244
+
245
+
246
+ def unix_time_millis() -> int:
247
+ return int(time.time() * 1000)
248
+
249
+
250
+ def unix_time_secs() -> int:
251
+ return int(time.time())
252
+
253
+
254
+ def random_nonce(prefix: str = "n_") -> str:
255
+ return f"{prefix}{_base64url_encode(os.urandom(16))}"
256
+
257
+
258
+ def _base58btc_encode(data: bytes) -> str:
259
+ return "z" + base58.b58encode(data).decode()
260
+
261
+
262
+ def _base58btc_decode(value: str) -> bytes:
263
+ if not value.startswith("z"):
264
+ raise AgentProtocolError("invalid_encoding", "expected base58btc multibase value")
265
+ return base58.b58decode(value[1:])
266
+
267
+
268
+ def _base64url_encode(data: bytes) -> str:
269
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
270
+
271
+
272
+ def _base64url_decode(value: str) -> bytes:
273
+ padding = "=" * ((4 - len(value) % 4) % 4)
274
+ return base64.urlsafe_b64decode(value + padding)
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .errors import AgentProtocolError
6
+ from .identity import AgentId, Envelope, Event, create_event, verify_envelope
7
+
8
+ PROFILE_PROTOCOL = "agent-profile/1.0"
9
+ PROFILE_UPDATE = "profile.update"
10
+
11
+ ProfileUpdatePayload = dict[str, Any]
12
+ AgentProfile = dict[str, Any]
13
+
14
+
15
+ def profile_update_event(actor: AgentId, created_at: int, nonce: str, payload: ProfileUpdatePayload) -> Event:
16
+ return create_event(PROFILE_PROTOCOL, PROFILE_UPDATE, actor, created_at, nonce, payload)
17
+
18
+
19
+ def validate_profile_update(envelope: Envelope) -> None:
20
+ verify_envelope(envelope)
21
+ event = envelope["event"]
22
+ if event["protocol"] != PROFILE_PROTOCOL:
23
+ raise AgentProtocolError("invalid_event_protocol", f"expected {PROFILE_PROTOCOL}, got {event['protocol']}")
24
+ if event["type"] != PROFILE_UPDATE:
25
+ raise AgentProtocolError("invalid_event_type", f"expected {PROFILE_UPDATE}, got {event['type']}")
26
+ if event["actor"] != event["payload"].get("agent_id"):
27
+ raise AgentProtocolError("invalid_actor", "profile update actor must match payload.agent_id")
28
+
29
+
30
+ def materialize_profile(envelope: Envelope) -> AgentProfile:
31
+ validate_profile_update(envelope)
32
+ payload = envelope["event"]["payload"]
33
+ return {
34
+ "agent_id": payload["agent_id"],
35
+ "name": payload["name"],
36
+ "description": payload.get("description"),
37
+ "avatar_url": payload.get("avatar_url"),
38
+ "provider": payload.get("provider"),
39
+ "capabilities": payload.get("capabilities", []),
40
+ "service_endpoints": payload.get("service_endpoints", []),
41
+ "links": payload.get("links", {}),
42
+ "metadata": payload.get("metadata", {}),
43
+ "updated_at": envelope["event"]["created_at"],
44
+ "profile_event_id": envelope["event_id"],
45
+ }
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-protocols
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
5
+ Author: LDCLabs
6
+ License: MIT
7
+ Keywords: agent,protocol,ed25519,sdk
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: base58<3,>=2.1
11
+ Requires-Dist: cryptography>=42
12
+ Requires-Dist: rfc8785<0.2,>=0.1.4
13
+ Provides-Extra: http
14
+ Requires-Dist: requests<3,>=2.31; extra == "http"
15
+
16
+ # agent-protocols Python SDK
17
+
18
+ Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse protocols.
19
+
20
+ ## Modules
21
+
22
+ - `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event IDs, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
23
+ - `agent_protocols.profile`: `profile.update` payload helpers, validation, materialization.
24
+ - `agent_protocols.discourse`: ADP event constants, room helpers, room-path checks, permission and state helpers.
25
+ - `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
26
+
27
+ ## Example
28
+
29
+ ```python
30
+ from agent_protocols import AgentSigner, materialize_profile, profile_update_event, unix_time_millis
31
+
32
+ signer = AgentSigner.generate()
33
+ event = profile_update_event(
34
+ signer.agent_id(),
35
+ unix_time_millis(),
36
+ "n_01J8Z6",
37
+ {"agent_id": signer.agent_id(), "name": "ResearchAgent-v3"},
38
+ )
39
+ envelope = signer.sign_event(event)
40
+ profile = materialize_profile(envelope)
41
+ ```
@@ -0,0 +1,16 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/agent_protocols/__init__.py
4
+ src/agent_protocols/discourse.py
5
+ src/agent_protocols/errors.py
6
+ src/agent_protocols/http_client.py
7
+ src/agent_protocols/identity.py
8
+ src/agent_protocols/profile.py
9
+ src/agent_protocols.egg-info/PKG-INFO
10
+ src/agent_protocols.egg-info/SOURCES.txt
11
+ src/agent_protocols.egg-info/dependency_links.txt
12
+ src/agent_protocols.egg-info/requires.txt
13
+ src/agent_protocols.egg-info/top_level.txt
14
+ tests/test_discourse.py
15
+ tests/test_identity.py
16
+ tests/test_profile.py
@@ -0,0 +1,6 @@
1
+ base58<3,>=2.1
2
+ cryptography>=42
3
+ rfc8785<0.2,>=0.1.4
4
+
5
+ [http]
6
+ requests<3,>=2.31
@@ -0,0 +1 @@
1
+ agent_protocols
@@ -0,0 +1,52 @@
1
+ import unittest
2
+
3
+ from agent_protocols.discourse import (
4
+ MESSAGE_TEXT,
5
+ REACTION_CREATE,
6
+ ROOM_CANCEL,
7
+ ROOM_CREATE,
8
+ can_accept_room_write,
9
+ can_submit_event,
10
+ room_create_event,
11
+ validate_discourse_envelope,
12
+ )
13
+ from agent_protocols.identity import AgentSigner, create_event
14
+
15
+
16
+ class DiscourseTests(unittest.TestCase):
17
+ def test_validates_room_create_without_room_id(self):
18
+ signer = AgentSigner.from_seed(bytes([14]) * 32)
19
+ event = room_create_event(
20
+ signer.agent_id(),
21
+ 100,
22
+ "n_room",
23
+ {"topic": "Research room", "visibility": "public", "start_time": 1000, "end_time": 2000},
24
+ )
25
+ envelope = signer.sign_event(event)
26
+
27
+ validate_discourse_envelope(envelope)
28
+
29
+ def test_rejects_room_event_without_room_id(self):
30
+ signer = AgentSigner.from_seed(bytes([15]) * 32)
31
+ event = create_event("agent-discourse/1.0", MESSAGE_TEXT, signer.agent_id(), 100, "n_message", {"text": "hello"})
32
+ envelope = signer.sign_event(event)
33
+
34
+ with self.assertRaises(Exception):
35
+ validate_discourse_envelope(envelope)
36
+
37
+ def test_applies_permission_matrix(self):
38
+ self.assertTrue(can_submit_event(REACTION_CREATE, {"role": "observer"}))
39
+ self.assertFalse(can_submit_event(MESSAGE_TEXT, {"role": "observer"}))
40
+ self.assertFalse(can_submit_event(ROOM_CANCEL, {"role": "moderator"}))
41
+ self.assertTrue(can_submit_event(ROOM_CANCEL, {"role": "moderator", "moderator_authorized": True}))
42
+ self.assertTrue(can_submit_event(ROOM_CREATE, {}))
43
+
44
+ def test_applies_state_restrictions(self):
45
+ self.assertTrue(can_accept_room_write(MESSAGE_TEXT, "active", {"role": "participant"}))
46
+ self.assertFalse(can_accept_room_write(MESSAGE_TEXT, "scheduled", {"role": "participant"}))
47
+ self.assertFalse(can_accept_room_write(REACTION_CREATE, "ended", {"role": "participant"}))
48
+ self.assertTrue(can_accept_room_write(REACTION_CREATE, "ended", {"role": "participant"}, post_end_reaction_allowed=True))
49
+
50
+
51
+ if __name__ == "__main__":
52
+ unittest.main()
@@ -0,0 +1,86 @@
1
+ import unittest
2
+
3
+ from agent_protocols.identity import (
4
+ AgentSigner,
5
+ MemoryNonceStore,
6
+ RequestBinding,
7
+ create_event,
8
+ create_request_jwt_claims,
9
+ verify_envelope,
10
+ verify_live_envelope,
11
+ verify_request_jwt_live,
12
+ )
13
+
14
+
15
+ class IdentityTests(unittest.TestCase):
16
+ def test_signs_and_verifies_event_envelopes(self):
17
+ signer = AgentSigner.from_seed(bytes([7]) * 32)
18
+ event = create_event(
19
+ "agent-profile/1.0",
20
+ "profile.update",
21
+ signer.agent_id(),
22
+ 1_779_753_600_000,
23
+ "n_test",
24
+ {"agent_id": signer.agent_id(), "name": "ResearchAgent"},
25
+ )
26
+
27
+ envelope = signer.sign_event(event)
28
+
29
+ self.assertTrue(envelope["event_id"].startswith("evt_z"))
30
+ verify_envelope(envelope)
31
+
32
+ def test_rejects_tampered_payloads(self):
33
+ signer = AgentSigner.from_seed(bytes([8]) * 32)
34
+ envelope = signer.sign_event(
35
+ create_event("agent-profile/1.0", "profile.update", signer.agent_id(), 1000, "n_test", {"name": "before"})
36
+ )
37
+ envelope["event"]["payload"] = {"name": "after"}
38
+
39
+ with self.assertRaises(Exception):
40
+ verify_envelope(envelope)
41
+
42
+ def test_rejects_nonce_reuse(self):
43
+ signer = AgentSigner.from_seed(bytes([9]) * 32)
44
+ envelope = signer.sign_event(
45
+ create_event("agent-profile/1.0", "profile.update", signer.agent_id(), 1000, "n_reused", {"name": "ResearchAgent"})
46
+ )
47
+ store = MemoryNonceStore()
48
+
49
+ verify_live_envelope(envelope, store, now_ms=1000, window_ms=1000)
50
+ with self.assertRaises(Exception):
51
+ verify_live_envelope(envelope, store, now_ms=1000, window_ms=1000)
52
+
53
+ def test_signs_and_verifies_request_jwts(self):
54
+ signer = AgentSigner.from_seed(bytes([10]) * 32)
55
+ binding = RequestBinding.create("https://api.example.com", "get", "api.example.com", "/v1/profiles/did:agent:test")
56
+ claims = create_request_jwt_claims(signer.agent_id(), binding, 100, 300, "jwt_nonce")
57
+ token = signer.sign_request_jwt(claims)
58
+ store = MemoryNonceStore()
59
+
60
+ verified = verify_request_jwt_live(
61
+ token,
62
+ store,
63
+ audience=binding.audience,
64
+ method=binding.method,
65
+ host=binding.host,
66
+ path=binding.path,
67
+ now_secs=120,
68
+ max_ttl_secs=300,
69
+ )
70
+
71
+ self.assertEqual(verified["jti"], "jwt_nonce")
72
+ with self.assertRaises(Exception):
73
+ verify_request_jwt_live(
74
+ token,
75
+ store,
76
+ audience=binding.audience,
77
+ method=binding.method,
78
+ host=binding.host,
79
+ path=binding.path,
80
+ now_secs=120,
81
+ max_ttl_secs=300,
82
+ )
83
+
84
+
85
+ if __name__ == "__main__":
86
+ unittest.main()
@@ -0,0 +1,30 @@
1
+ import unittest
2
+
3
+ from agent_protocols.identity import AgentSigner
4
+ from agent_protocols.profile import materialize_profile, profile_update_event, validate_profile_update
5
+
6
+
7
+ class ProfileTests(unittest.TestCase):
8
+ def test_materializes_valid_profile_update(self):
9
+ signer = AgentSigner.from_seed(bytes([11]) * 32)
10
+ payload = {"agent_id": signer.agent_id(), "name": "ResearchAgent-v3", "capabilities": ["research"]}
11
+ envelope = signer.sign_event(profile_update_event(signer.agent_id(), 1_779_753_600_000, "n_profile", payload))
12
+
13
+ profile = materialize_profile(envelope)
14
+
15
+ self.assertEqual(profile["name"], "ResearchAgent-v3")
16
+ self.assertEqual(profile["updated_at"], 1_779_753_600_000)
17
+ self.assertEqual(profile["profile_event_id"], envelope["event_id"])
18
+
19
+ def test_rejects_actor_payload_mismatch(self):
20
+ signer = AgentSigner.from_seed(bytes([12]) * 32)
21
+ other = AgentSigner.from_seed(bytes([13]) * 32)
22
+ payload = {"agent_id": other.agent_id(), "name": "Imposter"}
23
+ envelope = signer.sign_event(profile_update_event(signer.agent_id(), 1_779_753_600_000, "n_profile", payload))
24
+
25
+ with self.assertRaises(Exception):
26
+ validate_profile_update(envelope)
27
+
28
+
29
+ if __name__ == "__main__":
30
+ unittest.main()