agent-protocols 0.1.0__tar.gz → 0.2.2__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 (22) hide show
  1. {agent_protocols-0.1.0 → agent_protocols-0.2.2}/PKG-INFO +8 -8
  2. {agent_protocols-0.1.0 → agent_protocols-0.2.2}/README.md +7 -6
  3. {agent_protocols-0.1.0 → agent_protocols-0.2.2}/pyproject.toml +2 -2
  4. {agent_protocols-0.1.0 → agent_protocols-0.2.2}/src/agent_protocols/__init__.py +14 -14
  5. agent_protocols-0.2.2/src/agent_protocols/discourse.py +319 -0
  6. agent_protocols-0.2.2/src/agent_protocols/http_client.py +130 -0
  7. {agent_protocols-0.1.0 → agent_protocols-0.2.2}/src/agent_protocols/identity.py +93 -73
  8. {agent_protocols-0.1.0 → agent_protocols-0.2.2}/src/agent_protocols/profile.py +9 -7
  9. {agent_protocols-0.1.0 → agent_protocols-0.2.2}/src/agent_protocols.egg-info/PKG-INFO +8 -8
  10. {agent_protocols-0.1.0 → agent_protocols-0.2.2}/src/agent_protocols.egg-info/requires.txt +0 -1
  11. agent_protocols-0.2.2/tests/test_discourse.py +154 -0
  12. {agent_protocols-0.1.0 → agent_protocols-0.2.2}/tests/test_identity.py +45 -22
  13. agent_protocols-0.2.2/tests/test_profile.py +47 -0
  14. agent_protocols-0.1.0/src/agent_protocols/discourse.py +0 -190
  15. agent_protocols-0.1.0/src/agent_protocols/http_client.py +0 -75
  16. agent_protocols-0.1.0/tests/test_discourse.py +0 -52
  17. agent_protocols-0.1.0/tests/test_profile.py +0 -30
  18. {agent_protocols-0.1.0 → agent_protocols-0.2.2}/setup.cfg +0 -0
  19. {agent_protocols-0.1.0 → agent_protocols-0.2.2}/src/agent_protocols/errors.py +0 -0
  20. {agent_protocols-0.1.0 → agent_protocols-0.2.2}/src/agent_protocols.egg-info/SOURCES.txt +0 -0
  21. {agent_protocols-0.1.0 → agent_protocols-0.2.2}/src/agent_protocols.egg-info/dependency_links.txt +0 -0
  22. {agent_protocols-0.1.0 → agent_protocols-0.2.2}/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.2
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.2"
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",
@@ -0,0 +1,319 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ from typing import Any, Literal, TypedDict
6
+
7
+ import rfc8785
8
+
9
+ from .errors import AgentProtocolError
10
+ from .identity import AgentId, Envelope, Event, create_event, verify_envelope, with_room_id
11
+
12
+ DISCOURSE_PROTOCOL = "agent-discourse/1.0"
13
+
14
+ ROOM_CREATE = "room.create"
15
+ ROOM_JOIN = "room.join"
16
+ ROOM_JOIN_REVIEW = "room.join.review"
17
+ ROOM_LEAVE = "room.leave"
18
+ ROOM_MEMBER_ROLE_UPDATE = "room.member.role.update"
19
+ ROOM_CLOSE = "room.close"
20
+ ROOM_CANCEL = "room.cancel"
21
+ MESSAGE_CREATE = "message.create"
22
+ REACTION_CREATE = "reaction.create"
23
+ MESSAGE_PROPOSAL_CREATE = "message.proposal.create"
24
+ MESSAGE_POLL_CREATE = "message.poll.create"
25
+ MESSAGE_POLL_VOTE = "message.poll.vote"
26
+ MESSAGE_RESOLUTION_CREATE = "message.resolution.create"
27
+ SOURCE_ADD = "source.add"
28
+ TURN_UPDATE = "turn.update"
29
+ QUESTION_CREATE = "question.create"
30
+ ROOM_STEER = "room.steer"
31
+ MAP_UPDATE = "map.update"
32
+ ARTIFACT_CREATE = "artifact.create"
33
+
34
+ KNOWN_EVENT_TYPES = {
35
+ ROOM_CREATE,
36
+ ROOM_JOIN,
37
+ ROOM_JOIN_REVIEW,
38
+ ROOM_LEAVE,
39
+ ROOM_MEMBER_ROLE_UPDATE,
40
+ ROOM_CLOSE,
41
+ ROOM_CANCEL,
42
+ MESSAGE_CREATE,
43
+ REACTION_CREATE,
44
+ MESSAGE_PROPOSAL_CREATE,
45
+ MESSAGE_POLL_CREATE,
46
+ MESSAGE_POLL_VOTE,
47
+ MESSAGE_RESOLUTION_CREATE,
48
+ SOURCE_ADD,
49
+ TURN_UPDATE,
50
+ QUESTION_CREATE,
51
+ ROOM_STEER,
52
+ MAP_UPDATE,
53
+ ARTIFACT_CREATE,
54
+ }
55
+
56
+ RoomState = Literal["scheduled", "active", "ended", "cancelled"]
57
+ Role = Literal["moderator", "expert", "participant", "observer"]
58
+ JoinRequestStatus = Literal["pending", "approved", "rejected", "expired"]
59
+
60
+
61
+ class PermissionContext(TypedDict, total=False):
62
+ role: Role
63
+ is_creator: bool
64
+ join_request_approved: bool
65
+ moderator_authorized: bool
66
+ expert_policy_allowed: bool
67
+ participant_policy_allowed: bool
68
+ observer_steering_allowed: bool
69
+ observer_poll_vote_allowed: bool
70
+
71
+
72
+ def room_create_event(actor: AgentId, created_at: int, nonce: int, payload: dict[str, Any]) -> Event:
73
+ return create_event(DISCOURSE_PROTOCOL, ROOM_CREATE, actor, created_at, nonce, payload)
74
+
75
+
76
+ def discourse_event(event_type: str, actor: AgentId, created_at: int, nonce: int, room_id: str, payload: Any) -> Event:
77
+ return with_room_id(create_event(DISCOURSE_PROTOCOL, event_type, actor, created_at, nonce, payload), room_id)
78
+
79
+
80
+ def validate_discourse_envelope(envelope: Envelope) -> None:
81
+ verify_envelope(envelope)
82
+ event = envelope["event"]
83
+ protocol = event["protocol"]
84
+ if protocol != DISCOURSE_PROTOCOL:
85
+ raise AgentProtocolError("invalid_event_protocol", f"expected {DISCOURSE_PROTOCOL}, got {protocol}")
86
+ if event_requires_room_id(event["type"]) and "room_id" not in event:
87
+ raise AgentProtocolError("missing_room_id", "event requires a room_id")
88
+
89
+
90
+ def validate_room_path(envelope: Envelope, path_room_id: str) -> None:
91
+ actual = envelope["event"].get("room_id")
92
+ if actual is None and envelope["event"]["type"] == ROOM_CREATE:
93
+ return
94
+ if actual is None:
95
+ raise AgentProtocolError("missing_room_id", "event requires a room_id")
96
+ if actual != path_room_id:
97
+ raise AgentProtocolError("room_id_mismatch", f"expected {path_room_id}, got {actual}")
98
+
99
+
100
+ def event_requires_room_id(event_type: str) -> bool:
101
+ return event_type != ROOM_CREATE
102
+
103
+
104
+ def validate_room_create_payload(payload: dict[str, Any]) -> None:
105
+ if not str(payload.get("topic", "")).strip():
106
+ raise AgentProtocolError("invalid_room", "room topic must not be empty")
107
+ if payload.get("start_time", 0) >= payload.get("end_time", 0):
108
+ raise AgentProtocolError("invalid_room", "start_time must be before end_time")
109
+ policy = payload.get("policy") or {}
110
+ max_participants = policy.get("max_participants")
111
+ if max_participants is not None and (
112
+ not isinstance(max_participants, int) or max_participants < 1
113
+ ):
114
+ raise AgentProtocolError("invalid_room", "max_participants must be a positive integer")
115
+
116
+
117
+ def validate_poll_create_payload(payload: dict[str, Any]) -> None:
118
+ if not str(payload.get("poll_id", "")).strip() or not str(payload.get("question", "")).strip():
119
+ raise AgentProtocolError("invalid_poll", "poll_id and question are required")
120
+ options = payload.get("options", [])
121
+ if len(options) < 2:
122
+ raise AgentProtocolError("invalid_poll", "poll requires at least two options")
123
+ option_ids: set[str] = set()
124
+ for option in options:
125
+ option_id = str(option.get("id", ""))
126
+ label = str(option.get("label", ""))
127
+ if not option_id.strip() or not label.strip():
128
+ raise AgentProtocolError("invalid_poll", "option id and label are required")
129
+ if option_id in option_ids:
130
+ raise AgentProtocolError("invalid_poll", "poll option ids must be unique")
131
+ option_ids.add(option_id)
132
+ min_choices = payload.get("min_choices", 1)
133
+ max_choices = payload.get("max_choices", 1)
134
+ if min_choices < 1 or max_choices < min_choices:
135
+ raise AgentProtocolError("invalid_poll", "invalid poll choice limits")
136
+
137
+
138
+ def validate_poll_vote_payload(payload: dict[str, Any], poll: dict[str, Any], now_ms: int | None = None) -> None:
139
+ if poll.get("closes_at") is not None and now_ms is not None and now_ms > poll["closes_at"]:
140
+ raise AgentProtocolError("poll_closed", "poll is closed")
141
+ min_choices = poll.get("min_choices", 1)
142
+ max_choices = poll.get("max_choices", 1)
143
+ option_ids = {option["id"] for option in poll.get("options", [])}
144
+ selected = payload.get("option_ids", [])
145
+ selected_set = set(selected)
146
+ if len(selected_set) != len(selected):
147
+ raise AgentProtocolError("invalid_poll_vote", "duplicate poll options")
148
+ if len(selected_set) < min_choices or len(selected_set) > max_choices:
149
+ raise AgentProtocolError("invalid_poll_vote", "invalid number of options")
150
+ if any(option_id not in option_ids for option_id in selected_set):
151
+ raise AgentProtocolError("invalid_poll_vote", "unknown poll option")
152
+
153
+
154
+ def server_record_hash_payload(
155
+ room_id: str,
156
+ seq: int,
157
+ pre_hash: str | None,
158
+ envelope_hash: str,
159
+ received_at: int,
160
+ ) -> dict[str, Any]:
161
+ return {
162
+ "room_id": room_id,
163
+ "seq": seq,
164
+ "pre_hash": pre_hash,
165
+ "envelope_hash": envelope_hash,
166
+ "received_at": received_at,
167
+ }
168
+
169
+
170
+ def server_record_hash(
171
+ room_id: str,
172
+ seq: int,
173
+ pre_hash: str | None,
174
+ envelope_hash: str,
175
+ received_at: int,
176
+ ) -> str:
177
+ return _hash_canonical_json(server_record_hash_payload(room_id, seq, pre_hash, envelope_hash, received_at))
178
+
179
+
180
+ def build_server_record(
181
+ room_id: str,
182
+ seq: int,
183
+ pre_hash: str | None,
184
+ received_at: int,
185
+ envelope: Envelope,
186
+ ) -> dict[str, Any]:
187
+ return {
188
+ "room_id": room_id,
189
+ "seq": seq,
190
+ "pre_hash": pre_hash,
191
+ "hash": server_record_hash(room_id, seq, pre_hash, envelope["hash"], received_at),
192
+ "received_at": received_at,
193
+ "envelope": envelope,
194
+ }
195
+
196
+
197
+ def verify_server_record(record: dict[str, Any]) -> None:
198
+ expected = server_record_hash(
199
+ record["room_id"],
200
+ record["seq"],
201
+ record.get("pre_hash"),
202
+ record["envelope"]["hash"],
203
+ record["received_at"],
204
+ )
205
+ if record["hash"] != expected:
206
+ raise AgentProtocolError("invalid_record_hash", f"invalid server record hash: expected {expected}, got {record['hash']}")
207
+
208
+
209
+ def verify_server_record_chain(records: list[dict[str, Any]]) -> None:
210
+ previous: dict[str, Any] | None = None
211
+ for record in records:
212
+ verify_server_record(record)
213
+ if previous is None:
214
+ if record["seq"] != 1:
215
+ raise AgentProtocolError("invalid_record_chain", "first seq must be 1")
216
+ if record.get("pre_hash") is not None:
217
+ raise AgentProtocolError("invalid_record_chain", "first pre_hash must be null")
218
+ else:
219
+ if record["seq"] != previous["seq"] + 1:
220
+ raise AgentProtocolError("invalid_record_chain", "seq must increase by 1")
221
+ if record.get("pre_hash") != previous["hash"]:
222
+ raise AgentProtocolError("invalid_record_chain", "pre_hash mismatch")
223
+ previous = record
224
+
225
+
226
+ def archive_events_digest(records: list[dict[str, Any]]) -> str:
227
+ return _hash_canonical_json(records)
228
+
229
+
230
+ def can_submit_event(event_type: str, context: PermissionContext) -> bool:
231
+ if event_type == ROOM_CREATE:
232
+ return True
233
+ if event_type == ROOM_JOIN:
234
+ return context.get("join_request_approved", False)
235
+ if context.get("is_creator"):
236
+ return event_type in KNOWN_EVENT_TYPES
237
+
238
+ role = context.get("role")
239
+ if role == "moderator":
240
+ return _moderator_can_submit(event_type, context.get("moderator_authorized", False))
241
+ if role == "expert":
242
+ return _speaker_can_submit(event_type, context.get("expert_policy_allowed", False))
243
+ if role == "participant":
244
+ return _speaker_can_submit(event_type, context.get("participant_policy_allowed", False))
245
+ if role == "observer":
246
+ return _observer_can_submit(event_type, context)
247
+ return False
248
+
249
+
250
+ def can_write_in_state(event_type: str, state: RoomState, *, post_end_reaction_allowed: bool = False) -> bool:
251
+ if state == "scheduled":
252
+ return event_type in {ROOM_JOIN, ROOM_JOIN_REVIEW, ROOM_CANCEL}
253
+ if state == "active":
254
+ return event_type not in {ROOM_CREATE, ROOM_CANCEL}
255
+ if state == "ended":
256
+ return post_end_reaction_allowed and event_type == REACTION_CREATE
257
+ if state == "cancelled":
258
+ return False
259
+ return False
260
+
261
+
262
+ def can_accept_room_write(event_type: str, state: RoomState, context: PermissionContext, *, post_end_reaction_allowed: bool = False) -> bool:
263
+ return can_submit_event(event_type, context) and can_write_in_state(event_type, state, post_end_reaction_allowed=post_end_reaction_allowed)
264
+
265
+
266
+ def validate_room_write(event_type: str, state: RoomState, context: PermissionContext, *, post_end_reaction_allowed: bool = False) -> None:
267
+ if not can_accept_room_write(event_type, state, context, post_end_reaction_allowed=post_end_reaction_allowed):
268
+ raise AgentProtocolError("permission_denied", "actor lacks permission or state is not writable")
269
+
270
+
271
+ def _moderator_can_submit(event_type: str, moderator_authorized: bool) -> bool:
272
+ allowed = {
273
+ ROOM_JOIN_REVIEW,
274
+ ROOM_CLOSE,
275
+ MESSAGE_CREATE,
276
+ SOURCE_ADD,
277
+ TURN_UPDATE,
278
+ QUESTION_CREATE,
279
+ ROOM_STEER,
280
+ MAP_UPDATE,
281
+ ARTIFACT_CREATE,
282
+ MESSAGE_PROPOSAL_CREATE,
283
+ MESSAGE_POLL_CREATE,
284
+ MESSAGE_POLL_VOTE,
285
+ MESSAGE_RESOLUTION_CREATE,
286
+ REACTION_CREATE,
287
+ ROOM_LEAVE,
288
+ }
289
+ return event_type in allowed or (moderator_authorized and event_type in {ROOM_MEMBER_ROLE_UPDATE, ROOM_CANCEL})
290
+
291
+
292
+ def _speaker_can_submit(event_type: str, policy_allowed: bool) -> bool:
293
+ allowed = {
294
+ MESSAGE_CREATE,
295
+ SOURCE_ADD,
296
+ ROOM_STEER,
297
+ MESSAGE_PROPOSAL_CREATE,
298
+ MESSAGE_POLL_CREATE,
299
+ MESSAGE_POLL_VOTE,
300
+ REACTION_CREATE,
301
+ ROOM_LEAVE,
302
+ }
303
+ policy_events = {QUESTION_CREATE, MAP_UPDATE, ARTIFACT_CREATE, MESSAGE_RESOLUTION_CREATE}
304
+ return event_type in allowed or (policy_allowed and event_type in policy_events)
305
+
306
+
307
+ def _observer_can_submit(event_type: str, context: PermissionContext) -> bool:
308
+ return (
309
+ event_type in {REACTION_CREATE, ROOM_LEAVE}
310
+ or (context.get("observer_steering_allowed", False) and event_type == ROOM_STEER)
311
+ or (context.get("observer_poll_vote_allowed", False) and event_type == MESSAGE_POLL_VOTE)
312
+ )
313
+
314
+
315
+ def _hash_canonical_json(value: Any) -> str:
316
+ canonical = rfc8785.dumps(value)
317
+ data = canonical if isinstance(canonical, bytes) else canonical.encode()
318
+ digest = hashlib.sha3_256(data).digest()
319
+ return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
@@ -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()