agent-protocols 0.1.0__tar.gz → 0.2.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.
Files changed (20) hide show
  1. {agent_protocols-0.1.0 → agent_protocols-0.2.0}/PKG-INFO +8 -8
  2. {agent_protocols-0.1.0 → agent_protocols-0.2.0}/README.md +7 -6
  3. {agent_protocols-0.1.0 → agent_protocols-0.2.0}/pyproject.toml +2 -2
  4. {agent_protocols-0.1.0 → agent_protocols-0.2.0}/src/agent_protocols/__init__.py +14 -14
  5. {agent_protocols-0.1.0 → agent_protocols-0.2.0}/src/agent_protocols/discourse.py +48 -55
  6. agent_protocols-0.2.0/src/agent_protocols/http_client.py +130 -0
  7. {agent_protocols-0.1.0 → agent_protocols-0.2.0}/src/agent_protocols/identity.py +87 -73
  8. {agent_protocols-0.1.0 → agent_protocols-0.2.0}/src/agent_protocols/profile.py +9 -7
  9. {agent_protocols-0.1.0 → agent_protocols-0.2.0}/src/agent_protocols.egg-info/PKG-INFO +8 -8
  10. {agent_protocols-0.1.0 → agent_protocols-0.2.0}/src/agent_protocols.egg-info/requires.txt +0 -1
  11. {agent_protocols-0.1.0 → agent_protocols-0.2.0}/tests/test_discourse.py +30 -6
  12. {agent_protocols-0.1.0 → agent_protocols-0.2.0}/tests/test_identity.py +21 -26
  13. agent_protocols-0.2.0/tests/test_profile.py +49 -0
  14. agent_protocols-0.1.0/src/agent_protocols/http_client.py +0 -75
  15. agent_protocols-0.1.0/tests/test_profile.py +0 -30
  16. {agent_protocols-0.1.0 → agent_protocols-0.2.0}/setup.cfg +0 -0
  17. {agent_protocols-0.1.0 → agent_protocols-0.2.0}/src/agent_protocols/errors.py +0 -0
  18. {agent_protocols-0.1.0 → agent_protocols-0.2.0}/src/agent_protocols.egg-info/SOURCES.txt +0 -0
  19. {agent_protocols-0.1.0 → agent_protocols-0.2.0}/src/agent_protocols.egg-info/dependency_links.txt +0 -0
  20. {agent_protocols-0.1.0 → agent_protocols-0.2.0}/src/agent_protocols.egg-info/top_level.txt +0 -0
@@ -1,13 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-protocols
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
5
5
  Author: LDCLabs
6
6
  License: MIT
7
7
  Keywords: agent,protocol,ed25519,sdk
8
8
  Requires-Python: >=3.10
9
9
  Description-Content-Type: text/markdown
10
- Requires-Dist: base58<3,>=2.1
11
10
  Requires-Dist: cryptography>=42
12
11
  Requires-Dist: rfc8785<0.2,>=0.1.4
13
12
  Provides-Extra: http
@@ -19,22 +18,23 @@ Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse prot
19
18
 
20
19
  ## Modules
21
20
 
22
- - `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event IDs, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
21
+ - `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event hashes, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
23
22
  - `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.
23
+ - `agent_protocols.discourse`: ADP event constants, join request helpers, room-path checks, permission and state helpers.
25
24
  - `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
26
25
 
27
26
  ## Example
28
27
 
29
28
  ```python
30
- from agent_protocols import AgentSigner, materialize_profile, profile_update_event, unix_time_millis
29
+ from agent_protocols import AgentSigner, ClientNonceManager, materialize_profile, profile_update_event, unix_ms
31
30
 
32
31
  signer = AgentSigner.generate()
32
+ nonces = ClientNonceManager()
33
33
  event = profile_update_event(
34
34
  signer.agent_id(),
35
- unix_time_millis(),
36
- "n_01J8Z6",
37
- {"agent_id": signer.agent_id(), "name": "ResearchAgent-v3"},
35
+ unix_ms(),
36
+ nonces.next_nonce(),
37
+ {"id": signer.agent_id(), "name": "ResearchAgent-v3"},
38
38
  )
39
39
  envelope = signer.sign_event(event)
40
40
  profile = materialize_profile(envelope)
@@ -4,22 +4,23 @@ Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse prot
4
4
 
5
5
  ## Modules
6
6
 
7
- - `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event IDs, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
7
+ - `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event hashes, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
8
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.
9
+ - `agent_protocols.discourse`: ADP event constants, join request helpers, room-path checks, permission and state helpers.
10
10
  - `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
11
11
 
12
12
  ## Example
13
13
 
14
14
  ```python
15
- from agent_protocols import AgentSigner, materialize_profile, profile_update_event, unix_time_millis
15
+ from agent_protocols import AgentSigner, ClientNonceManager, materialize_profile, profile_update_event, unix_ms
16
16
 
17
17
  signer = AgentSigner.generate()
18
+ nonces = ClientNonceManager()
18
19
  event = profile_update_event(
19
20
  signer.agent_id(),
20
- unix_time_millis(),
21
- "n_01J8Z6",
22
- {"agent_id": signer.agent_id(), "name": "ResearchAgent-v3"},
21
+ unix_ms(),
22
+ nonces.next_nonce(),
23
+ {"id": signer.agent_id(), "name": "ResearchAgent-v3"},
23
24
  )
24
25
  envelope = signer.sign_event(event)
25
26
  profile = materialize_profile(envelope)
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "agent-protocols"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  license = { text = "MIT" }
8
8
  authors = [{ name = "LDCLabs" }]
9
9
  keywords = ["agent", "protocol", "ed25519", "sdk"]
10
- dependencies = ["base58>=2.1,<3", "cryptography>=42", "rfc8785>=0.1.4,<0.2"]
10
+ dependencies = ["cryptography>=42", "rfc8785>=0.1.4,<0.2"]
11
11
 
12
12
  [project.optional-dependencies]
13
13
  http = ["requests>=2.31,<3"]
@@ -1,26 +1,26 @@
1
1
  from .errors import AgentProtocolError
2
2
  from .identity import (
3
3
  AGENT_ID_PREFIX,
4
- EVENT_ID_PREFIX,
5
4
  AgentSigner,
5
+ ClientNonceManager,
6
+ MAX_NONCE_HEADER,
6
7
  MemoryNonceStore,
7
8
  RequestBinding,
8
9
  agent_id_from_public_key,
9
10
  canonical_event_bytes,
10
11
  create_event,
11
12
  create_request_jwt_claims,
12
- event_id,
13
+ event_hash,
13
14
  public_key_bytes,
14
- random_nonce,
15
15
  sign_event,
16
- unix_time_millis,
17
- unix_time_secs,
16
+ unix_ms,
17
+ unix_secs,
18
18
  validate_agent_id,
19
+ validate_nonce,
19
20
  verify_envelope,
20
- verify_event_id,
21
+ verify_event_hash,
21
22
  verify_live_envelope,
22
23
  verify_request_jwt,
23
- verify_request_jwt_live,
24
24
  verify_signature,
25
25
  verify_timestamp,
26
26
  with_room_id,
@@ -29,32 +29,32 @@ from .profile import PROFILE_PROTOCOL, PROFILE_UPDATE, materialize_profile, prof
29
29
 
30
30
  __all__ = [
31
31
  "AGENT_ID_PREFIX",
32
- "EVENT_ID_PREFIX",
32
+ "MAX_NONCE_HEADER",
33
33
  "PROFILE_PROTOCOL",
34
34
  "PROFILE_UPDATE",
35
35
  "AgentProtocolError",
36
36
  "AgentSigner",
37
+ "ClientNonceManager",
37
38
  "MemoryNonceStore",
38
39
  "RequestBinding",
39
40
  "agent_id_from_public_key",
40
41
  "canonical_event_bytes",
41
42
  "create_event",
42
43
  "create_request_jwt_claims",
43
- "event_id",
44
+ "event_hash",
44
45
  "materialize_profile",
45
46
  "profile_update_event",
46
47
  "public_key_bytes",
47
- "random_nonce",
48
48
  "sign_event",
49
- "unix_time_millis",
50
- "unix_time_secs",
49
+ "unix_ms",
50
+ "unix_secs",
51
51
  "validate_agent_id",
52
+ "validate_nonce",
52
53
  "validate_profile_update",
53
54
  "verify_envelope",
54
- "verify_event_id",
55
+ "verify_event_hash",
55
56
  "verify_live_envelope",
56
57
  "verify_request_jwt",
57
- "verify_request_jwt_live",
58
58
  "verify_signature",
59
59
  "verify_timestamp",
60
60
  "with_room_id",
@@ -10,61 +10,55 @@ LEGACY_DISCOURSE_PROTOCOL = "adp/1.0"
10
10
 
11
11
  ROOM_CREATE = "room.create"
12
12
  ROOM_JOIN = "room.join"
13
+ ROOM_JOIN_REVIEW = "room.join.review"
13
14
  ROOM_LEAVE = "room.leave"
14
15
  ROOM_MEMBER_ROLE_UPDATE = "room.member.role.update"
15
- ROOM_INVITE = "room.invite"
16
- ROOM_INVITE_REVOKE = "room.invite.revoke"
17
16
  ROOM_CLOSE = "room.close"
18
17
  ROOM_CANCEL = "room.cancel"
19
- MESSAGE_TEXT = "message.text"
20
- MESSAGE_MARKDOWN = "message.markdown"
21
- MESSAGE_DATA = "message.data"
18
+ MESSAGE_CREATE = "message.create"
22
19
  REACTION_CREATE = "reaction.create"
23
- PROPOSAL_CREATE = "proposal.create"
24
- POLL_CREATE = "poll.create"
25
- POLL_VOTE = "poll.vote"
26
- RESOLUTION_CREATE = "resolution.create"
20
+ MESSAGE_PROPOSAL_CREATE = "message.proposal.create"
21
+ MESSAGE_POLL_CREATE = "message.poll.create"
22
+ MESSAGE_POLL_VOTE = "message.poll.vote"
23
+ MESSAGE_RESOLUTION_CREATE = "message.resolution.create"
27
24
  SOURCE_ADD = "source.add"
28
25
  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"
26
+ QUESTION_CREATE = "question.create"
27
+ ROOM_STEER = "room.steer"
28
+ MAP_UPDATE = "map.update"
29
+ ARTIFACT_CREATE = "artifact.create"
34
30
 
35
31
  KNOWN_EVENT_TYPES = {
36
32
  ROOM_CREATE,
37
33
  ROOM_JOIN,
34
+ ROOM_JOIN_REVIEW,
38
35
  ROOM_LEAVE,
39
36
  ROOM_MEMBER_ROLE_UPDATE,
40
- ROOM_INVITE,
41
- ROOM_INVITE_REVOKE,
42
37
  ROOM_CLOSE,
43
38
  ROOM_CANCEL,
44
- MESSAGE_TEXT,
45
- MESSAGE_MARKDOWN,
46
- MESSAGE_DATA,
39
+ MESSAGE_CREATE,
47
40
  REACTION_CREATE,
48
- PROPOSAL_CREATE,
49
- POLL_CREATE,
50
- POLL_VOTE,
51
- RESOLUTION_CREATE,
41
+ MESSAGE_PROPOSAL_CREATE,
42
+ MESSAGE_POLL_CREATE,
43
+ MESSAGE_POLL_VOTE,
44
+ MESSAGE_RESOLUTION_CREATE,
52
45
  SOURCE_ADD,
53
46
  TURN_UPDATE,
54
- QUESTION_GENERATE,
55
- DISCOURSE_STEER,
56
- MINDMAP_UPDATE,
57
- REPORT_GENERATE,
58
- SESSION_AUTH,
47
+ QUESTION_CREATE,
48
+ ROOM_STEER,
49
+ MAP_UPDATE,
50
+ ARTIFACT_CREATE,
59
51
  }
60
52
 
61
53
  RoomState = Literal["scheduled", "active", "ended", "cancelled"]
62
54
  Role = Literal["moderator", "expert", "participant", "observer"]
55
+ JoinRequestStatus = Literal["pending", "approved", "rejected", "expired"]
63
56
 
64
57
 
65
58
  class PermissionContext(TypedDict, total=False):
66
59
  role: Role
67
60
  is_creator: bool
61
+ join_request_approved: bool
68
62
  moderator_authorized: bool
69
63
  expert_policy_allowed: bool
70
64
  participant_policy_allowed: bool
@@ -72,11 +66,11 @@ class PermissionContext(TypedDict, total=False):
72
66
  observer_poll_vote_allowed: bool
73
67
 
74
68
 
75
- def room_create_event(actor: AgentId, created_at: int, nonce: str, payload: dict[str, Any]) -> Event:
69
+ def room_create_event(actor: AgentId, created_at: int, nonce: int, payload: dict[str, Any]) -> Event:
76
70
  return create_event(DISCOURSE_PROTOCOL, ROOM_CREATE, actor, created_at, nonce, payload)
77
71
 
78
72
 
79
- def discourse_event(event_type: str, actor: AgentId, created_at: int, nonce: str, room_id: str, payload: Any) -> Event:
73
+ def discourse_event(event_type: str, actor: AgentId, created_at: int, nonce: int, room_id: str, payload: Any) -> Event:
80
74
  return with_room_id(create_event(DISCOURSE_PROTOCOL, event_type, actor, created_at, nonce, payload), room_id)
81
75
 
82
76
 
@@ -92,6 +86,8 @@ def validate_discourse_envelope(envelope: Envelope, accept_legacy_protocol: bool
92
86
 
93
87
  def validate_room_path(envelope: Envelope, path_room_id: str) -> None:
94
88
  actual = envelope["event"].get("room_id")
89
+ if actual is None and envelope["event"]["type"] == ROOM_CREATE:
90
+ return
95
91
  if actual is None:
96
92
  raise AgentProtocolError("missing_room_id", "event requires a room_id")
97
93
  if actual != path_room_id:
@@ -103,8 +99,10 @@ def event_requires_room_id(event_type: str) -> bool:
103
99
 
104
100
 
105
101
  def can_submit_event(event_type: str, context: PermissionContext) -> bool:
106
- if event_type in {ROOM_CREATE, ROOM_JOIN}:
102
+ if event_type == ROOM_CREATE:
107
103
  return True
104
+ if event_type == ROOM_JOIN:
105
+ return context.get("join_request_approved", False)
108
106
  if context.get("is_creator"):
109
107
  return event_type in KNOWN_EVENT_TYPES
110
108
 
@@ -122,7 +120,7 @@ def can_submit_event(event_type: str, context: PermissionContext) -> bool:
122
120
 
123
121
  def can_write_in_state(event_type: str, state: RoomState, *, post_end_reaction_allowed: bool = False) -> bool:
124
122
  if state == "scheduled":
125
- return event_type in {ROOM_JOIN, ROOM_INVITE, ROOM_INVITE_REVOKE, ROOM_CANCEL}
123
+ return event_type in {ROOM_JOIN, ROOM_JOIN_REVIEW, ROOM_CANCEL}
126
124
  if state == "active":
127
125
  return event_type not in {ROOM_CREATE, ROOM_CANCEL}
128
126
  if state == "ended":
@@ -143,22 +141,19 @@ def validate_room_write(event_type: str, state: RoomState, context: PermissionCo
143
141
 
144
142
  def _moderator_can_submit(event_type: str, moderator_authorized: bool) -> bool:
145
143
  allowed = {
146
- ROOM_INVITE,
147
- ROOM_INVITE_REVOKE,
144
+ ROOM_JOIN_REVIEW,
148
145
  ROOM_CLOSE,
149
- MESSAGE_TEXT,
150
- MESSAGE_MARKDOWN,
151
- MESSAGE_DATA,
146
+ MESSAGE_CREATE,
152
147
  SOURCE_ADD,
153
148
  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,
149
+ QUESTION_CREATE,
150
+ ROOM_STEER,
151
+ MAP_UPDATE,
152
+ ARTIFACT_CREATE,
153
+ MESSAGE_PROPOSAL_CREATE,
154
+ MESSAGE_POLL_CREATE,
155
+ MESSAGE_POLL_VOTE,
156
+ MESSAGE_RESOLUTION_CREATE,
162
157
  REACTION_CREATE,
163
158
  ROOM_LEAVE,
164
159
  }
@@ -167,24 +162,22 @@ def _moderator_can_submit(event_type: str, moderator_authorized: bool) -> bool:
167
162
 
168
163
  def _speaker_can_submit(event_type: str, policy_allowed: bool) -> bool:
169
164
  allowed = {
170
- MESSAGE_TEXT,
171
- MESSAGE_MARKDOWN,
172
- MESSAGE_DATA,
165
+ MESSAGE_CREATE,
173
166
  SOURCE_ADD,
174
- DISCOURSE_STEER,
175
- PROPOSAL_CREATE,
176
- POLL_CREATE,
177
- POLL_VOTE,
167
+ ROOM_STEER,
168
+ MESSAGE_PROPOSAL_CREATE,
169
+ MESSAGE_POLL_CREATE,
170
+ MESSAGE_POLL_VOTE,
178
171
  REACTION_CREATE,
179
172
  ROOM_LEAVE,
180
173
  }
181
- policy_events = {QUESTION_GENERATE, MINDMAP_UPDATE, REPORT_GENERATE, RESOLUTION_CREATE}
174
+ policy_events = {QUESTION_CREATE, MAP_UPDATE, ARTIFACT_CREATE, MESSAGE_RESOLUTION_CREATE}
182
175
  return event_type in allowed or (policy_allowed and event_type in policy_events)
183
176
 
184
177
 
185
178
  def _observer_can_submit(event_type: str, context: PermissionContext) -> bool:
186
179
  return (
187
180
  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)
181
+ or (context.get("observer_steering_allowed", False) and event_type == ROOM_STEER)
182
+ or (context.get("observer_poll_vote_allowed", False) and event_type == MESSAGE_POLL_VOTE)
190
183
  )
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from urllib.parse import quote, urlencode
5
+
6
+ try:
7
+ import requests
8
+ except ImportError: # pragma: no cover
9
+ requests = None # type: ignore[assignment]
10
+
11
+ from .identity import AgentId, Envelope
12
+
13
+
14
+ class ProfileClient:
15
+ def __init__(self, base_url: str, session: Any | None = None):
16
+ self.base_url = base_url.rstrip("/")
17
+ self.session = session or _requests_session()
18
+
19
+ def get_profile(self, agent_id: AgentId) -> dict[str, Any]:
20
+ return self._get(f"/v1/profiles/{agent_id}")
21
+
22
+ def get_profiles(self, agent_ids: list[AgentId]) -> dict[str, Any]:
23
+ return self._post("/v1/profiles/batch", {"ids": agent_ids})
24
+
25
+ def profile_events(self, agent_id: AgentId, limit: int = 1) -> dict[str, Any]:
26
+ return self._get(f"/v1/profiles/{agent_id}/events?limit={limit}")
27
+
28
+ def submit_profile_update(self, envelope: Envelope) -> dict[str, Any]:
29
+ return self._post("/v1/profiles", envelope)
30
+
31
+ def _get(self, path: str) -> Any:
32
+ response = self.session.get(self.base_url + path)
33
+ response.raise_for_status()
34
+ return response.json()
35
+
36
+ def _post(self, path: str, body: Any) -> dict[str, Any]:
37
+ response = self.session.post(self.base_url + path, json=body)
38
+ response.raise_for_status()
39
+ return response.json()
40
+
41
+
42
+ class DiscourseClient:
43
+ def __init__(self, base_url: str, session: Any | None = None):
44
+ self.base_url = base_url.rstrip("/")
45
+ self.session = session or _requests_session()
46
+
47
+ def protocol(self) -> dict[str, Any]:
48
+ return self._get("/.well-known/agent-discourse")
49
+
50
+ def create_room(self, envelope: Envelope) -> dict[str, Any]:
51
+ return self._post("/v1/rooms", envelope)
52
+
53
+ def room(self, room_id: str) -> dict[str, Any]:
54
+ return self._get(f"/v1/rooms/{room_id}")
55
+
56
+ def request_join(self, room_id: str, jwt: str, request: dict[str, Any]) -> dict[str, Any]:
57
+ return self._post(f"/v1/rooms/{room_id}/join-requests", request, jwt=jwt)
58
+
59
+ def join_request(self, room_id: str, request_id: str, jwt: str) -> dict[str, Any]:
60
+ return self._get(f"/v1/rooms/{room_id}/join-requests/{request_id}", jwt=jwt)
61
+
62
+ def join_requests(self, room_id: str, jwt: str) -> list[dict[str, Any]]:
63
+ return self._get(f"/v1/rooms/{room_id}/join-requests", jwt=jwt)
64
+
65
+ def join_room(self, room_id: str, envelope: Envelope) -> dict[str, Any]:
66
+ return self._post(f"/v1/rooms/{room_id}", envelope)
67
+
68
+ def leave_room(self, room_id: str, envelope: Envelope) -> dict[str, Any]:
69
+ return self._post(f"/v1/rooms/{room_id}", envelope)
70
+
71
+ def submit_event(self, room_id: str, envelope: Envelope) -> dict[str, Any]:
72
+ return self._post(f"/v1/rooms/{room_id}", envelope)
73
+
74
+ def events(
75
+ self,
76
+ room_id: str,
77
+ *,
78
+ after_seq: int | None = None,
79
+ limit: int | None = None,
80
+ cursor: str | None = None,
81
+ jwt: str | None = None,
82
+ ) -> list[dict[str, Any]]:
83
+ query = urlencode(
84
+ {
85
+ key: value
86
+ for key, value in {
87
+ "after_seq": after_seq,
88
+ "limit": limit,
89
+ "cursor": cursor,
90
+ }.items()
91
+ if value is not None
92
+ }
93
+ )
94
+ suffix = f"?{query}" if query else ""
95
+ return self._get(f"/v1/rooms/{room_id}/events{suffix}", jwt=jwt)
96
+
97
+ def websocket_events_url(self, room_id: str, jwt: str) -> str:
98
+ return websocket_events_url(self.base_url, room_id, jwt)
99
+
100
+ def archive(self, room_id: str) -> dict[str, Any]:
101
+ return self._get(f"/v1/rooms/{room_id}/archive")
102
+
103
+ def _get(self, path: str, jwt: str | None = None) -> Any:
104
+ response = self.session.get(self.base_url + path, headers=_auth_headers(jwt))
105
+ response.raise_for_status()
106
+ return response.json()
107
+
108
+ def _post(self, path: str, body: Any, jwt: str | None = None) -> Any:
109
+ response = self.session.post(self.base_url + path, json=body, headers=_auth_headers(jwt))
110
+ response.raise_for_status()
111
+ return response.json()
112
+
113
+
114
+ def websocket_events_url(base_url: str, room_id: str, jwt: str) -> str:
115
+ websocket_base = base_url.rstrip("/")
116
+ if websocket_base.startswith("https://"):
117
+ websocket_base = "wss://" + websocket_base[len("https://") :]
118
+ elif websocket_base.startswith("http://"):
119
+ websocket_base = "ws://" + websocket_base[len("http://") :]
120
+ return f"{websocket_base}/v1/rooms/{quote(room_id, safe='')}/events/live?access_token={quote(jwt, safe='')}"
121
+
122
+
123
+ def _auth_headers(jwt: str | None) -> dict[str, str] | None:
124
+ return {"Authorization": f"Bearer {jwt}"} if jwt else None
125
+
126
+
127
+ def _requests_session() -> Any:
128
+ if requests is None:
129
+ raise RuntimeError("Install agent-protocols[http] to use HTTP clients")
130
+ return requests.Session()
@@ -3,12 +3,11 @@ from __future__ import annotations
3
3
  import base64
4
4
  import hashlib
5
5
  import json
6
- import os
6
+ import re
7
7
  import time
8
8
  from dataclasses import dataclass
9
9
  from typing import Any, MutableMapping, Protocol
10
10
 
11
- import base58
12
11
  import rfc8785
13
12
  from cryptography.exceptions import InvalidSignature
14
13
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
@@ -17,10 +16,10 @@ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
17
16
  from .errors import AgentProtocolError
18
17
 
19
18
  AGENT_ID_PREFIX = "did:agent:"
20
- EVENT_ID_PREFIX = "evt_"
21
19
  DEFAULT_LIVE_WRITE_WINDOW_MS = 300_000
20
+ DEFAULT_NONCE_TTL_MS = 300_000
22
21
  DEFAULT_REQUEST_JWT_TTL_SECS = 300
23
- _REQUEST_AUTH_REPLAY_SCOPE = "agent-identity/request-auth"
22
+ MAX_NONCE_HEADER = "Max-Seen-Nonce"
24
23
 
25
24
  Event = dict[str, Any]
26
25
  Envelope = dict[str, Any]
@@ -30,13 +29,13 @@ AgentId = str
30
29
  def agent_id_from_public_key(public_key: bytes) -> AgentId:
31
30
  if len(public_key) != 32:
32
31
  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)}"
32
+ return f"{AGENT_ID_PREFIX}{_base64url_encode(public_key)}"
34
33
 
35
34
 
36
35
  def public_key_bytes(agent_id: AgentId) -> bytes:
37
36
  if not agent_id.startswith(AGENT_ID_PREFIX):
38
37
  raise AgentProtocolError("invalid_agent_id", "agent id must start with did:agent:")
39
- data = _base58btc_decode(agent_id[len(AGENT_ID_PREFIX):])
38
+ data = _base64url_decode_no_pad(agent_id[len(AGENT_ID_PREFIX):])
40
39
  if len(data) != 32:
41
40
  raise AgentProtocolError("invalid_public_key", f"agent id public key must be 32 bytes, got {len(data)}")
42
41
  return data
@@ -50,13 +49,10 @@ def validate_agent_id(agent_id: AgentId) -> AgentId:
50
49
  @dataclass(frozen=True)
51
50
  class RequestBinding:
52
51
  audience: str
53
- method: str
54
- host: str
55
- path: str
56
52
 
57
53
  @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)
54
+ def create(cls, audience: str) -> "RequestBinding":
55
+ return cls(audience=audience)
60
56
 
61
57
 
62
58
  class AgentSigner:
@@ -81,7 +77,7 @@ class AgentSigner:
81
77
 
82
78
  def sign_event(self, event: Event) -> Envelope:
83
79
  return {
84
- "event_id": event_id(event),
80
+ "hash": event_hash(event),
85
81
  "event": event,
86
82
  "signature": sign_event(self._private_key, event),
87
83
  }
@@ -99,21 +95,63 @@ class AgentSigner:
99
95
 
100
96
 
101
97
  class NonceStore(Protocol):
102
- def check_and_insert(self, scope: tuple[AgentId, str, str | None, str]) -> None: ...
98
+ def check_and_update(self, actor: AgentId, nonce: int, now_ms: int, ttl_ms: int) -> int: ...
99
+
100
+ def max_nonce(self, actor: AgentId, now_ms: int) -> int | None: ...
103
101
 
104
102
 
105
103
  class MemoryNonceStore:
106
104
  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:
105
+ self._records: dict[AgentId, tuple[int, int]] = {}
106
+
107
+ def check_and_update(self, actor: AgentId, nonce: int, now_ms: int, ttl_ms: int) -> int:
108
+ validate_nonce(nonce)
109
+ if ttl_ms < 0:
110
+ raise AgentProtocolError("invalid_nonce", "nonce cache ttl must be non-negative")
111
+ record = self._records.get(actor)
112
+ if record is not None:
113
+ max_nonce, expires_at = record
114
+ if expires_at > now_ms and nonce <= max_nonce:
115
+ raise AgentProtocolError("nonce_not_greater", f"nonce must be greater than accepted max nonce {max_nonce}")
116
+ self._records[actor] = (nonce, now_ms + ttl_ms)
117
+ return nonce
118
+
119
+ def max_nonce(self, actor: AgentId, now_ms: int) -> int | None:
120
+ record = self._records.get(actor)
121
+ if record is None:
122
+ return None
123
+ max_nonce, expires_at = record
124
+ return max_nonce if expires_at > now_ms else None
125
+
126
+
127
+ class ClientNonceManager:
128
+ def __init__(self, next_nonce: int = 1) -> None:
129
+ validate_nonce(next_nonce)
130
+ self._next_nonce = next_nonce
131
+
132
+ def peek(self) -> int:
133
+ return self._next_nonce
134
+
135
+ def next_nonce(self) -> int:
136
+ nonce = self._next_nonce
137
+ self._next_nonce += 1
138
+ return nonce
139
+
140
+ def observe_max_nonce(self, max_nonce: int | str | None) -> None:
141
+ if max_nonce is None or max_nonce == "":
142
+ return
143
+ try:
144
+ parsed = int(max_nonce)
145
+ except (TypeError, ValueError) as exc:
146
+ raise AgentProtocolError("invalid_nonce", "invalid max nonce header") from exc
147
+ validate_nonce(parsed)
148
+ if parsed >= self._next_nonce:
149
+ self._next_nonce = parsed + 1
150
+
151
+
152
+ def create_event(protocol: str, event_type: str, actor: AgentId, created_at: int, nonce: int, payload: Any) -> Event:
116
153
  validate_agent_id(actor)
154
+ validate_nonce(nonce)
117
155
  return {
118
156
  "protocol": protocol,
119
157
  "type": event_type,
@@ -135,20 +173,20 @@ def canonical_event_bytes(event: Event) -> bytes:
135
173
  return canonical if isinstance(canonical, bytes) else canonical.encode()
136
174
 
137
175
 
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)}"
176
+ def event_hash(event: Event) -> str:
177
+ digest = hashlib.sha3_256(canonical_event_bytes(event)).digest()
178
+ return _base64url_encode(digest)
141
179
 
142
180
 
143
181
  def sign_event(private_key: Ed25519PrivateKey, event: Event) -> str:
144
182
  return _base64url_encode(private_key.sign(canonical_event_bytes(event)))
145
183
 
146
184
 
147
- def verify_event_id(envelope: Envelope) -> None:
148
- expected = event_id(envelope["event"])
149
- actual = envelope["event_id"]
185
+ def verify_event_hash(envelope: Envelope) -> None:
186
+ expected = event_hash(envelope["event"])
187
+ actual = envelope["hash"]
150
188
  if expected != actual:
151
- raise AgentProtocolError("invalid_event_id", f"invalid event id: expected {expected}, got {actual}")
189
+ raise AgentProtocolError("invalid_event_hash", f"invalid event hash: expected {expected}, got {actual}")
152
190
 
153
191
 
154
192
  def verify_signature(envelope: Envelope) -> None:
@@ -163,7 +201,7 @@ def verify_signature(envelope: Envelope) -> None:
163
201
 
164
202
 
165
203
  def verify_envelope(envelope: Envelope) -> None:
166
- verify_event_id(envelope)
204
+ verify_event_hash(envelope)
167
205
  verify_signature(envelope)
168
206
 
169
207
 
@@ -172,32 +210,24 @@ def verify_timestamp(created_at: int, now_ms: int, window_ms: int) -> None:
172
210
  raise AgentProtocolError("timestamp_out_of_window", "timestamp is outside the allowed live-write window")
173
211
 
174
212
 
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:
213
+ def verify_live_envelope(envelope: Envelope, nonce_store: NonceStore, *, now_ms: int | None = None, window_ms: int = DEFAULT_LIVE_WRITE_WINDOW_MS, nonce_ttl_ms: int = DEFAULT_NONCE_TTL_MS) -> int:
214
+ current_now_ms = now_ms if now_ms is not None else unix_ms()
181
215
  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))
216
+ verify_timestamp(envelope["event"]["created_at"], current_now_ms, window_ms)
217
+ return nonce_store.check_and_update(envelope["event"]["actor"], envelope["event"]["nonce"], current_now_ms, nonce_ttl_ms)
184
218
 
185
219
 
186
- def create_request_jwt_claims(agent_id: AgentId, binding: RequestBinding, issued_at: int, ttl_secs: int, jti: str) -> dict[str, Any]:
220
+ def create_request_jwt_claims(agent_id: AgentId, binding: RequestBinding, issued_at: int, ttl_secs: int) -> dict[str, Any]:
187
221
  return {
188
222
  "iss": agent_id,
189
223
  "sub": agent_id,
190
224
  "aud": binding.audience,
191
225
  "iat": issued_at,
192
226
  "exp": issued_at + ttl_secs,
193
- "jti": jti,
194
- "method": binding.method.upper(),
195
- "host": binding.host,
196
- "path": binding.path,
197
227
  }
198
228
 
199
229
 
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]:
230
+ def verify_request_jwt(token: str, *, audience: str, now_secs: int | None = None, max_ttl_secs: int = DEFAULT_REQUEST_JWT_TTL_SECS) -> dict[str, Any]:
201
231
  parts = token.split(".")
202
232
  if len(parts) != 3:
203
233
  raise AgentProtocolError("invalid_jwt", "expected three compact JWS parts")
@@ -219,17 +249,10 @@ def verify_request_jwt(token: str, *, audience: str, method: str, host: str, pat
219
249
  except InvalidSignature as exc:
220
250
  raise AgentProtocolError("invalid_signature", "JWT signature verification failed") from exc
221
251
 
222
- expected_method = method.upper()
223
252
  if claims.get("aud") != audience:
224
253
  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()
254
+
255
+ now = now_secs if now_secs is not None else unix_secs()
233
256
  if claims["iat"] > now or claims["exp"] < now:
234
257
  raise AgentProtocolError("invalid_jwt_claim", "iat/exp outside valid time window")
235
258
  if claims["exp"] - claims["iat"] > max_ttl_secs:
@@ -237,32 +260,17 @@ def verify_request_jwt(token: str, *, audience: str, method: str, host: str, pat
237
260
  return claims
238
261
 
239
262
 
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:
263
+ def unix_ms() -> int:
247
264
  return int(time.time() * 1000)
248
265
 
249
266
 
250
- def unix_time_secs() -> int:
267
+ def unix_secs() -> int:
251
268
  return int(time.time())
252
269
 
253
270
 
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:])
271
+ def validate_nonce(nonce: int) -> None:
272
+ if not isinstance(nonce, int) or nonce < 1 or nonce > 0x1FFFFFFFFFFFFF:
273
+ raise AgentProtocolError("invalid_nonce", "nonce must be a positive integer less than or equal to 9007199254740991")
266
274
 
267
275
 
268
276
  def _base64url_encode(data: bytes) -> str:
@@ -272,3 +280,9 @@ def _base64url_encode(data: bytes) -> str:
272
280
  def _base64url_decode(value: str) -> bytes:
273
281
  padding = "=" * ((4 - len(value) % 4) % 4)
274
282
  return base64.urlsafe_b64decode(value + padding)
283
+
284
+
285
+ def _base64url_decode_no_pad(value: str) -> bytes:
286
+ if not re.fullmatch(r"[A-Za-z0-9_-]+", value):
287
+ raise AgentProtocolError("invalid_encoding", "expected base64url without padding")
288
+ return _base64url_decode(value)
@@ -12,34 +12,36 @@ ProfileUpdatePayload = dict[str, Any]
12
12
  AgentProfile = dict[str, Any]
13
13
 
14
14
 
15
- def profile_update_event(actor: AgentId, created_at: int, nonce: str, payload: ProfileUpdatePayload) -> Event:
15
+ def profile_update_event(actor: AgentId, created_at: int, nonce: int, payload: ProfileUpdatePayload) -> Event:
16
16
  return create_event(PROFILE_PROTOCOL, PROFILE_UPDATE, actor, created_at, nonce, payload)
17
17
 
18
18
 
19
19
  def validate_profile_update(envelope: Envelope) -> None:
20
20
  verify_envelope(envelope)
21
21
  event = envelope["event"]
22
+ payload_id = event["payload"].get("id") or event["payload"].get("agent_id")
22
23
  if event["protocol"] != PROFILE_PROTOCOL:
23
24
  raise AgentProtocolError("invalid_event_protocol", f"expected {PROFILE_PROTOCOL}, got {event['protocol']}")
24
25
  if event["type"] != PROFILE_UPDATE:
25
26
  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")
27
+ if event["actor"] != payload_id:
28
+ raise AgentProtocolError("invalid_actor", "profile update actor must match payload.id")
28
29
 
29
30
 
30
31
  def materialize_profile(envelope: Envelope) -> AgentProfile:
31
32
  validate_profile_update(envelope)
32
33
  payload = envelope["event"]["payload"]
34
+ payload_id = payload.get("id") or payload.get("agent_id")
33
35
  return {
34
- "agent_id": payload["agent_id"],
36
+ "id": payload_id,
35
37
  "name": payload["name"],
36
38
  "description": payload.get("description"),
37
39
  "avatar_url": payload.get("avatar_url"),
38
40
  "provider": payload.get("provider"),
39
41
  "capabilities": payload.get("capabilities", []),
40
42
  "service_endpoints": payload.get("service_endpoints", []),
41
- "links": payload.get("links", {}),
42
- "metadata": payload.get("metadata", {}),
43
+ "links": payload.get("links", []),
44
+ "extra": payload.get("extra", {}),
43
45
  "updated_at": envelope["event"]["created_at"],
44
- "profile_event_id": envelope["event_id"],
46
+ "event_id": envelope["hash"],
45
47
  }
@@ -1,13 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-protocols
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
5
5
  Author: LDCLabs
6
6
  License: MIT
7
7
  Keywords: agent,protocol,ed25519,sdk
8
8
  Requires-Python: >=3.10
9
9
  Description-Content-Type: text/markdown
10
- Requires-Dist: base58<3,>=2.1
11
10
  Requires-Dist: cryptography>=42
12
11
  Requires-Dist: rfc8785<0.2,>=0.1.4
13
12
  Provides-Extra: http
@@ -19,22 +18,23 @@ Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse prot
19
18
 
20
19
  ## Modules
21
20
 
22
- - `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event IDs, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
21
+ - `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event hashes, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
23
22
  - `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.
23
+ - `agent_protocols.discourse`: ADP event constants, join request helpers, room-path checks, permission and state helpers.
25
24
  - `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
26
25
 
27
26
  ## Example
28
27
 
29
28
  ```python
30
- from agent_protocols import AgentSigner, materialize_profile, profile_update_event, unix_time_millis
29
+ from agent_protocols import AgentSigner, ClientNonceManager, materialize_profile, profile_update_event, unix_ms
31
30
 
32
31
  signer = AgentSigner.generate()
32
+ nonces = ClientNonceManager()
33
33
  event = profile_update_event(
34
34
  signer.agent_id(),
35
- unix_time_millis(),
36
- "n_01J8Z6",
37
- {"agent_id": signer.agent_id(), "name": "ResearchAgent-v3"},
35
+ unix_ms(),
36
+ nonces.next_nonce(),
37
+ {"id": signer.agent_id(), "name": "ResearchAgent-v3"},
38
38
  )
39
39
  envelope = signer.sign_event(event)
40
40
  profile = materialize_profile(envelope)
@@ -1,4 +1,3 @@
1
- base58<3,>=2.1
2
1
  cryptography>=42
3
2
  rfc8785<0.2,>=0.1.4
4
3
 
@@ -1,15 +1,19 @@
1
1
  import unittest
2
2
 
3
3
  from agent_protocols.discourse import (
4
- MESSAGE_TEXT,
4
+ MESSAGE_CREATE,
5
5
  REACTION_CREATE,
6
6
  ROOM_CANCEL,
7
7
  ROOM_CREATE,
8
+ ROOM_JOIN,
9
+ ROOM_JOIN_REVIEW,
8
10
  can_accept_room_write,
9
11
  can_submit_event,
10
12
  room_create_event,
11
13
  validate_discourse_envelope,
14
+ validate_room_path,
12
15
  )
16
+ from agent_protocols.http_client import websocket_events_url
13
17
  from agent_protocols.identity import AgentSigner, create_event
14
18
 
15
19
 
@@ -19,16 +23,24 @@ class DiscourseTests(unittest.TestCase):
19
23
  event = room_create_event(
20
24
  signer.agent_id(),
21
25
  100,
22
- "n_room",
26
+ 1,
23
27
  {"topic": "Research room", "visibility": "public", "start_time": 1000, "end_time": 2000},
24
28
  )
25
29
  envelope = signer.sign_event(event)
26
30
 
27
31
  validate_discourse_envelope(envelope)
32
+ validate_room_path(envelope, "d8ftedhpqhsusbg001tg")
28
33
 
29
34
  def test_rejects_room_event_without_room_id(self):
30
35
  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"})
36
+ event = create_event(
37
+ "agent-discourse/1.0",
38
+ MESSAGE_CREATE,
39
+ signer.agent_id(),
40
+ 100,
41
+ 1,
42
+ {"content_type": "text/plain", "content": "hello"},
43
+ )
32
44
  envelope = signer.sign_event(event)
33
45
 
34
46
  with self.assertRaises(Exception):
@@ -36,17 +48,29 @@ class DiscourseTests(unittest.TestCase):
36
48
 
37
49
  def test_applies_permission_matrix(self):
38
50
  self.assertTrue(can_submit_event(REACTION_CREATE, {"role": "observer"}))
39
- self.assertFalse(can_submit_event(MESSAGE_TEXT, {"role": "observer"}))
51
+ self.assertFalse(can_submit_event(MESSAGE_CREATE, {"role": "observer"}))
52
+ self.assertFalse(can_submit_event(ROOM_JOIN, {"role": "observer"}))
53
+ self.assertTrue(can_submit_event(ROOM_JOIN, {"join_request_approved": True}))
54
+ self.assertTrue(can_submit_event(ROOM_JOIN_REVIEW, {"role": "moderator"}))
55
+ self.assertFalse(can_submit_event(ROOM_JOIN_REVIEW, {"role": "participant"}))
40
56
  self.assertFalse(can_submit_event(ROOM_CANCEL, {"role": "moderator"}))
41
57
  self.assertTrue(can_submit_event(ROOM_CANCEL, {"role": "moderator", "moderator_authorized": True}))
42
58
  self.assertTrue(can_submit_event(ROOM_CREATE, {}))
43
59
 
44
60
  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"}))
61
+ self.assertTrue(can_accept_room_write(MESSAGE_CREATE, "active", {"role": "participant"}))
62
+ self.assertFalse(can_accept_room_write(MESSAGE_CREATE, "scheduled", {"role": "participant"}))
63
+ self.assertTrue(can_accept_room_write(ROOM_JOIN_REVIEW, "scheduled", {"role": "moderator"}))
64
+ self.assertTrue(can_accept_room_write(ROOM_JOIN, "scheduled", {"join_request_approved": True}))
47
65
  self.assertFalse(can_accept_room_write(REACTION_CREATE, "ended", {"role": "participant"}))
48
66
  self.assertTrue(can_accept_room_write(REACTION_CREATE, "ended", {"role": "participant"}, post_end_reaction_allowed=True))
49
67
 
68
+ def test_builds_websocket_event_stream_url(self):
69
+ self.assertEqual(
70
+ websocket_events_url("https://api.example.com", "room123", "jwt.token"),
71
+ "wss://api.example.com/v1/rooms/room123/events/live?access_token=jwt.token",
72
+ )
73
+
50
74
 
51
75
  if __name__ == "__main__":
52
76
  unittest.main()
@@ -2,13 +2,14 @@ import unittest
2
2
 
3
3
  from agent_protocols.identity import (
4
4
  AgentSigner,
5
+ ClientNonceManager,
5
6
  MemoryNonceStore,
6
7
  RequestBinding,
7
8
  create_event,
8
9
  create_request_jwt_claims,
9
10
  verify_envelope,
10
11
  verify_live_envelope,
11
- verify_request_jwt_live,
12
+ verify_request_jwt,
12
13
  )
13
14
 
14
15
 
@@ -20,19 +21,20 @@ class IdentityTests(unittest.TestCase):
20
21
  "profile.update",
21
22
  signer.agent_id(),
22
23
  1_779_753_600_000,
23
- "n_test",
24
+ 1,
24
25
  {"agent_id": signer.agent_id(), "name": "ResearchAgent"},
25
26
  )
26
27
 
27
28
  envelope = signer.sign_event(event)
28
29
 
29
- self.assertTrue(envelope["event_id"].startswith("evt_z"))
30
+ self.assertEqual(len(envelope["hash"]), 43)
31
+ self.assertFalse(envelope["hash"].startswith("evt_"))
30
32
  verify_envelope(envelope)
31
33
 
32
34
  def test_rejects_tampered_payloads(self):
33
35
  signer = AgentSigner.from_seed(bytes([8]) * 32)
34
36
  envelope = signer.sign_event(
35
- create_event("agent-profile/1.0", "profile.update", signer.agent_id(), 1000, "n_test", {"name": "before"})
37
+ create_event("agent-profile/1.0", "profile.update", signer.agent_id(), 1000, 1, {"name": "before"})
36
38
  )
37
39
  envelope["event"]["payload"] = {"name": "after"}
38
40
 
@@ -42,44 +44,37 @@ class IdentityTests(unittest.TestCase):
42
44
  def test_rejects_nonce_reuse(self):
43
45
  signer = AgentSigner.from_seed(bytes([9]) * 32)
44
46
  envelope = signer.sign_event(
45
- create_event("agent-profile/1.0", "profile.update", signer.agent_id(), 1000, "n_reused", {"name": "ResearchAgent"})
47
+ create_event("agent-profile/1.0", "profile.update", signer.agent_id(), 1000, 1, {"name": "ResearchAgent"})
46
48
  )
47
49
  store = MemoryNonceStore()
48
50
 
49
- verify_live_envelope(envelope, store, now_ms=1000, window_ms=1000)
51
+ self.assertEqual(verify_live_envelope(envelope, store, now_ms=1000, window_ms=1000), 1)
50
52
  with self.assertRaises(Exception):
51
53
  verify_live_envelope(envelope, store, now_ms=1000, window_ms=1000)
52
54
 
55
+ def test_client_nonce_manager_observes_server_max(self):
56
+ manager = ClientNonceManager()
57
+
58
+ self.assertEqual(manager.next_nonce(), 1)
59
+ manager.observe_max_nonce("5")
60
+
61
+ self.assertEqual(manager.peek(), 6)
62
+ self.assertEqual(manager.next_nonce(), 6)
63
+
53
64
  def test_signs_and_verifies_request_jwts(self):
54
65
  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")
66
+ binding = RequestBinding.create("https://api.example.com")
67
+ claims = create_request_jwt_claims(signer.agent_id(), binding, 100, 300)
57
68
  token = signer.sign_request_jwt(claims)
58
- store = MemoryNonceStore()
59
69
 
60
- verified = verify_request_jwt_live(
70
+ verified = verify_request_jwt(
61
71
  token,
62
- store,
63
72
  audience=binding.audience,
64
- method=binding.method,
65
- host=binding.host,
66
- path=binding.path,
67
73
  now_secs=120,
68
74
  max_ttl_secs=300,
69
75
  )
70
76
 
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
- )
77
+ self.assertEqual(verified["iss"], signer.agent_id())
83
78
 
84
79
 
85
80
  if __name__ == "__main__":
@@ -0,0 +1,49 @@
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 = {
11
+ "id": signer.agent_id(),
12
+ "name": "ResearchAgent-v3",
13
+ "capabilities": ["research"],
14
+ "extra": {"domain": "research"},
15
+ "links": [{"name": "Homepage", "url": "https://example.com", "rel": "homepage"}],
16
+ }
17
+ envelope = signer.sign_event(profile_update_event(signer.agent_id(), 1_779_753_600_000, 1, payload))
18
+
19
+ profile = materialize_profile(envelope)
20
+
21
+ self.assertEqual(profile["id"], signer.agent_id())
22
+ self.assertEqual(profile["name"], "ResearchAgent-v3")
23
+ self.assertEqual(profile["links"], payload["links"])
24
+ self.assertEqual(profile["extra"], payload["extra"])
25
+ self.assertEqual(profile["updated_at"], 1_779_753_600_000)
26
+ self.assertEqual(profile["event_id"], envelope["hash"])
27
+
28
+ def test_rejects_actor_payload_mismatch(self):
29
+ signer = AgentSigner.from_seed(bytes([12]) * 32)
30
+ other = AgentSigner.from_seed(bytes([13]) * 32)
31
+ payload = {"id": other.agent_id(), "name": "Imposter"}
32
+ envelope = signer.sign_event(profile_update_event(signer.agent_id(), 1_779_753_600_000, 1, payload))
33
+
34
+ with self.assertRaises(Exception):
35
+ validate_profile_update(envelope)
36
+
37
+ def test_materializes_legacy_agent_id_payload(self):
38
+ signer = AgentSigner.from_seed(bytes([14]) * 32)
39
+ payload = {"agent_id": signer.agent_id(), "name": "LegacyAgent"}
40
+ envelope = signer.sign_event(profile_update_event(signer.agent_id(), 1_779_753_600_001, 1, payload))
41
+
42
+ profile = materialize_profile(envelope)
43
+
44
+ self.assertEqual(profile["id"], signer.agent_id())
45
+ self.assertEqual(profile["name"], "LegacyAgent")
46
+
47
+
48
+ if __name__ == "__main__":
49
+ unittest.main()
@@ -1,75 +0,0 @@
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()
@@ -1,30 +0,0 @@
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()