agent-protocols 0.4.1__tar.gz → 0.5.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 (25) hide show
  1. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/PKG-INFO +3 -3
  2. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/README.md +2 -2
  3. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/pyproject.toml +1 -1
  4. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols/__init__.py +13 -1
  5. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols/delegation.py +26 -2
  6. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols/discourse.py +155 -3
  7. agent_protocols-0.5.0/src/agent_protocols/errors.py +6 -0
  8. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols/http_client.py +34 -4
  9. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols/identity.py +44 -9
  10. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols/profile.py +10 -1
  11. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols.egg-info/PKG-INFO +3 -3
  12. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/tests/test_delegation.py +42 -0
  13. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/tests/test_discourse.py +114 -0
  14. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/tests/test_identity.py +83 -0
  15. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/tests/test_profile.py +28 -4
  16. agent_protocols-0.4.1/src/agent_protocols/errors.py +0 -4
  17. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/setup.cfg +0 -0
  18. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols.egg-info/SOURCES.txt +0 -0
  19. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols.egg-info/dependency_links.txt +0 -0
  20. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols.egg-info/requires.txt +0 -0
  21. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols.egg-info/top_level.txt +0 -0
  22. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/tests/test_discourse_coverage.py +0 -0
  23. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/tests/test_http_client.py +0 -0
  24. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/tests/test_identity_coverage.py +0 -0
  25. {agent_protocols-0.4.1 → agent_protocols-0.5.0}/tests/test_profile_coverage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-protocols
3
- Version: 0.4.1
3
+ Version: 0.5.0
4
4
  Summary: Python SDK for Agent Identity, Agent Profile, Agent Delegation, and Agent Discourse protocols
5
5
  Author: LDCLabs
6
6
  License: MIT
@@ -42,6 +42,6 @@ envelope = signer.sign_event(event)
42
42
  profile = materialize_profile(envelope)
43
43
  ```
44
44
 
45
- `username` is provider-confirmed and appears on Profile documents returned by a profile service. Do not put it in agent-submitted `profile.update` payloads.
45
+ Agent Profile has no `username` field: the Agent ID is the identity key, and the latest profile is the accepted `profile.update` with the greatest `nonce`.
46
46
 
47
- ADP room writes are signed against the current room head. Use `discourse_event` or `type_define_event` with `base_seq` and `base_hash`. Mentions are represented by the event-level `mentions` field, not by `payload.extra`.
47
+ ADP room writes declare a signed `base_seq` / `base_hash`: discussion and contract writes must match the current room head, while `signal`-kind writes — including the built-in membership events — only anchor to an accepted record and never contend for the head. Use `discourse_event` or `type_define_event` with `base_seq` and `base_hash`. Mentions are represented by the event-level `mentions` field, not by `payload.extra`.
@@ -27,6 +27,6 @@ envelope = signer.sign_event(event)
27
27
  profile = materialize_profile(envelope)
28
28
  ```
29
29
 
30
- `username` is provider-confirmed and appears on Profile documents returned by a profile service. Do not put it in agent-submitted `profile.update` payloads.
30
+ Agent Profile has no `username` field: the Agent ID is the identity key, and the latest profile is the accepted `profile.update` with the greatest `nonce`.
31
31
 
32
- ADP room writes are signed against the current room head. Use `discourse_event` or `type_define_event` with `base_seq` and `base_hash`. Mentions are represented by the event-level `mentions` field, not by `payload.extra`.
32
+ ADP room writes declare a signed `base_seq` / `base_hash`: discussion and contract writes must match the current room head, while `signal`-kind writes — including the built-in membership events — only anchor to an accepted record and never contend for the head. Use `discourse_event` or `type_define_event` with `base_seq` and `base_hash`. Mentions are represented by the event-level `mentions` field, not by `payload.extra`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-protocols"
3
- version = "0.4.1"
3
+ version = "0.5.0"
4
4
  description = "Python SDK for Agent Identity, Agent Profile, Agent Delegation, and Agent Discourse protocols"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -13,6 +13,7 @@ from .identity import (
13
13
  event_hash,
14
14
  event_hash_bytes,
15
15
  public_key_bytes,
16
+ service_origin,
16
17
  sign_event,
17
18
  sign_event_hash,
18
19
  unix_ms,
@@ -31,7 +32,14 @@ from .identity import (
31
32
  with_room_head,
32
33
  with_room_id,
33
34
  )
34
- from .profile import PROFILE_PROTOCOL, PROFILE_UPDATE, materialize_profile, profile_update_event, validate_profile_update
35
+ from .profile import (
36
+ PROFILE_PROTOCOL,
37
+ PROFILE_UPDATE,
38
+ latest_profile_update,
39
+ materialize_profile,
40
+ profile_update_event,
41
+ validate_profile_update,
42
+ )
35
43
  from .delegation import (
36
44
  DELEGATION_GRANT,
37
45
  DELEGATION_PROTOCOL,
@@ -41,6 +49,7 @@ from .delegation import (
41
49
  materialize_delegation_credential,
42
50
  validate_delegation_envelope,
43
51
  validate_delegation_grant_payload,
52
+ validate_delegation_query_request,
44
53
  validate_delegation_revoke_payload,
45
54
  validate_principal_document,
46
55
  )
@@ -66,10 +75,12 @@ __all__ = [
66
75
  "create_request_jwt_claims",
67
76
  "event_hash",
68
77
  "event_hash_bytes",
78
+ "latest_profile_update",
69
79
  "materialize_profile",
70
80
  "materialize_delegation_credential",
71
81
  "profile_update_event",
72
82
  "public_key_bytes",
83
+ "service_origin",
73
84
  "sign_event",
74
85
  "sign_event_hash",
75
86
  "unix_ms",
@@ -77,6 +88,7 @@ __all__ = [
77
88
  "validate_agent_id",
78
89
  "validate_delegation_envelope",
79
90
  "validate_delegation_grant_payload",
91
+ "validate_delegation_query_request",
80
92
  "validate_delegation_revoke_payload",
81
93
  "validate_nonce",
82
94
  "validate_principal_document",
@@ -65,12 +65,36 @@ def validate_delegation_grant_payload(
65
65
  raise AgentProtocolError("invalid_delegation", "constraints must be an object")
66
66
  expires_at = payload.get("expires_at")
67
67
  if expires_at is not None:
68
- not_before = payload.get("not_before", created_at)
68
+ not_before = payload.get("not_before")
69
69
  if not_before is not None and expires_at <= not_before:
70
70
  raise AgentProtocolError(
71
71
  "invalid_delegation",
72
- "expires_at must be greater than not_before or created_at",
72
+ "expires_at must be greater than not_before",
73
73
  )
74
+ if created_at is not None and expires_at <= created_at:
75
+ raise AgentProtocolError(
76
+ "invalid_delegation",
77
+ "expires_at must be greater than created_at",
78
+ )
79
+
80
+
81
+ def validate_delegation_query_request(request: dict[str, Any]) -> None:
82
+ """A delegation query MUST include at least one of `subject` or
83
+ `principal_id`. `limit` defaults to 20; services SHOULD cap it at 100."""
84
+ subject = request.get("subject")
85
+ principal_id = request.get("principal_id")
86
+ if subject is None and principal_id is None:
87
+ raise AgentProtocolError(
88
+ "invalid_delegation",
89
+ "query must include at least one of subject or principal_id",
90
+ )
91
+ if subject is not None:
92
+ validate_agent_id(subject)
93
+ if principal_id is not None:
94
+ _validate_https_url(principal_id, "principal_id")
95
+ limit = request.get("limit")
96
+ if limit is not None and (not isinstance(limit, int) or limit < 1):
97
+ raise AgentProtocolError("invalid_delegation", "limit must be a positive integer")
74
98
 
75
99
 
76
100
  def validate_delegation_revoke_payload(payload: DelegationRevokePayload) -> None:
@@ -1,7 +1,7 @@
1
1
  """Agent Discourse Protocol 1.0: kernel types, the room type system, and
2
2
  verification helpers.
3
3
 
4
- The protocol defines nine built-in event types. Every other event type is
4
+ The protocol defines eleven built-in event types. Every other event type is
5
5
  declared per room as a schema-validated type definition, either inline or
6
6
  imported from a type pack. Hosts validate structure and permissions; they
7
7
  never need to understand application semantics.
@@ -32,12 +32,14 @@ from .identity import (
32
32
 
33
33
  DISCOURSE_PROTOCOL = "agent-discourse/1.0"
34
34
 
35
- # The nine built-in event types. All other types are room-defined.
35
+ # The eleven built-in event types. All other types are room-defined.
36
36
  ROOM_CREATE = "room.create"
37
+ ROOM_UPDATE = "room.update"
37
38
  ROOM_JOIN = "room.join"
38
39
  ROOM_JOIN_REVIEW = "room.join.review"
39
40
  ROOM_LEAVE = "room.leave"
40
41
  ROOM_MEMBER_ROLE_UPDATE = "room.member.role.update"
42
+ ROOM_MEMBER_REMOVE = "room.member.remove"
41
43
  ROOM_CLOSE = "room.close"
42
44
  ROOM_CANCEL = "room.cancel"
43
45
  TYPE_DEFINE = "type.define"
@@ -45,16 +47,67 @@ MESSAGE_CREATE = "message.create"
45
47
 
46
48
  BUILTIN_EVENT_TYPES = {
47
49
  ROOM_CREATE,
50
+ ROOM_UPDATE,
48
51
  ROOM_JOIN,
49
52
  ROOM_JOIN_REVIEW,
50
53
  ROOM_LEAVE,
51
54
  ROOM_MEMBER_ROLE_UPDATE,
55
+ ROOM_MEMBER_REMOVE,
52
56
  ROOM_CLOSE,
53
57
  ROOM_CANCEL,
54
58
  TYPE_DEFINE,
55
59
  MESSAGE_CREATE,
56
60
  }
57
61
 
62
+ # Built-in membership events. They carry the `signal` class: they anchor to
63
+ # an accepted record but never contend for or advance the room head, so busy
64
+ # rooms cannot starve joins, reviews, or other membership writes.
65
+ MEMBERSHIP_EVENT_TYPES = (
66
+ ROOM_JOIN,
67
+ ROOM_JOIN_REVIEW,
68
+ ROOM_LEAVE,
69
+ ROOM_MEMBER_ROLE_UPDATE,
70
+ ROOM_MEMBER_REMOVE,
71
+ )
72
+
73
+ # Standard ADP error codes from the Section 20 table.
74
+ DISCOURSE_ERROR_CODES = frozenset(
75
+ {
76
+ "invalid_event",
77
+ "invalid_event_hash",
78
+ "invalid_signature",
79
+ "invalid_actor",
80
+ "timestamp_out_of_window",
81
+ "nonce_not_greater",
82
+ "room_not_found",
83
+ "room_not_active",
84
+ "room_ended",
85
+ "permission_denied",
86
+ "approval_required",
87
+ "join_request_not_found",
88
+ "join_request_not_approved",
89
+ "join_request_role_mismatch",
90
+ "join_request_expired",
91
+ "member_banned",
92
+ "role_not_allowed",
93
+ "max_speakers_exceeded",
94
+ "membership_required",
95
+ "invalid_token",
96
+ "room_head_mismatch",
97
+ "base_record_mismatch",
98
+ "agent_status_not_found",
99
+ "rate_limited",
100
+ "payload_too_large",
101
+ "type_not_defined",
102
+ "type_disabled",
103
+ "payload_schema_violation",
104
+ "pack_unavailable",
105
+ }
106
+ )
107
+
108
+ # Hosts MUST reject events with more than this many `mentions` entries.
109
+ MAX_MENTIONS = 32
110
+
58
111
  # Custom event types must not use these prefixes.
59
112
  RESERVED_TYPE_PREFIXES = ("room.", "type.")
60
113
 
@@ -171,6 +224,42 @@ def event_requires_room_id(event_type: str) -> bool:
171
224
  return event_type != ROOM_CREATE
172
225
 
173
226
 
227
+ BuiltinEventClass = Literal["lifecycle", "signal", "control", "message"]
228
+
229
+ _BUILTIN_EVENT_CLASSES: dict[str, BuiltinEventClass] = {
230
+ ROOM_CREATE: "lifecycle",
231
+ ROOM_UPDATE: "lifecycle",
232
+ ROOM_CLOSE: "lifecycle",
233
+ ROOM_CANCEL: "lifecycle",
234
+ ROOM_JOIN: "signal",
235
+ ROOM_JOIN_REVIEW: "signal",
236
+ ROOM_LEAVE: "signal",
237
+ ROOM_MEMBER_ROLE_UPDATE: "signal",
238
+ ROOM_MEMBER_REMOVE: "signal",
239
+ TYPE_DEFINE: "control",
240
+ MESSAGE_CREATE: "message",
241
+ }
242
+
243
+
244
+ def builtin_event_class(event_type: str) -> BuiltinEventClass | None:
245
+ """Section 13.2 class of a built-in type; ``None`` for room-defined types."""
246
+ return _BUILTIN_EVENT_CLASSES.get(event_type)
247
+
248
+
249
+ def event_advances_room_head(event_type: str, registry: "TypeRegistry | None" = None) -> bool:
250
+ """Whether an accepted record of this type advances the room head and must
251
+ therefore match the current head when written (Section 6.1). Room
252
+ lifecycle, `message`-kind, and `control`-kind records advance the head;
253
+ `signal`-kind records — including the built-in membership events — only
254
+ anchor to an accepted record. Unknown custom types default to
255
+ head-advancing."""
256
+ builtin_class = builtin_event_class(event_type)
257
+ if builtin_class is not None:
258
+ return builtin_class != "signal"
259
+ definition = registry.get(event_type) if registry is not None else None
260
+ return definition is None or definition.get("kind") != "signal"
261
+
262
+
174
263
  def validate_discourse_envelope(envelope: Envelope) -> None:
175
264
  verify_envelope(envelope)
176
265
  event = envelope["event"]
@@ -222,8 +311,17 @@ def _validate_mentions(mentions: Any) -> None:
222
311
  return
223
312
  if not isinstance(mentions, list):
224
313
  raise AgentProtocolError("invalid_event", "mentions must be an Agent ID array")
314
+ if len(mentions) > MAX_MENTIONS:
315
+ raise AgentProtocolError("invalid_event", f"mentions must not exceed {MAX_MENTIONS} entries")
316
+ # Validate each entry before testing uniqueness: a non-string mention would
317
+ # otherwise raise a raw TypeError from set() instead of a clean protocol
318
+ # error.
319
+ seen: set[str] = set()
225
320
  for mention in mentions:
226
321
  validate_agent_id(mention)
322
+ if mention in seen:
323
+ raise AgentProtocolError("invalid_event", "mentions must be unique")
324
+ seen.add(mention)
227
325
 
228
326
 
229
327
  def validate_custom_event_type_name(name: str) -> None:
@@ -334,6 +432,12 @@ class TypeRegistry:
334
432
 
335
433
  def define(self, definition: dict[str, Any]) -> None:
336
434
  validate_type_def(definition)
435
+ existing = self._types.get(definition["type"])
436
+ if existing is not None and existing.get("kind") != definition.get("kind"):
437
+ raise AgentProtocolError(
438
+ "invalid_event",
439
+ f"type {definition['type']} cannot change kind on redefinition",
440
+ )
337
441
  self._types[definition["type"]] = definition
338
442
 
339
443
  def _import(self, declaration: dict[str, Any], packs: dict[str, dict[str, Any]]) -> None:
@@ -452,6 +556,44 @@ def validate_room_join_payload(payload: dict[str, Any]) -> None:
452
556
  )
453
557
 
454
558
 
559
+ _ROOM_UPDATE_FIELDS = frozenset(
560
+ {"topic", "agenda", "guidance", "tags", "language", "policy", "start_time", "end_time"}
561
+ )
562
+
563
+
564
+ def validate_room_update_payload(payload: dict[str, Any]) -> None:
565
+ """Shape checks for a `room.update` payload. State-dependent rules — room
566
+ status, effective time ordering against the current contract — remain
567
+ host-side."""
568
+ if not payload:
569
+ raise AgentProtocolError("invalid_event", "room.update payload must not be empty")
570
+ for field in payload:
571
+ if field not in _ROOM_UPDATE_FIELDS:
572
+ raise AgentProtocolError(
573
+ "invalid_event", f"room.update payload field {field} is not updatable"
574
+ )
575
+ topic = payload.get("topic")
576
+ if topic is not None and not str(topic).strip():
577
+ raise AgentProtocolError("invalid_event", "room topic must not be empty")
578
+ start_time = payload.get("start_time")
579
+ end_time = payload.get("end_time")
580
+ if start_time is not None and end_time is not None and start_time >= end_time:
581
+ raise AgentProtocolError("invalid_event", "start_time must be before end_time")
582
+ policy = payload.get("policy") or {}
583
+ max_speakers = policy.get("max_speakers")
584
+ if max_speakers is not None and (not isinstance(max_speakers, int) or max_speakers < 1):
585
+ raise AgentProtocolError("invalid_event", "max_speakers must be a positive integer")
586
+
587
+
588
+ def validate_room_member_remove_payload(payload: dict[str, Any]) -> None:
589
+ """Shape checks for a `room.member.remove` payload. Creator, self, and
590
+ membership checks remain host-side."""
591
+ validate_agent_id(payload.get("member"))
592
+ ban = payload.get("ban")
593
+ if ban is not None and not isinstance(ban, bool):
594
+ raise AgentProtocolError("invalid_event", "ban must be a boolean")
595
+
596
+
455
597
  def server_record_hash_payload(
456
598
  room_id: str,
457
599
  seq: int,
@@ -554,7 +696,15 @@ def can_submit_event(
554
696
  return bool(context.get("public_join_allowed") or context.get("join_request_approved"))
555
697
  if event_type == ROOM_LEAVE:
556
698
  return is_creator or role is not None
557
- if event_type in {ROOM_JOIN_REVIEW, ROOM_MEMBER_ROLE_UPDATE, ROOM_CLOSE, ROOM_CANCEL, TYPE_DEFINE}:
699
+ if event_type in {
700
+ ROOM_UPDATE,
701
+ ROOM_JOIN_REVIEW,
702
+ ROOM_MEMBER_ROLE_UPDATE,
703
+ ROOM_MEMBER_REMOVE,
704
+ ROOM_CLOSE,
705
+ ROOM_CANCEL,
706
+ TYPE_DEFINE,
707
+ }:
558
708
  return is_creator or role == "moderator"
559
709
  if event_type == MESSAGE_CREATE:
560
710
  return is_creator or role in ("moderator", "speaker")
@@ -576,7 +726,9 @@ def can_write_in_state(event_type: str, state: RoomState) -> bool:
576
726
  ROOM_JOIN,
577
727
  ROOM_JOIN_REVIEW,
578
728
  ROOM_MEMBER_ROLE_UPDATE,
729
+ ROOM_MEMBER_REMOVE,
579
730
  ROOM_LEAVE,
731
+ ROOM_UPDATE,
580
732
  TYPE_DEFINE,
581
733
  ROOM_CANCEL,
582
734
  }
@@ -0,0 +1,6 @@
1
+ class AgentProtocolError(ValueError):
2
+ def __init__(self, code: str, message: str, data: dict | None = None):
3
+ super().__init__(message)
4
+ self.code = code
5
+ # Structured error details, e.g. {"max_nonce": ...} on nonce_not_greater.
6
+ self.data = data
@@ -8,6 +8,7 @@ try:
8
8
  except ImportError: # pragma: no cover
9
9
  requests = None # type: ignore[assignment]
10
10
 
11
+ from .delegation import validate_delegation_query_request
11
12
  from .identity import AgentId, Envelope
12
13
 
13
14
 
@@ -22,8 +23,14 @@ class ProfileClient:
22
23
  def get_profiles(self, agent_ids: list[AgentId]) -> dict[str, Any]:
23
24
  return self._post("/v1/profiles/batch", {"ids": agent_ids})
24
25
 
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}")
26
+ def profile_events(
27
+ self, agent_id: AgentId, limit: int = 1, cursor: str | None = None
28
+ ) -> dict[str, Any]:
29
+ query = urlencode(
30
+ {key: value for key, value in {"limit": limit, "cursor": cursor}.items() if value is not None},
31
+ quote_via=quote,
32
+ )
33
+ return self._get(f"/v1/profiles/{agent_id}/events?{query}")
27
34
 
28
35
  def submit_profile_update(self, envelope: Envelope) -> dict[str, Any]:
29
36
  return self._post("/v1/profiles", envelope)
@@ -87,8 +94,30 @@ class DiscourseClient:
87
94
  suffix = f"?{query}" if query else ""
88
95
  return self._get(f"/v1/rooms/public{suffix}")
89
96
 
90
- def my_rooms(self, jwt: str) -> list[dict[str, Any]]:
91
- return self._get("/v1/me/rooms", jwt=jwt)
97
+ def my_rooms(
98
+ self,
99
+ jwt: str,
100
+ *,
101
+ status: str | None = None,
102
+ membership: str | None = None,
103
+ limit: int | None = None,
104
+ cursor: str | None = None,
105
+ ) -> list[dict[str, Any]]:
106
+ query = urlencode(
107
+ {
108
+ key: value
109
+ for key, value in {
110
+ "status": status,
111
+ "membership": membership,
112
+ "limit": limit,
113
+ "cursor": cursor,
114
+ }.items()
115
+ if value is not None
116
+ },
117
+ quote_via=quote,
118
+ )
119
+ suffix = f"?{query}" if query else ""
120
+ return self._get(f"/v1/me/rooms{suffix}", jwt=jwt)
92
121
 
93
122
  def request_join(self, room_id: str, jwt: str, request: dict[str, Any]) -> dict[str, Any]:
94
123
  return self._post(f"/v1/rooms/{room_id}/join-requests", request, jwt=jwt)
@@ -192,6 +221,7 @@ class DelegationClient:
192
221
  return self._post("/v1/delegations", envelope)
193
222
 
194
223
  def query_delegations(self, request: dict[str, Any]) -> dict[str, Any]:
224
+ validate_delegation_query_request(request)
195
225
  return self._post("/v1/delegations/query", request)
196
226
 
197
227
  def _get(self, path: str) -> Any:
@@ -6,6 +6,7 @@ import json
6
6
  import re
7
7
  import time
8
8
  from dataclasses import dataclass
9
+ from urllib.parse import urlparse
9
10
  from typing import Any, MutableMapping, Protocol
10
11
 
11
12
  import rfc8785
@@ -34,9 +35,11 @@ def agent_id_from_public_key(public_key: bytes) -> AgentId:
34
35
 
35
36
 
36
37
  def public_key_bytes(agent_id: AgentId) -> bytes:
38
+ if not isinstance(agent_id, str):
39
+ raise AgentProtocolError("invalid_agent_id", "agent id must be a string")
37
40
  if not agent_id.startswith(AGENT_ID_PREFIX):
38
41
  raise AgentProtocolError("invalid_agent_id", "agent id must start with did:agent:")
39
- data = _base64url_decode_no_pad(agent_id[len(AGENT_ID_PREFIX):])
42
+ data = _base64url_decode(agent_id[len(AGENT_ID_PREFIX):])
40
43
  if len(data) != 32:
41
44
  raise AgentProtocolError("invalid_public_key", f"agent id public key must be 32 bytes, got {len(data)}")
42
45
  return data
@@ -114,7 +117,14 @@ class MemoryNonceStore:
114
117
  if record is not None:
115
118
  max_nonce, expires_at = record
116
119
  if expires_at > now_ms and nonce <= max_nonce:
117
- raise AgentProtocolError("nonce_not_greater", f"nonce must be greater than accepted max nonce {max_nonce}")
120
+ # Services rejecting for this reason MUST return the effective
121
+ # maximum in the `Max-Seen-Nonce` response header; `data`
122
+ # carries it.
123
+ raise AgentProtocolError(
124
+ "nonce_not_greater",
125
+ f"nonce must be greater than accepted max nonce {max_nonce}",
126
+ data={"max_nonce": max_nonce},
127
+ )
118
128
  self._records[actor] = (nonce, now_ms + ttl_ms)
119
129
  return nonce
120
130
 
@@ -215,6 +225,11 @@ def sign_event(private_key: Ed25519PrivateKey, event: Event) -> str:
215
225
 
216
226
 
217
227
  def sign_event_hash(private_key: Ed25519PrivateKey, event_hash: bytes) -> str:
228
+ """Signs a precomputed 32-byte event hash. Signing a digest supplied by
229
+ another component without seeing the event it commits to (blind signing)
230
+ is NOT RECOMMENDED: ``actor`` and all event content are inside the digest,
231
+ so a blind signer can be tricked into signing arbitrary events attributed
232
+ to its key. Prefer :func:`sign_event`."""
218
233
  return _base64url_encode(private_key.sign(_valid_event_hash_bytes(event_hash)))
219
234
 
220
235
 
@@ -302,6 +317,20 @@ def verify_request_jwt(token: str, *, audience: str, now_secs: int | None = None
302
317
  return claims
303
318
 
304
319
 
320
+ def service_origin(url: str) -> str:
321
+ """Derives the request JWT ``aud`` from a request URL: the service origin —
322
+ scheme, host, and non-default port, with no path (Agent Identity Section 8)."""
323
+ parsed = urlparse(url)
324
+ if parsed.scheme not in ("https", "http") or not parsed.hostname:
325
+ raise AgentProtocolError("invalid_url", f"not an HTTP(S) URL: {url}")
326
+ host = parsed.hostname
327
+ port = parsed.port
328
+ default_port = 443 if parsed.scheme == "https" else 80
329
+ if port is None or port == default_port:
330
+ return f"{parsed.scheme}://{host}"
331
+ return f"{parsed.scheme}://{host}:{port}"
332
+
333
+
305
334
  def unix_ms() -> int:
306
335
  return int(time.time() * 1000)
307
336
 
@@ -320,14 +349,20 @@ def _base64url_encode(data: bytes) -> str:
320
349
 
321
350
 
322
351
  def _base64url_decode(value: str) -> bytes:
352
+ """Canonical base64url decoding: URL-safe alphabet, no padding, zero
353
+ trailing bits. Receivers MUST reject non-canonical encodings, otherwise
354
+ one value gains multiple distinct string forms and corrupts string-keyed
355
+ comparisons."""
356
+ if not re.fullmatch(r"[A-Za-z0-9_-]*", value):
357
+ raise AgentProtocolError("invalid_encoding", "expected canonical base64url without padding")
323
358
  padding = "=" * ((4 - len(value) % 4) % 4)
324
- return base64.urlsafe_b64decode(value + padding)
325
-
326
-
327
- def _base64url_decode_no_pad(value: str) -> bytes:
328
- if not re.fullmatch(r"[A-Za-z0-9_-]+", value):
329
- raise AgentProtocolError("invalid_encoding", "expected base64url without padding")
330
- return _base64url_decode(value)
359
+ try:
360
+ data = base64.urlsafe_b64decode(value + padding)
361
+ except (ValueError, TypeError) as exc:
362
+ raise AgentProtocolError("invalid_encoding", "expected canonical base64url without padding") from exc
363
+ if _base64url_encode(data) != value:
364
+ raise AgentProtocolError("invalid_encoding", "expected canonical base64url without padding")
365
+ return data
331
366
 
332
367
 
333
368
  def _valid_event_hash_bytes(event_hash: bytes) -> bytes:
@@ -35,7 +35,6 @@ def materialize_profile(envelope: Envelope) -> AgentProfile:
35
35
  return {
36
36
  "id": payload_id,
37
37
  "name": payload["name"],
38
- "username": None,
39
38
  "description": payload.get("description"),
40
39
  "avatar_url": payload.get("avatar_url"),
41
40
  "provider": payload.get("provider"),
@@ -47,3 +46,13 @@ def materialize_profile(envelope: Envelope) -> AgentProfile:
47
46
  "updated_at": envelope["event"]["created_at"],
48
47
  "event_id": envelope["hash"],
49
48
  }
49
+
50
+
51
+ def latest_profile_update(envelopes: list[Envelope]) -> Envelope | None:
52
+ """Selects the latest profile state from accepted update envelopes. Nonces
53
+ are strictly monotonic per Agent ID, so the latest profile is defined as
54
+ the accepted `profile.update` with the greatest `nonce` — deterministic
55
+ and independently checkable from event history alone."""
56
+ if not envelopes:
57
+ return None
58
+ return max(envelopes, key=lambda envelope: envelope["event"]["nonce"])
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-protocols
3
- Version: 0.4.1
3
+ Version: 0.5.0
4
4
  Summary: Python SDK for Agent Identity, Agent Profile, Agent Delegation, and Agent Discourse protocols
5
5
  Author: LDCLabs
6
6
  License: MIT
@@ -42,6 +42,6 @@ envelope = signer.sign_event(event)
42
42
  profile = materialize_profile(envelope)
43
43
  ```
44
44
 
45
- `username` is provider-confirmed and appears on Profile documents returned by a profile service. Do not put it in agent-submitted `profile.update` payloads.
45
+ Agent Profile has no `username` field: the Agent ID is the identity key, and the latest profile is the accepted `profile.update` with the greatest `nonce`.
46
46
 
47
- ADP room writes are signed against the current room head. Use `discourse_event` or `type_define_event` with `base_seq` and `base_hash`. Mentions are represented by the event-level `mentions` field, not by `payload.extra`.
47
+ ADP room writes declare a signed `base_seq` / `base_hash`: discussion and contract writes must match the current room head, while `signal`-kind writes — including the built-in membership events — only anchor to an accepted record and never contend for the head. Use `discourse_event` or `type_define_event` with `base_seq` and `base_hash`. Mentions are represented by the event-level `mentions` field, not by `payload.extra`.
@@ -122,3 +122,45 @@ class DelegationTests(unittest.TestCase):
122
122
 
123
123
  if __name__ == "__main__":
124
124
  unittest.main()
125
+
126
+
127
+ class Revision20260704DelegationTests(unittest.TestCase):
128
+ def test_grant_expiry_checked_against_not_before_and_created_at(self):
129
+ from agent_protocols.delegation import validate_delegation_grant_payload
130
+ from agent_protocols.errors import AgentProtocolError
131
+ from agent_protocols.identity import AgentSigner
132
+
133
+ subject = AgentSigner.from_seed(bytes([46]) * 32).agent_id()
134
+ base = {
135
+ "id": "del_1",
136
+ "principal": {"id": "https://al.ink/yan"},
137
+ "subject": subject,
138
+ "scopes": ["inbox.screen"],
139
+ }
140
+
141
+ with self.assertRaisesRegex(AgentProtocolError, "greater than not_before"):
142
+ validate_delegation_grant_payload(
143
+ {**base, "not_before": 2000, "expires_at": 2000}, 1000
144
+ )
145
+ with self.assertRaisesRegex(AgentProtocolError, "greater than created_at"):
146
+ validate_delegation_grant_payload({**base, "expires_at": 1000}, 1000)
147
+ with self.assertRaisesRegex(AgentProtocolError, "greater than created_at"):
148
+ validate_delegation_grant_payload(
149
+ {**base, "not_before": 500, "expires_at": 800}, 1000
150
+ )
151
+ validate_delegation_grant_payload(
152
+ {**base, "not_before": 500, "expires_at": 1500}, 1000
153
+ )
154
+
155
+ def test_delegation_queries_require_subject_or_principal(self):
156
+ from agent_protocols.delegation import validate_delegation_query_request
157
+ from agent_protocols.errors import AgentProtocolError
158
+ from agent_protocols.identity import AgentSigner
159
+
160
+ subject = AgentSigner.from_seed(bytes([47]) * 32).agent_id()
161
+ validate_delegation_query_request({"subject": subject})
162
+ validate_delegation_query_request({"principal_id": "https://al.ink/yan", "limit": 20})
163
+ with self.assertRaisesRegex(AgentProtocolError, "at least one of subject or principal_id"):
164
+ validate_delegation_query_request({"status": "active"})
165
+ with self.assertRaisesRegex(AgentProtocolError, "positive integer"):
166
+ validate_delegation_query_request({"subject": subject, "limit": 0})
@@ -27,6 +27,7 @@ from agent_protocols.discourse import (
27
27
  can_accept_room_write,
28
28
  can_submit_event,
29
29
  can_write_in_state,
30
+ discourse_event,
30
31
  pack_map,
31
32
  room_create_event,
32
33
  server_record_hash,
@@ -41,6 +42,7 @@ from agent_protocols.discourse import (
41
42
  verify_server_record,
42
43
  verify_server_record_chain,
43
44
  )
45
+ from agent_protocols import discourse
44
46
  from agent_protocols.errors import AgentProtocolError
45
47
  from agent_protocols.http_client import sse_events_url
46
48
  from agent_protocols.identity import AgentSigner, create_event
@@ -401,6 +403,118 @@ class DiscourseTests(unittest.TestCase):
401
403
  "https://api.example.com/v1/rooms/room123/events/live",
402
404
  )
403
405
 
406
+ class Revision20260704Tests(unittest.TestCase):
407
+ def test_kernel_defines_eleven_builtins_with_membership_signals(self):
408
+ self.assertEqual(len(discourse.BUILTIN_EVENT_TYPES), 11)
409
+ self.assertIn("room.update", discourse.BUILTIN_EVENT_TYPES)
410
+ self.assertIn("room.member.remove", discourse.BUILTIN_EVENT_TYPES)
411
+
412
+ for membership in discourse.MEMBERSHIP_EVENT_TYPES:
413
+ self.assertEqual(discourse.builtin_event_class(membership), "signal")
414
+ self.assertFalse(discourse.event_advances_room_head(membership))
415
+ for advancing in (
416
+ discourse.ROOM_CREATE,
417
+ discourse.ROOM_UPDATE,
418
+ discourse.ROOM_CLOSE,
419
+ discourse.ROOM_CANCEL,
420
+ discourse.TYPE_DEFINE,
421
+ discourse.MESSAGE_CREATE,
422
+ ):
423
+ self.assertTrue(discourse.event_advances_room_head(advancing))
424
+ self.assertIsNone(discourse.builtin_event_class("review.finding"))
425
+
426
+ registry = discourse.TypeRegistry()
427
+ registry.define(
428
+ {
429
+ "type": "reaction.create",
430
+ "kind": "signal",
431
+ "title": "Reaction",
432
+ "schema": {"type": "object"},
433
+ }
434
+ )
435
+ self.assertFalse(discourse.event_advances_room_head("reaction.create", registry))
436
+ self.assertTrue(discourse.event_advances_room_head("unknown.type", registry))
437
+
438
+ self.assertIn("member_banned", discourse.DISCOURSE_ERROR_CODES)
439
+ self.assertIn("role_not_allowed", discourse.DISCOURSE_ERROR_CODES)
440
+ self.assertIn("max_speakers_exceeded", discourse.DISCOURSE_ERROR_CODES)
441
+
442
+ def test_room_update_and_member_remove_follow_moderator_rules(self):
443
+ for builtin in (discourse.ROOM_UPDATE, discourse.ROOM_MEMBER_REMOVE):
444
+ self.assertTrue(discourse.can_submit_event(builtin, {"role": "moderator"}))
445
+ self.assertTrue(discourse.can_submit_event(builtin, {"is_creator": True}))
446
+ self.assertFalse(discourse.can_submit_event(builtin, {"role": "speaker"}))
447
+ self.assertTrue(discourse.can_write_in_state(builtin, "scheduled"))
448
+ self.assertTrue(discourse.can_write_in_state(builtin, "active"))
449
+ self.assertFalse(discourse.can_write_in_state(builtin, "ended"))
450
+ self.assertFalse(discourse.can_write_in_state(builtin, "cancelled"))
451
+
452
+ def test_validates_room_update_payloads(self):
453
+ discourse.validate_room_update_payload(
454
+ {"topic": "New topic", "guidance": "", "end_time": 2000}
455
+ )
456
+ with self.assertRaisesRegex(AgentProtocolError, "must not be empty"):
457
+ discourse.validate_room_update_payload({})
458
+ with self.assertRaisesRegex(AgentProtocolError, "not updatable"):
459
+ discourse.validate_room_update_payload({"visibility": "private"})
460
+ with self.assertRaisesRegex(AgentProtocolError, "topic"):
461
+ discourse.validate_room_update_payload({"topic": " "})
462
+ with self.assertRaisesRegex(AgentProtocolError, "before end_time"):
463
+ discourse.validate_room_update_payload({"start_time": 5, "end_time": 5})
464
+ with self.assertRaisesRegex(AgentProtocolError, "max_speakers"):
465
+ discourse.validate_room_update_payload({"policy": {"max_speakers": 0}})
466
+
467
+ def test_validates_room_member_remove_payloads(self):
468
+ member = AgentSigner.from_seed(bytes([41]) * 32).agent_id()
469
+ discourse.validate_room_member_remove_payload({"member": member})
470
+ discourse.validate_room_member_remove_payload({"member": member, "ban": True})
471
+ with self.assertRaises(AgentProtocolError):
472
+ discourse.validate_room_member_remove_payload({"member": "not-an-id"})
473
+ with self.assertRaisesRegex(AgentProtocolError, "ban must be a boolean"):
474
+ discourse.validate_room_member_remove_payload({"member": member, "ban": "yes"})
475
+
476
+ def test_mentions_are_capped_at_32_unique_agent_ids(self):
477
+ signer = AgentSigner.from_seed(bytes([42]) * 32)
478
+ others = [AgentSigner.from_seed(bytes([100 + index]) * 32).agent_id() for index in range(33)]
479
+
480
+ def envelope_with(mentions):
481
+ event = discourse_event(
482
+ MESSAGE_CREATE,
483
+ signer.agent_id(),
484
+ 100,
485
+ 1,
486
+ "room1",
487
+ 1,
488
+ "room-create-head",
489
+ {"content_type": "text/plain", "content": "hi"},
490
+ )
491
+ event["mentions"] = mentions
492
+ return signer.sign_event(event)
493
+
494
+ validate_discourse_envelope(envelope_with(others[:32]))
495
+ with self.assertRaisesRegex(AgentProtocolError, "must not exceed 32"):
496
+ validate_discourse_envelope(envelope_with(others))
497
+ with self.assertRaisesRegex(AgentProtocolError, "unique"):
498
+ validate_discourse_envelope(envelope_with([others[0], others[0]]))
499
+ # A non-string mention yields a clean protocol error, not a raw
500
+ # TypeError from set() hashing.
501
+ with self.assertRaises(AgentProtocolError):
502
+ validate_discourse_envelope(envelope_with([{"not": "a string"}]))
503
+
504
+ def test_type_redefinition_cannot_change_kind(self):
505
+ definition = {
506
+ "type": "review.finding",
507
+ "kind": "message",
508
+ "title": "Finding",
509
+ "schema": {"type": "object"},
510
+ }
511
+ registry = discourse.TypeRegistry()
512
+ registry.define(definition)
513
+ registry.define({**definition, "title": "Finding v2"})
514
+ self.assertEqual(registry.get("review.finding")["title"], "Finding v2")
515
+ with self.assertRaisesRegex(AgentProtocolError, "cannot change kind"):
516
+ registry.define({**definition, "kind": "signal"})
517
+
404
518
 
405
519
  if __name__ == "__main__":
406
520
  unittest.main()
@@ -131,3 +131,86 @@ class IdentityTests(unittest.TestCase):
131
131
 
132
132
  if __name__ == "__main__":
133
133
  unittest.main()
134
+
135
+
136
+ class Revision20260704IdentityTests(unittest.TestCase):
137
+ ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
138
+
139
+ def test_base64url_decoding_rejects_non_canonical_encodings(self):
140
+ from agent_protocols.errors import AgentProtocolError
141
+ from agent_protocols.identity import (
142
+ AGENT_ID_PREFIX,
143
+ AgentSigner,
144
+ create_event,
145
+ validate_agent_id,
146
+ verify_envelope,
147
+ )
148
+
149
+ signer = AgentSigner.from_seed(bytes([60]) * 32)
150
+ agent_id = signer.agent_id()
151
+ suffix = agent_id[len(AGENT_ID_PREFIX):]
152
+ validate_agent_id(agent_id)
153
+
154
+ # Padding characters are rejected even when the decoded bytes match.
155
+ with self.assertRaisesRegex(AgentProtocolError, "canonical base64url"):
156
+ validate_agent_id(f"{AGENT_ID_PREFIX}{suffix}==")
157
+
158
+ # Non-zero trailing bits give the same key a second string form:
159
+ # replace the final character with one sharing its used bits but
160
+ # different trailing bits (the last char of a 32-byte value uses 4 of
161
+ # its 6 bits).
162
+ index = self.ALPHABET.index(suffix[-1])
163
+ non_canonical = self.ALPHABET[(index & ~3) | ((index & 3) ^ 1)]
164
+ with self.assertRaisesRegex(AgentProtocolError, "canonical base64url"):
165
+ validate_agent_id(f"{AGENT_ID_PREFIX}{suffix[:-1]}{non_canonical}")
166
+
167
+ # Non-alphabet characters are rejected.
168
+ with self.assertRaisesRegex(AgentProtocolError, "canonical base64url"):
169
+ validate_agent_id(f"{AGENT_ID_PREFIX}{suffix[:-1]}+")
170
+
171
+ # Signatures must be canonical base64url too.
172
+ event = create_event(
173
+ "agent-profile/1.0",
174
+ "profile.update",
175
+ agent_id,
176
+ 1000,
177
+ 1,
178
+ {"id": agent_id, "name": "Agent"},
179
+ )
180
+ envelope = signer.sign_event(event)
181
+ tampered = {**envelope, "signature": envelope["signature"] + "="}
182
+ with self.assertRaisesRegex(AgentProtocolError, "canonical base64url"):
183
+ verify_envelope(tampered)
184
+
185
+ def test_service_origin_derives_request_jwt_audience(self):
186
+ from agent_protocols.errors import AgentProtocolError
187
+ from agent_protocols.identity import service_origin
188
+
189
+ self.assertEqual(
190
+ service_origin("https://api.example.com/v1/rooms/room1"),
191
+ "https://api.example.com",
192
+ )
193
+ self.assertEqual(
194
+ service_origin("https://api.example.com:8443/path?q=1"),
195
+ "https://api.example.com:8443",
196
+ )
197
+ self.assertEqual(
198
+ service_origin("https://API.Example.com:443/"),
199
+ "https://api.example.com",
200
+ )
201
+ with self.assertRaises(AgentProtocolError):
202
+ service_origin("not a url")
203
+ with self.assertRaises(AgentProtocolError):
204
+ service_origin("ftp://example.com")
205
+
206
+ def test_nonce_not_greater_errors_carry_the_effective_maximum(self):
207
+ from agent_protocols.errors import AgentProtocolError
208
+ from agent_protocols.identity import AgentSigner, MemoryNonceStore
209
+
210
+ actor = AgentSigner.from_seed(bytes([61]) * 32).agent_id()
211
+ store = MemoryNonceStore()
212
+ store.check_and_update(actor, 7, 1000, 1000)
213
+ with self.assertRaises(AgentProtocolError) as caught:
214
+ store.check_and_update(actor, 7, 1100, 1000)
215
+ self.assertEqual(caught.exception.code, "nonce_not_greater")
216
+ self.assertEqual(caught.exception.data, {"max_nonce": 7})
@@ -1,7 +1,12 @@
1
1
  import unittest
2
2
 
3
3
  from agent_protocols.identity import AgentSigner
4
- from agent_protocols.profile import materialize_profile, profile_update_event, validate_profile_update
4
+ from agent_protocols.profile import (
5
+ latest_profile_update,
6
+ materialize_profile,
7
+ profile_update_event,
8
+ validate_profile_update,
9
+ )
5
10
 
6
11
 
7
12
  class ProfileTests(unittest.TestCase):
@@ -28,14 +33,14 @@ class ProfileTests(unittest.TestCase):
28
33
 
29
34
  self.assertEqual(profile["id"], signer.agent_id())
30
35
  self.assertEqual(profile["name"], "ResearchAgent-v3")
31
- self.assertIsNone(profile["username"])
36
+ self.assertNotIn("username", profile)
32
37
  self.assertEqual(profile["links"], payload["links"])
33
38
  self.assertEqual(profile["delegations"], payload["delegations"])
34
39
  self.assertEqual(profile["extra"], payload["extra"])
35
40
  self.assertEqual(profile["updated_at"], 1_779_753_600_000)
36
41
  self.assertEqual(profile["event_id"], envelope["hash"])
37
42
 
38
- def test_does_not_materialize_unconfirmed_payload_username(self):
43
+ def test_does_not_materialize_the_removed_username_field(self):
39
44
  signer = AgentSigner.from_seed(bytes([15]) * 32)
40
45
  payload = {"id": signer.agent_id(), "name": "ResearchAgent-v3", "username": "anda"}
41
46
  envelope = signer.sign_event(profile_update_event(signer.agent_id(), 1_779_753_600_002, 1, payload))
@@ -43,7 +48,26 @@ class ProfileTests(unittest.TestCase):
43
48
  profile = materialize_profile(envelope)
44
49
 
45
50
  self.assertEqual(profile["id"], signer.agent_id())
46
- self.assertIsNone(profile["username"])
51
+ self.assertNotIn("username", profile)
52
+
53
+ def test_latest_profile_update_picks_greatest_nonce(self):
54
+ signer = AgentSigner.from_seed(bytes([16]) * 32)
55
+ envelopes = [
56
+ signer.sign_event(
57
+ profile_update_event(
58
+ signer.agent_id(),
59
+ 1_779_753_600_000 + nonce,
60
+ nonce,
61
+ {"id": signer.agent_id(), "name": f"Agent-v{nonce}"},
62
+ )
63
+ )
64
+ for nonce in (3, 1, 2)
65
+ ]
66
+
67
+ self.assertIsNone(latest_profile_update([]))
68
+ latest = latest_profile_update(envelopes)
69
+ self.assertEqual(latest["event"]["nonce"], 3)
70
+ self.assertEqual(materialize_profile(latest)["name"], "Agent-v3")
47
71
 
48
72
  def test_rejects_actor_payload_mismatch(self):
49
73
  signer = AgentSigner.from_seed(bytes([12]) * 32)
@@ -1,4 +0,0 @@
1
- class AgentProtocolError(ValueError):
2
- def __init__(self, code: str, message: str):
3
- super().__init__(message)
4
- self.code = code