agent-protocols 0.2.2__tar.gz → 0.2.3__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 (18) hide show
  1. {agent_protocols-0.2.2 → agent_protocols-0.2.3}/PKG-INFO +3 -1
  2. {agent_protocols-0.2.2 → agent_protocols-0.2.3}/README.md +2 -0
  3. {agent_protocols-0.2.2 → agent_protocols-0.2.3}/pyproject.toml +1 -1
  4. {agent_protocols-0.2.2 → agent_protocols-0.2.3}/src/agent_protocols/discourse.py +76 -0
  5. {agent_protocols-0.2.2 → agent_protocols-0.2.3}/src/agent_protocols/profile.py +1 -0
  6. {agent_protocols-0.2.2 → agent_protocols-0.2.3}/src/agent_protocols.egg-info/PKG-INFO +3 -1
  7. {agent_protocols-0.2.2 → agent_protocols-0.2.3}/tests/test_discourse.py +46 -0
  8. {agent_protocols-0.2.2 → agent_protocols-0.2.3}/tests/test_profile.py +11 -0
  9. {agent_protocols-0.2.2 → agent_protocols-0.2.3}/setup.cfg +0 -0
  10. {agent_protocols-0.2.2 → agent_protocols-0.2.3}/src/agent_protocols/__init__.py +0 -0
  11. {agent_protocols-0.2.2 → agent_protocols-0.2.3}/src/agent_protocols/errors.py +0 -0
  12. {agent_protocols-0.2.2 → agent_protocols-0.2.3}/src/agent_protocols/http_client.py +0 -0
  13. {agent_protocols-0.2.2 → agent_protocols-0.2.3}/src/agent_protocols/identity.py +0 -0
  14. {agent_protocols-0.2.2 → agent_protocols-0.2.3}/src/agent_protocols.egg-info/SOURCES.txt +0 -0
  15. {agent_protocols-0.2.2 → agent_protocols-0.2.3}/src/agent_protocols.egg-info/dependency_links.txt +0 -0
  16. {agent_protocols-0.2.2 → agent_protocols-0.2.3}/src/agent_protocols.egg-info/requires.txt +0 -0
  17. {agent_protocols-0.2.2 → agent_protocols-0.2.3}/src/agent_protocols.egg-info/top_level.txt +0 -0
  18. {agent_protocols-0.2.2 → agent_protocols-0.2.3}/tests/test_identity.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-protocols
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
5
5
  Author: LDCLabs
6
6
  License: MIT
@@ -39,3 +39,5 @@ event = profile_update_event(
39
39
  envelope = signer.sign_event(event)
40
40
  profile = materialize_profile(envelope)
41
41
  ```
42
+
43
+ `username` is provider-confirmed and appears on Profile documents returned by a profile service. Do not put it in agent-submitted `profile.update` payloads.
@@ -25,3 +25,5 @@ event = profile_update_event(
25
25
  envelope = signer.sign_event(event)
26
26
  profile = materialize_profile(envelope)
27
27
  ```
28
+
29
+ `username` is provider-confirmed and appears on Profile documents returned by a profile service. Do not put it in agent-submitted `profile.update` payloads.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-protocols"
3
- version = "0.2.2"
3
+ version = "0.2.3"
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"
@@ -30,6 +30,10 @@ QUESTION_CREATE = "question.create"
30
30
  ROOM_STEER = "room.steer"
31
31
  MAP_UPDATE = "map.update"
32
32
  ARTIFACT_CREATE = "artifact.create"
33
+ SESSION_OFFER = "session.offer"
34
+ SESSION_ANSWER = "session.answer"
35
+ SESSION_CANDIDATE = "session.candidate"
36
+ SESSION_CLOSE = "session.close"
33
37
 
34
38
  KNOWN_EVENT_TYPES = {
35
39
  ROOM_CREATE,
@@ -51,11 +55,16 @@ KNOWN_EVENT_TYPES = {
51
55
  ROOM_STEER,
52
56
  MAP_UPDATE,
53
57
  ARTIFACT_CREATE,
58
+ SESSION_OFFER,
59
+ SESSION_ANSWER,
60
+ SESSION_CANDIDATE,
61
+ SESSION_CLOSE,
54
62
  }
55
63
 
56
64
  RoomState = Literal["scheduled", "active", "ended", "cancelled"]
57
65
  Role = Literal["moderator", "expert", "participant", "observer"]
58
66
  JoinRequestStatus = Literal["pending", "approved", "rejected", "expired"]
67
+ SESSION_MEDIA_KINDS = {"audio", "video", "screen", "data", "file"}
59
68
 
60
69
 
61
70
  class PermissionContext(TypedDict, total=False):
@@ -151,6 +160,36 @@ def validate_poll_vote_payload(payload: dict[str, Any], poll: dict[str, Any], no
151
160
  raise AgentProtocolError("invalid_poll_vote", "unknown poll option")
152
161
 
153
162
 
163
+ def validate_session_offer_payload(payload: dict[str, Any]) -> None:
164
+ _validate_session_id(payload.get("session_id"))
165
+ if payload.get("session_type") != "webrtc":
166
+ raise AgentProtocolError("invalid_session", "session_type must be webrtc")
167
+ media = payload.get("media", [])
168
+ if not isinstance(media, list) or not media:
169
+ raise AgentProtocolError("invalid_session", "media must not be empty")
170
+ if any(media_kind not in SESSION_MEDIA_KINDS for media_kind in media):
171
+ raise AgentProtocolError("invalid_session", "unsupported media kind")
172
+ _validate_session_description(payload.get("description"), "offer")
173
+ _validate_session_transfers(payload.get("transfers", []))
174
+
175
+
176
+ def validate_session_answer_payload(payload: dict[str, Any]) -> None:
177
+ _validate_session_id(payload.get("session_id"))
178
+ if not str(payload.get("offer_event_id", "")).strip():
179
+ raise AgentProtocolError("invalid_session", "offer_event_id is required")
180
+ _validate_session_description(payload.get("description"), "answer")
181
+ _validate_session_transfers(payload.get("transfers", []))
182
+
183
+
184
+ def validate_session_candidate_payload(payload: dict[str, Any]) -> None:
185
+ _validate_session_id(payload.get("session_id"))
186
+ if payload.get("end_of_candidates"):
187
+ return
188
+ candidate = payload.get("candidate") or {}
189
+ if not str(candidate.get("candidate", "")).strip():
190
+ raise AgentProtocolError("invalid_session", "candidate is required unless end_of_candidates is true")
191
+
192
+
154
193
  def server_record_hash_payload(
155
194
  room_id: str,
156
195
  seq: int,
@@ -279,6 +318,10 @@ def _moderator_can_submit(event_type: str, moderator_authorized: bool) -> bool:
279
318
  ROOM_STEER,
280
319
  MAP_UPDATE,
281
320
  ARTIFACT_CREATE,
321
+ SESSION_OFFER,
322
+ SESSION_ANSWER,
323
+ SESSION_CANDIDATE,
324
+ SESSION_CLOSE,
282
325
  MESSAGE_PROPOSAL_CREATE,
283
326
  MESSAGE_POLL_CREATE,
284
327
  MESSAGE_POLL_VOTE,
@@ -297,6 +340,10 @@ def _speaker_can_submit(event_type: str, policy_allowed: bool) -> bool:
297
340
  MESSAGE_PROPOSAL_CREATE,
298
341
  MESSAGE_POLL_CREATE,
299
342
  MESSAGE_POLL_VOTE,
343
+ SESSION_OFFER,
344
+ SESSION_ANSWER,
345
+ SESSION_CANDIDATE,
346
+ SESSION_CLOSE,
300
347
  REACTION_CREATE,
301
348
  ROOM_LEAVE,
302
349
  }
@@ -312,6 +359,35 @@ def _observer_can_submit(event_type: str, context: PermissionContext) -> bool:
312
359
  )
313
360
 
314
361
 
362
+ def _validate_session_id(session_id: Any) -> None:
363
+ if not str(session_id or "").strip():
364
+ raise AgentProtocolError("invalid_session", "session_id is required")
365
+
366
+
367
+ def _validate_session_description(description: Any, expected_type: str) -> None:
368
+ if not isinstance(description, dict):
369
+ raise AgentProtocolError("invalid_session", "session description is required")
370
+ if description.get("type") != expected_type:
371
+ raise AgentProtocolError("invalid_session", f"session description type must be {expected_type}")
372
+ if not str(description.get("sdp", "")).strip():
373
+ raise AgentProtocolError("invalid_session", "session description sdp is required")
374
+
375
+
376
+ def _validate_session_transfers(transfers: Any) -> None:
377
+ if transfers is None:
378
+ return
379
+ if not isinstance(transfers, list):
380
+ raise AgentProtocolError("invalid_session", "transfers must be an array")
381
+ for transfer in transfers:
382
+ if not isinstance(transfer, dict):
383
+ raise AgentProtocolError("invalid_session", "transfer must be an object")
384
+ if not str(transfer.get("transfer_id", "")).strip():
385
+ raise AgentProtocolError("invalid_session", "transfer_id is required")
386
+ size_bytes = transfer.get("size_bytes")
387
+ if size_bytes is not None and (not isinstance(size_bytes, int) or size_bytes < 0):
388
+ raise AgentProtocolError("invalid_session", "size_bytes must be a non-negative integer")
389
+
390
+
315
391
  def _hash_canonical_json(value: Any) -> str:
316
392
  canonical = rfc8785.dumps(value)
317
393
  data = canonical if isinstance(canonical, bytes) else canonical.encode()
@@ -35,6 +35,7 @@ def materialize_profile(envelope: Envelope) -> AgentProfile:
35
35
  return {
36
36
  "id": payload_id,
37
37
  "name": payload["name"],
38
+ "username": None,
38
39
  "description": payload.get("description"),
39
40
  "avatar_url": payload.get("avatar_url"),
40
41
  "provider": payload.get("provider"),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-protocols
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
5
5
  Author: LDCLabs
6
6
  License: MIT
@@ -39,3 +39,5 @@ event = profile_update_event(
39
39
  envelope = signer.sign_event(event)
40
40
  profile = materialize_profile(envelope)
41
41
  ```
42
+
43
+ `username` is provider-confirmed and appears on Profile documents returned by a profile service. Do not put it in agent-submitted `profile.update` payloads.
@@ -7,6 +7,8 @@ from agent_protocols.discourse import (
7
7
  ROOM_CREATE,
8
8
  ROOM_JOIN,
9
9
  ROOM_JOIN_REVIEW,
10
+ SESSION_CANDIDATE,
11
+ SESSION_OFFER,
10
12
  archive_events_digest,
11
13
  build_server_record,
12
14
  can_accept_room_write,
@@ -18,6 +20,9 @@ from agent_protocols.discourse import (
18
20
  validate_discourse_envelope,
19
21
  validate_room_create_payload,
20
22
  validate_room_path,
23
+ validate_session_answer_payload,
24
+ validate_session_candidate_payload,
25
+ validate_session_offer_payload,
21
26
  verify_server_record,
22
27
  verify_server_record_chain,
23
28
  )
@@ -63,6 +68,8 @@ class DiscourseTests(unittest.TestCase):
63
68
  self.assertFalse(can_submit_event(ROOM_JOIN_REVIEW, {"role": "participant"}))
64
69
  self.assertFalse(can_submit_event(ROOM_CANCEL, {"role": "moderator"}))
65
70
  self.assertTrue(can_submit_event(ROOM_CANCEL, {"role": "moderator", "moderator_authorized": True}))
71
+ self.assertTrue(can_submit_event(SESSION_OFFER, {"role": "participant"}))
72
+ self.assertFalse(can_submit_event(SESSION_CANDIDATE, {"role": "observer"}))
66
73
  self.assertTrue(can_submit_event(ROOM_CREATE, {}))
67
74
 
68
75
  def test_applies_state_restrictions(self):
@@ -111,6 +118,45 @@ class DiscourseTests(unittest.TestCase):
111
118
  }
112
119
  )
113
120
 
121
+ def test_validates_webrtc_session_payloads(self):
122
+ offer = {
123
+ "session_id": "sess_live_review",
124
+ "session_type": "webrtc",
125
+ "media": ["audio", "video", "file"],
126
+ "description": {"type": "offer", "sdp": "v=0\r\n..."},
127
+ "transfers": [
128
+ {
129
+ "transfer_id": "file_1",
130
+ "file_name": "trace.har",
131
+ "size_bytes": 1024,
132
+ "mime_type": "application/json",
133
+ "content_digest": "sha256:abc",
134
+ }
135
+ ],
136
+ }
137
+
138
+ validate_session_offer_payload(offer)
139
+ validate_session_answer_payload(
140
+ {
141
+ "session_id": "sess_live_review",
142
+ "offer_event_id": "evt_offer",
143
+ "description": {"type": "answer", "sdp": "v=0\r\n..."},
144
+ "accepted_media": ["audio", "file"],
145
+ }
146
+ )
147
+ validate_session_candidate_payload(
148
+ {
149
+ "session_id": "sess_live_review",
150
+ "candidate": {"candidate": "candidate:1 1 udp 1 127.0.0.1 3478 typ host"},
151
+ }
152
+ )
153
+ validate_session_candidate_payload({"session_id": "sess_live_review", "end_of_candidates": True})
154
+
155
+ with self.assertRaisesRegex(Exception, "offer"):
156
+ validate_session_offer_payload({**offer, "description": {"type": "answer", "sdp": "v=0\r\n..."}})
157
+ with self.assertRaisesRegex(Exception, "candidate"):
158
+ validate_session_candidate_payload({"session_id": "sess_live_review"})
159
+
114
160
  def test_builds_and_verifies_server_record_chains(self):
115
161
  signer = AgentSigner.from_seed(bytes([18]) * 32)
116
162
  envelope1 = signer.sign_event(
@@ -20,11 +20,22 @@ class ProfileTests(unittest.TestCase):
20
20
 
21
21
  self.assertEqual(profile["id"], signer.agent_id())
22
22
  self.assertEqual(profile["name"], "ResearchAgent-v3")
23
+ self.assertIsNone(profile["username"])
23
24
  self.assertEqual(profile["links"], payload["links"])
24
25
  self.assertEqual(profile["extra"], payload["extra"])
25
26
  self.assertEqual(profile["updated_at"], 1_779_753_600_000)
26
27
  self.assertEqual(profile["event_id"], envelope["hash"])
27
28
 
29
+ def test_does_not_materialize_unconfirmed_payload_username(self):
30
+ signer = AgentSigner.from_seed(bytes([15]) * 32)
31
+ payload = {"id": signer.agent_id(), "name": "ResearchAgent-v3", "username": "anda"}
32
+ envelope = signer.sign_event(profile_update_event(signer.agent_id(), 1_779_753_600_002, 1, payload))
33
+
34
+ profile = materialize_profile(envelope)
35
+
36
+ self.assertEqual(profile["id"], signer.agent_id())
37
+ self.assertIsNone(profile["username"])
38
+
28
39
  def test_rejects_actor_payload_mismatch(self):
29
40
  signer = AgentSigner.from_seed(bytes([12]) * 32)
30
41
  other = AgentSigner.from_seed(bytes([13]) * 32)