agent-protocols 0.3.1__tar.gz → 0.4.1__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 (24) hide show
  1. {agent_protocols-0.3.1 → agent_protocols-0.4.1}/PKG-INFO +8 -5
  2. {agent_protocols-0.3.1 → agent_protocols-0.4.1}/README.md +6 -3
  3. {agent_protocols-0.3.1 → agent_protocols-0.4.1}/pyproject.toml +2 -2
  4. {agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols/__init__.py +28 -0
  5. agent_protocols-0.4.1/src/agent_protocols/delegation.py +150 -0
  6. {agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols/discourse.py +117 -14
  7. {agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols/http_client.py +97 -9
  8. {agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols/identity.py +25 -0
  9. {agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols/profile.py +1 -0
  10. {agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols.egg-info/PKG-INFO +8 -5
  11. {agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols.egg-info/SOURCES.txt +2 -0
  12. agent_protocols-0.4.1/tests/test_delegation.py +124 -0
  13. {agent_protocols-0.3.1 → agent_protocols-0.4.1}/tests/test_discourse.py +17 -5
  14. {agent_protocols-0.3.1 → agent_protocols-0.4.1}/tests/test_discourse_coverage.py +16 -5
  15. {agent_protocols-0.3.1 → agent_protocols-0.4.1}/tests/test_http_client.py +84 -11
  16. {agent_protocols-0.3.1 → agent_protocols-0.4.1}/tests/test_identity_coverage.py +13 -0
  17. {agent_protocols-0.3.1 → agent_protocols-0.4.1}/tests/test_profile.py +9 -0
  18. {agent_protocols-0.3.1 → agent_protocols-0.4.1}/setup.cfg +0 -0
  19. {agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols/errors.py +0 -0
  20. {agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols.egg-info/dependency_links.txt +0 -0
  21. {agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols.egg-info/requires.txt +0 -0
  22. {agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols.egg-info/top_level.txt +0 -0
  23. {agent_protocols-0.3.1 → agent_protocols-0.4.1}/tests/test_identity.py +0 -0
  24. {agent_protocols-0.3.1 → agent_protocols-0.4.1}/tests/test_profile_coverage.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-protocols
3
- Version: 0.3.1
4
- Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
3
+ Version: 0.4.1
4
+ Summary: Python SDK for Agent Identity, Agent Profile, Agent Delegation, and Agent Discourse protocols
5
5
  Author: LDCLabs
6
6
  License: MIT
7
7
  Keywords: agent,protocol,ed25519,sdk
@@ -15,14 +15,15 @@ Requires-Dist: requests<3,>=2.31; extra == "http"
15
15
 
16
16
  # agent-protocols Python SDK
17
17
 
18
- Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse protocols.
18
+ Python SDK for the draft Agent Identity, Agent Profile, Agent Delegation, and Agent Discourse protocols.
19
19
 
20
20
  ## Modules
21
21
 
22
22
  - `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event hashes, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
23
- - `agent_protocols.profile`: `profile.update` payload helpers, validation, materialization.
23
+ - `agent_protocols.profile`: `profile.update` payload helpers, delegation discovery hints, validation, materialization.
24
+ - `agent_protocols.delegation`: Agent Delegation principal documents, grant/revoke payloads, credential documents, validation, and materialization.
24
25
  - `agent_protocols.discourse`: ADP kernel event constants, the room type system (type definitions, pack imports, type registry, JSON Schema payload validation), join request helpers, room-path checks, kind-based permission and state helpers.
25
- - `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
26
+ - `agent_protocols.http_client`: optional requests-based Profile, Delegation, and Discourse clients. Install with `agent-protocols[http]`.
26
27
 
27
28
  ## Example
28
29
 
@@ -42,3 +43,5 @@ profile = materialize_profile(envelope)
42
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.
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`.
@@ -1,13 +1,14 @@
1
1
  # agent-protocols Python SDK
2
2
 
3
- Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse protocols.
3
+ Python SDK for the draft Agent Identity, Agent Profile, Agent Delegation, and Agent Discourse protocols.
4
4
 
5
5
  ## Modules
6
6
 
7
7
  - `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event hashes, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
8
- - `agent_protocols.profile`: `profile.update` payload helpers, validation, materialization.
8
+ - `agent_protocols.profile`: `profile.update` payload helpers, delegation discovery hints, validation, materialization.
9
+ - `agent_protocols.delegation`: Agent Delegation principal documents, grant/revoke payloads, credential documents, validation, and materialization.
9
10
  - `agent_protocols.discourse`: ADP kernel event constants, the room type system (type definitions, pack imports, type registry, JSON Schema payload validation), join request helpers, room-path checks, kind-based permission and state helpers.
10
- - `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
11
+ - `agent_protocols.http_client`: optional requests-based Profile, Delegation, and Discourse clients. Install with `agent-protocols[http]`.
11
12
 
12
13
  ## Example
13
14
 
@@ -27,3 +28,5 @@ profile = materialize_profile(envelope)
27
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.
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`.
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "agent-protocols"
3
- version = "0.3.1"
4
- description = "Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols"
3
+ version = "0.4.1"
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"
7
7
  license = { text = "MIT" }
@@ -26,12 +26,30 @@ from .identity import (
26
26
  verify_request_jwt,
27
27
  verify_signature,
28
28
  verify_timestamp,
29
+ with_mention,
30
+ with_mentions,
31
+ with_room_head,
29
32
  with_room_id,
30
33
  )
31
34
  from .profile import PROFILE_PROTOCOL, PROFILE_UPDATE, materialize_profile, profile_update_event, validate_profile_update
35
+ from .delegation import (
36
+ DELEGATION_GRANT,
37
+ DELEGATION_PROTOCOL,
38
+ DELEGATION_REVOKE,
39
+ delegation_grant_event,
40
+ delegation_revoke_event,
41
+ materialize_delegation_credential,
42
+ validate_delegation_envelope,
43
+ validate_delegation_grant_payload,
44
+ validate_delegation_revoke_payload,
45
+ validate_principal_document,
46
+ )
32
47
 
33
48
  __all__ = [
34
49
  "AGENT_ID_PREFIX",
50
+ "DELEGATION_GRANT",
51
+ "DELEGATION_PROTOCOL",
52
+ "DELEGATION_REVOKE",
35
53
  "MAX_NONCE_HEADER",
36
54
  "PROFILE_PROTOCOL",
37
55
  "PROFILE_UPDATE",
@@ -43,10 +61,13 @@ __all__ = [
43
61
  "agent_id_from_public_key",
44
62
  "canonical_event_bytes",
45
63
  "create_event",
64
+ "delegation_grant_event",
65
+ "delegation_revoke_event",
46
66
  "create_request_jwt_claims",
47
67
  "event_hash",
48
68
  "event_hash_bytes",
49
69
  "materialize_profile",
70
+ "materialize_delegation_credential",
50
71
  "profile_update_event",
51
72
  "public_key_bytes",
52
73
  "sign_event",
@@ -54,7 +75,11 @@ __all__ = [
54
75
  "unix_ms",
55
76
  "unix_secs",
56
77
  "validate_agent_id",
78
+ "validate_delegation_envelope",
79
+ "validate_delegation_grant_payload",
80
+ "validate_delegation_revoke_payload",
57
81
  "validate_nonce",
82
+ "validate_principal_document",
58
83
  "validate_profile_update",
59
84
  "verify_envelope",
60
85
  "verify_event_hash",
@@ -63,5 +88,8 @@ __all__ = [
63
88
  "verify_request_jwt",
64
89
  "verify_signature",
65
90
  "verify_timestamp",
91
+ "with_mention",
92
+ "with_mentions",
93
+ "with_room_head",
66
94
  "with_room_id",
67
95
  ]
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from urllib.parse import urlparse
5
+
6
+ from .errors import AgentProtocolError
7
+ from .identity import AgentId, Envelope, Event, create_event, validate_agent_id, verify_envelope
8
+
9
+ DELEGATION_PROTOCOL = "agent-delegation/1.0"
10
+ DELEGATION_GRANT = "delegation.grant"
11
+ DELEGATION_REVOKE = "delegation.revoke"
12
+
13
+ DelegationGrantPayload = dict[str, Any]
14
+ DelegationRevokePayload = dict[str, Any]
15
+ DelegationCredential = dict[str, Any]
16
+ PrincipalDocument = dict[str, Any]
17
+
18
+
19
+ def delegation_grant_event(
20
+ actor: AgentId,
21
+ created_at: int,
22
+ nonce: int,
23
+ payload: DelegationGrantPayload,
24
+ ) -> Event:
25
+ return create_event(DELEGATION_PROTOCOL, DELEGATION_GRANT, actor, created_at, nonce, payload)
26
+
27
+
28
+ def delegation_revoke_event(
29
+ actor: AgentId,
30
+ created_at: int,
31
+ nonce: int,
32
+ payload: DelegationRevokePayload,
33
+ ) -> Event:
34
+ return create_event(DELEGATION_PROTOCOL, DELEGATION_REVOKE, actor, created_at, nonce, payload)
35
+
36
+
37
+ def validate_principal_document(document: PrincipalDocument) -> None:
38
+ _validate_https_url(document.get("id"), "principal.id")
39
+ controllers = document.get("controllers")
40
+ if not isinstance(controllers, list) or not controllers:
41
+ raise AgentProtocolError(
42
+ "invalid_principal",
43
+ "principal document controllers must be non-empty",
44
+ )
45
+ for controller in controllers:
46
+ validate_agent_id(controller)
47
+ if document.get("delegations_url") is not None:
48
+ _validate_https_url(document["delegations_url"], "delegations_url")
49
+
50
+
51
+ def validate_delegation_grant_payload(
52
+ payload: DelegationGrantPayload,
53
+ created_at: int | None = None,
54
+ ) -> None:
55
+ _validate_non_empty(payload.get("id"), "payload.id")
56
+ _validate_principal_descriptor(payload.get("principal"))
57
+ validate_agent_id(payload.get("subject"))
58
+ scopes = payload.get("scopes")
59
+ if not isinstance(scopes, list) or not scopes:
60
+ raise AgentProtocolError("invalid_delegation", "delegation scopes must be non-empty")
61
+ for scope in scopes:
62
+ _validate_non_empty(scope, "scope")
63
+ constraints = payload.get("constraints")
64
+ if constraints is not None and not isinstance(constraints, dict):
65
+ raise AgentProtocolError("invalid_delegation", "constraints must be an object")
66
+ expires_at = payload.get("expires_at")
67
+ if expires_at is not None:
68
+ not_before = payload.get("not_before", created_at)
69
+ if not_before is not None and expires_at <= not_before:
70
+ raise AgentProtocolError(
71
+ "invalid_delegation",
72
+ "expires_at must be greater than not_before or created_at",
73
+ )
74
+
75
+
76
+ def validate_delegation_revoke_payload(payload: DelegationRevokePayload) -> None:
77
+ _validate_non_empty(payload.get("id"), "payload.id")
78
+ _validate_https_url(payload.get("principal_id"), "principal_id")
79
+
80
+
81
+ def validate_delegation_envelope(envelope: Envelope) -> None:
82
+ verify_envelope(envelope)
83
+ event = envelope["event"]
84
+ if event["protocol"] != DELEGATION_PROTOCOL:
85
+ raise AgentProtocolError(
86
+ "invalid_event_protocol",
87
+ f"expected {DELEGATION_PROTOCOL}, got {event['protocol']}",
88
+ )
89
+ if event["type"] == DELEGATION_GRANT:
90
+ validate_delegation_grant_payload(event["payload"], event["created_at"])
91
+ elif event["type"] == DELEGATION_REVOKE:
92
+ validate_delegation_revoke_payload(event["payload"])
93
+ else:
94
+ raise AgentProtocolError(
95
+ "invalid_event_type",
96
+ f"expected {DELEGATION_GRANT} or {DELEGATION_REVOKE}, got {event['type']}",
97
+ )
98
+
99
+
100
+ def materialize_delegation_credential(
101
+ envelope: Envelope,
102
+ *,
103
+ status: str = "active",
104
+ status_url: str | None = None,
105
+ updated_at: int | None = None,
106
+ ) -> DelegationCredential:
107
+ validate_delegation_envelope(envelope)
108
+ event = envelope["event"]
109
+ if event["type"] != DELEGATION_GRANT:
110
+ raise AgentProtocolError(
111
+ "invalid_event_type",
112
+ "delegation credential materialization requires a grant event",
113
+ )
114
+ payload = event["payload"]
115
+ credential = {
116
+ "id": payload["id"],
117
+ "protocol": DELEGATION_PROTOCOL,
118
+ "principal": payload["principal"],
119
+ "controller": event["actor"],
120
+ "subject": payload["subject"],
121
+ "relationship": payload.get("relationship"),
122
+ "scopes": payload["scopes"],
123
+ "constraints": payload.get("constraints"),
124
+ "not_before": payload.get("not_before"),
125
+ "expires_at": payload.get("expires_at"),
126
+ "status": status,
127
+ "status_url": status_url,
128
+ "updated_at": updated_at if updated_at is not None else event["created_at"],
129
+ "event_id": envelope["hash"],
130
+ }
131
+ return credential
132
+
133
+
134
+ def _validate_principal_descriptor(value: Any) -> None:
135
+ if not isinstance(value, dict):
136
+ raise AgentProtocolError("invalid_principal", "principal must be an object")
137
+ _validate_https_url(value.get("id"), "principal.id")
138
+
139
+
140
+ def _validate_https_url(value: Any, field: str) -> None:
141
+ if not isinstance(value, str):
142
+ raise AgentProtocolError("invalid_url", f"{field} must be an HTTPS URL")
143
+ parsed = urlparse(value)
144
+ if parsed.scheme != "https" or not parsed.netloc:
145
+ raise AgentProtocolError("invalid_url", f"{field} must be an HTTPS URL")
146
+
147
+
148
+ def _validate_non_empty(value: Any, field: str) -> None:
149
+ if not isinstance(value, str) or not value.strip():
150
+ raise AgentProtocolError("invalid_delegation", f"{field} must not be empty")
@@ -18,7 +18,17 @@ import rfc8785
18
18
  from jsonschema.validators import Draft202012Validator
19
19
 
20
20
  from .errors import AgentProtocolError
21
- from .identity import AgentId, Envelope, Event, create_event, verify_envelope, with_room_id
21
+ from .identity import (
22
+ AgentId,
23
+ Envelope,
24
+ Event,
25
+ MAX_SAFE_NONCE,
26
+ create_event,
27
+ validate_agent_id,
28
+ verify_envelope,
29
+ with_room_head,
30
+ with_room_id,
31
+ )
22
32
 
23
33
  DISCOURSE_PROTOCOL = "agent-discourse/1.0"
24
34
 
@@ -68,6 +78,8 @@ Role = Literal["moderator", "speaker", "observer"]
68
78
  TypeKind = Literal["message", "signal", "control"]
69
79
  TypeStatus = Literal["active", "deprecated", "disabled"]
70
80
  JoinRequestStatus = Literal["pending", "approved", "rejected", "expired"]
81
+ JoinDecision = Literal["approve", "reject"]
82
+ Visibility = Literal["public", "restricted", "private"]
71
83
 
72
84
  ROLES = ("moderator", "speaker", "observer")
73
85
  TYPE_KINDS = ("message", "signal", "control")
@@ -82,24 +94,73 @@ class PermissionContext(TypedDict, total=False):
82
94
 
83
95
  role: Role
84
96
  is_creator: bool
97
+ public_join_allowed: bool
85
98
  join_request_approved: bool
86
99
 
87
100
 
101
+ class AgentStatusInput(TypedDict, total=False):
102
+ state: str
103
+ summary: str
104
+ seen_seq: int
105
+ seen_hash: str
106
+ claim_id: str
107
+ activity: str
108
+ expires_at: int
109
+ extra: dict[str, Any]
110
+
111
+
112
+ class AgentStatus(TypedDict, total=False):
113
+ room_id: str
114
+ agent_id: AgentId
115
+ state: str
116
+ summary: str
117
+ seen_seq: int
118
+ seen_hash: str
119
+ claim_id: str
120
+ activity: str
121
+ expires_at: int
122
+ updated_at: int
123
+ extra: dict[str, Any]
124
+
125
+
88
126
  def room_create_event(actor: AgentId, created_at: int, nonce: int, payload: dict[str, Any]) -> Event:
89
127
  return create_event(DISCOURSE_PROTOCOL, ROOM_CREATE, actor, created_at, nonce, payload)
90
128
 
91
129
 
92
130
  def type_define_event(
93
- actor: AgentId, created_at: int, nonce: int, room_id: str, declaration: dict[str, Any]
131
+ actor: AgentId,
132
+ created_at: int,
133
+ nonce: int,
134
+ room_id: str,
135
+ base_seq: int,
136
+ base_hash: str,
137
+ declaration: dict[str, Any],
94
138
  ) -> Event:
95
- return with_room_id(
96
- create_event(DISCOURSE_PROTOCOL, TYPE_DEFINE, actor, created_at, nonce, declaration),
97
- room_id,
139
+ return with_room_head(
140
+ with_room_id(
141
+ create_event(DISCOURSE_PROTOCOL, TYPE_DEFINE, actor, created_at, nonce, declaration),
142
+ room_id,
143
+ ),
144
+ base_seq,
145
+ base_hash,
98
146
  )
99
147
 
100
148
 
101
- def discourse_event(event_type: str, actor: AgentId, created_at: int, nonce: int, room_id: str, payload: Any) -> Event:
102
- return with_room_id(create_event(DISCOURSE_PROTOCOL, event_type, actor, created_at, nonce, payload), room_id)
149
+ def discourse_event(
150
+ event_type: str,
151
+ actor: AgentId,
152
+ created_at: int,
153
+ nonce: int,
154
+ room_id: str,
155
+ base_seq: int,
156
+ base_hash: str,
157
+ payload: Any,
158
+ ) -> Event:
159
+ return with_room_head(
160
+ with_room_id(create_event(DISCOURSE_PROTOCOL, event_type, actor, created_at, nonce, payload), room_id),
161
+ base_seq,
162
+ base_hash,
163
+ )
103
164
 
104
165
 
105
166
  def is_builtin_event_type(event_type: str) -> bool:
@@ -117,25 +178,54 @@ def validate_discourse_envelope(envelope: Envelope) -> None:
117
178
  if protocol != DISCOURSE_PROTOCOL:
118
179
  raise AgentProtocolError("invalid_event_protocol", f"expected {DISCOURSE_PROTOCOL}, got {protocol}")
119
180
  if event["type"] == ROOM_CREATE:
120
- if "room_id" in event:
121
- raise AgentProtocolError("invalid_event", "room.create must not include room_id")
122
- elif "room_id" not in event:
123
- raise AgentProtocolError("missing_room_id", "event requires a room_id")
181
+ _validate_room_create_event_fields(event)
182
+ else:
183
+ if "room_id" not in event:
184
+ raise AgentProtocolError("missing_room_id", "event requires a room_id")
185
+ validate_room_head_precondition(event)
186
+ _validate_mentions(event.get("mentions"))
124
187
 
125
188
 
126
189
  def validate_room_path(envelope: Envelope, path_room_id: str) -> None:
127
190
  event = envelope["event"]
128
191
  actual = event.get("room_id")
129
192
  if event["type"] == ROOM_CREATE:
130
- if actual is not None:
131
- raise AgentProtocolError("invalid_event", "room.create must not include room_id")
193
+ _validate_room_create_event_fields(event)
132
194
  return
195
+ validate_room_head_precondition(event)
196
+ _validate_mentions(event.get("mentions"))
133
197
  if actual is None:
134
198
  raise AgentProtocolError("missing_room_id", "event requires a room_id")
135
199
  if actual != path_room_id:
136
200
  raise AgentProtocolError("room_id_mismatch", f"expected {path_room_id}, got {actual}")
137
201
 
138
202
 
203
+ def _validate_room_create_event_fields(event: Event) -> None:
204
+ if any(field in event for field in ("room_id", "base_seq", "base_hash", "mentions")):
205
+ raise AgentProtocolError(
206
+ "invalid_event",
207
+ "room.create must not include room_id, base_seq, base_hash, or mentions",
208
+ )
209
+
210
+
211
+ def validate_room_head_precondition(event: Event) -> None:
212
+ base_seq = event.get("base_seq")
213
+ base_hash = event.get("base_hash")
214
+ if not isinstance(base_seq, int) or base_seq < 1 or base_seq > MAX_SAFE_NONCE:
215
+ raise AgentProtocolError("invalid_event", "base_seq must be a positive safe JSON integer")
216
+ if not isinstance(base_hash, str) or not base_hash.strip():
217
+ raise AgentProtocolError("invalid_event", "base_hash must not be empty")
218
+
219
+
220
+ def _validate_mentions(mentions: Any) -> None:
221
+ if mentions is None:
222
+ return
223
+ if not isinstance(mentions, list):
224
+ raise AgentProtocolError("invalid_event", "mentions must be an Agent ID array")
225
+ for mention in mentions:
226
+ validate_agent_id(mention)
227
+
228
+
139
229
  def validate_custom_event_type_name(name: str) -> None:
140
230
  """Checks the shape of a custom event type name: lowercase dot-separated,
141
231
  at least two segments, not built-in, not under a reserved prefix."""
@@ -349,6 +439,19 @@ def validate_message_create_payload(payload: dict[str, Any]) -> None:
349
439
  raise AgentProtocolError("invalid_event", "content_type must not be empty")
350
440
 
351
441
 
442
+ def validate_room_join_payload(payload: dict[str, Any]) -> None:
443
+ if payload.get("role") not in ROLES:
444
+ raise AgentProtocolError("invalid_event", f"invalid room role: {payload.get('role')}")
445
+ request_id = payload.get("request_id")
446
+ if request_id is not None and not str(request_id).strip():
447
+ raise AgentProtocolError("invalid_event", "request_id must not be empty")
448
+ if request_id is not None and "perspective" in payload:
449
+ raise AgentProtocolError(
450
+ "invalid_event",
451
+ "room.join payload cannot include both request_id and perspective",
452
+ )
453
+
454
+
352
455
  def server_record_hash_payload(
353
456
  room_id: str,
354
457
  seq: int,
@@ -448,7 +551,7 @@ def can_submit_event(
448
551
  if event_type == ROOM_CREATE:
449
552
  return True
450
553
  if event_type == ROOM_JOIN:
451
- return bool(context.get("join_request_approved"))
554
+ return bool(context.get("public_join_allowed") or context.get("join_request_approved"))
452
555
  if event_type == ROOM_LEAVE:
453
556
  return is_creator or role is not None
454
557
  if event_type in {ROOM_JOIN_REVIEW, ROOM_MEMBER_ROLE_UPDATE, ROOM_CLOSE, ROOM_CANCEL, TYPE_DEFINE}:
@@ -53,6 +53,43 @@ class DiscourseClient:
53
53
  def room(self, room_id: str) -> dict[str, Any]:
54
54
  return self._get(f"/v1/rooms/{room_id}")
55
55
 
56
+ def public_rooms(
57
+ self,
58
+ *,
59
+ status: str | None = None,
60
+ tag: str | None = None,
61
+ keyword: str | None = None,
62
+ creator: str | None = None,
63
+ starts_after: int | None = None,
64
+ ends_before: int | None = None,
65
+ language: str | None = None,
66
+ limit: int | None = None,
67
+ cursor: str | None = None,
68
+ ) -> list[dict[str, Any]]:
69
+ query = urlencode(
70
+ {
71
+ key: value
72
+ for key, value in {
73
+ "status": status,
74
+ "tag": tag,
75
+ "keyword": keyword,
76
+ "creator": creator,
77
+ "starts_after": starts_after,
78
+ "ends_before": ends_before,
79
+ "language": language,
80
+ "limit": limit,
81
+ "cursor": cursor,
82
+ }.items()
83
+ if value is not None
84
+ },
85
+ quote_via=quote,
86
+ )
87
+ suffix = f"?{query}" if query else ""
88
+ return self._get(f"/v1/rooms/public{suffix}")
89
+
90
+ def my_rooms(self, jwt: str) -> list[dict[str, Any]]:
91
+ return self._get("/v1/me/rooms", jwt=jwt)
92
+
56
93
  def request_join(self, room_id: str, jwt: str, request: dict[str, Any]) -> dict[str, Any]:
57
94
  return self._post(f"/v1/rooms/{room_id}/join-requests", request, jwt=jwt)
58
95
 
@@ -95,8 +132,17 @@ class DiscourseClient:
95
132
  suffix = f"?{query}" if query else ""
96
133
  return self._get(f"/v1/rooms/{room_id}/events{suffix}", jwt=jwt)
97
134
 
98
- def websocket_events_url(self, room_id: str, jwt: str) -> str:
99
- return websocket_events_url(self.base_url, room_id, jwt)
135
+ def agent_statuses(self, room_id: str, jwt: str | None = None) -> dict[str, Any]:
136
+ return self._get(f"/v1/rooms/{room_id}/agent-status", jwt=jwt)
137
+
138
+ def agent_status(self, room_id: str, agent_id: AgentId, jwt: str | None = None) -> dict[str, Any]:
139
+ return self._get(f"/v1/rooms/{room_id}/agent-status/{agent_id}", jwt=jwt)
140
+
141
+ def set_agent_status(self, room_id: str, jwt: str, status: dict[str, Any]) -> dict[str, Any]:
142
+ return self._put(f"/v1/rooms/{room_id}/agent-status", status, jwt=jwt)
143
+
144
+ def sse_events_url(self, room_id: str) -> str:
145
+ return sse_events_url(self.base_url, room_id)
100
146
 
101
147
  def archive(self, room_id: str) -> dict[str, Any]:
102
148
  return self._get(f"/v1/rooms/{room_id}/archive")
@@ -111,14 +157,56 @@ class DiscourseClient:
111
157
  response.raise_for_status()
112
158
  return response.json()
113
159
 
160
+ def _put(self, path: str, body: Any, jwt: str | None = None) -> Any:
161
+ response = self.session.put(self.base_url + path, json=body, headers=_auth_headers(jwt))
162
+ response.raise_for_status()
163
+ return response.json()
164
+
165
+
166
+ class DelegationClient:
167
+ def __init__(self, base_url: str, session: Any | None = None):
168
+ self.base_url = base_url.rstrip("/")
169
+ self.session = session or _requests_session()
170
+
171
+ def protocol(self) -> dict[str, Any]:
172
+ return self._get("/.well-known/agent-delegation")
173
+
174
+ def principal(self, principal_url: str | None = None) -> dict[str, Any]:
175
+ response = self.session.get(
176
+ principal_url or self.base_url,
177
+ headers={"Accept": "application/json"},
178
+ )
179
+ response.raise_for_status()
180
+ return response.json()
181
+
182
+ def delegation(self, delegation_id: str) -> dict[str, Any]:
183
+ return self._get(f"/v1/delegations/{delegation_id}")
184
+
185
+ def delegation_status(self, delegation_id: str) -> dict[str, Any]:
186
+ return self._get(f"/v1/delegations/{delegation_id}/status")
187
+
188
+ def delegation_events(self, delegation_id: str) -> dict[str, Any]:
189
+ return self._get(f"/v1/delegations/{delegation_id}/events")
190
+
191
+ def submit_delegation_event(self, envelope: Envelope) -> dict[str, Any]:
192
+ return self._post("/v1/delegations", envelope)
193
+
194
+ def query_delegations(self, request: dict[str, Any]) -> dict[str, Any]:
195
+ return self._post("/v1/delegations/query", request)
196
+
197
+ def _get(self, path: str) -> Any:
198
+ response = self.session.get(self.base_url + path)
199
+ response.raise_for_status()
200
+ return response.json()
201
+
202
+ def _post(self, path: str, body: Any) -> dict[str, Any]:
203
+ response = self.session.post(self.base_url + path, json=body)
204
+ response.raise_for_status()
205
+ return response.json()
206
+
114
207
 
115
- def websocket_events_url(base_url: str, room_id: str, jwt: str) -> str:
116
- websocket_base = base_url.rstrip("/")
117
- if websocket_base.startswith("https://"):
118
- websocket_base = "wss://" + websocket_base[len("https://") :]
119
- elif websocket_base.startswith("http://"):
120
- websocket_base = "ws://" + websocket_base[len("http://") :]
121
- return f"{websocket_base}/v1/rooms/{quote(room_id, safe='')}/events/live?access_token={quote(jwt, safe='')}"
208
+ def sse_events_url(base_url: str, room_id: str) -> str:
209
+ return f"{base_url.rstrip('/')}/v1/rooms/{quote(room_id, safe='')}/events/live"
122
210
 
123
211
 
124
212
  def _auth_headers(jwt: str | None) -> dict[str, str] | None:
@@ -171,6 +171,31 @@ def with_room_id(event: Event, room_id: str) -> Event:
171
171
  return next_event
172
172
 
173
173
 
174
+ def with_room_head(event: Event, base_seq: int, base_hash: str) -> Event:
175
+ validate_nonce(base_seq)
176
+ if not isinstance(base_hash, str) or not base_hash.strip():
177
+ raise AgentProtocolError("invalid_event", "base_hash must not be empty")
178
+ next_event = dict(event)
179
+ next_event["base_seq"] = base_seq
180
+ next_event["base_hash"] = base_hash
181
+ return next_event
182
+
183
+
184
+ def with_mentions(event: Event, mentions: list[AgentId]) -> Event:
185
+ for mention in mentions:
186
+ validate_agent_id(mention)
187
+ next_event = dict(event)
188
+ next_event["mentions"] = list(mentions)
189
+ return next_event
190
+
191
+
192
+ def with_mention(event: Event, agent_id: AgentId) -> Event:
193
+ validate_agent_id(agent_id)
194
+ next_event = dict(event)
195
+ next_event["mentions"] = [*next_event.get("mentions", []), agent_id]
196
+ return next_event
197
+
198
+
174
199
  def canonical_event_bytes(event: Event) -> bytes:
175
200
  canonical = rfc8785.dumps(event)
176
201
  return canonical if isinstance(canonical, bytes) else canonical.encode()
@@ -42,6 +42,7 @@ def materialize_profile(envelope: Envelope) -> AgentProfile:
42
42
  "capabilities": payload.get("capabilities", []),
43
43
  "service_endpoints": payload.get("service_endpoints", []),
44
44
  "links": payload.get("links", []),
45
+ "delegations": payload.get("delegations", []),
45
46
  "extra": payload.get("extra", {}),
46
47
  "updated_at": envelope["event"]["created_at"],
47
48
  "event_id": envelope["hash"],
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-protocols
3
- Version: 0.3.1
4
- Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
3
+ Version: 0.4.1
4
+ Summary: Python SDK for Agent Identity, Agent Profile, Agent Delegation, and Agent Discourse protocols
5
5
  Author: LDCLabs
6
6
  License: MIT
7
7
  Keywords: agent,protocol,ed25519,sdk
@@ -15,14 +15,15 @@ Requires-Dist: requests<3,>=2.31; extra == "http"
15
15
 
16
16
  # agent-protocols Python SDK
17
17
 
18
- Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse protocols.
18
+ Python SDK for the draft Agent Identity, Agent Profile, Agent Delegation, and Agent Discourse protocols.
19
19
 
20
20
  ## Modules
21
21
 
22
22
  - `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event hashes, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
23
- - `agent_protocols.profile`: `profile.update` payload helpers, validation, materialization.
23
+ - `agent_protocols.profile`: `profile.update` payload helpers, delegation discovery hints, validation, materialization.
24
+ - `agent_protocols.delegation`: Agent Delegation principal documents, grant/revoke payloads, credential documents, validation, and materialization.
24
25
  - `agent_protocols.discourse`: ADP kernel event constants, the room type system (type definitions, pack imports, type registry, JSON Schema payload validation), join request helpers, room-path checks, kind-based permission and state helpers.
25
- - `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
26
+ - `agent_protocols.http_client`: optional requests-based Profile, Delegation, and Discourse clients. Install with `agent-protocols[http]`.
26
27
 
27
28
  ## Example
28
29
 
@@ -42,3 +43,5 @@ profile = materialize_profile(envelope)
42
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.
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`.
@@ -1,6 +1,7 @@
1
1
  README.md
2
2
  pyproject.toml
3
3
  src/agent_protocols/__init__.py
4
+ src/agent_protocols/delegation.py
4
5
  src/agent_protocols/discourse.py
5
6
  src/agent_protocols/errors.py
6
7
  src/agent_protocols/http_client.py
@@ -11,6 +12,7 @@ src/agent_protocols.egg-info/SOURCES.txt
11
12
  src/agent_protocols.egg-info/dependency_links.txt
12
13
  src/agent_protocols.egg-info/requires.txt
13
14
  src/agent_protocols.egg-info/top_level.txt
15
+ tests/test_delegation.py
14
16
  tests/test_discourse.py
15
17
  tests/test_discourse_coverage.py
16
18
  tests/test_http_client.py
@@ -0,0 +1,124 @@
1
+ import unittest
2
+
3
+ from agent_protocols.delegation import (
4
+ DELEGATION_GRANT,
5
+ DELEGATION_PROTOCOL,
6
+ DELEGATION_REVOKE,
7
+ delegation_grant_event,
8
+ delegation_revoke_event,
9
+ materialize_delegation_credential,
10
+ validate_delegation_envelope,
11
+ validate_delegation_grant_payload,
12
+ validate_principal_document,
13
+ )
14
+ from agent_protocols.identity import AgentSigner, create_event
15
+
16
+
17
+ class DelegationTests(unittest.TestCase):
18
+ def test_grant_event_materializes_credential(self):
19
+ controller = AgentSigner.from_seed(bytes([31]) * 32)
20
+ subject = AgentSigner.from_seed(bytes([32]) * 32)
21
+ payload = {
22
+ "id": "del_01J8ZM7A3G2T9B4Q6X8R0N1P2Q",
23
+ "principal": {
24
+ "id": "https://al.ink/yan",
25
+ "type": "person",
26
+ "name": "Yan",
27
+ },
28
+ "subject": subject.agent_id(),
29
+ "relationship": "primary_delegate",
30
+ "scopes": ["inbox.screen", "meeting.propose"],
31
+ "constraints": {"requires_human_approval": ["meeting.accept"]},
32
+ "not_before": 1_779_753_600_000,
33
+ "expires_at": 1_790_000_000_000,
34
+ }
35
+ envelope = controller.sign_event(
36
+ delegation_grant_event(controller.agent_id(), 1_779_753_600_000, 1, payload)
37
+ )
38
+
39
+ validate_delegation_envelope(envelope)
40
+ credential = materialize_delegation_credential(
41
+ envelope,
42
+ status_url="https://al.ink/v1/delegations/del/status",
43
+ )
44
+
45
+ self.assertEqual(envelope["event"]["type"], DELEGATION_GRANT)
46
+ self.assertEqual(credential["protocol"], DELEGATION_PROTOCOL)
47
+ self.assertEqual(credential["controller"], controller.agent_id())
48
+ self.assertEqual(credential["subject"], subject.agent_id())
49
+ self.assertEqual(credential["status"], "active")
50
+ self.assertEqual(credential["event_id"], envelope["hash"])
51
+
52
+ def test_revoke_event_and_invalid_payloads(self):
53
+ controller = AgentSigner.from_seed(bytes([33]) * 32)
54
+ envelope = controller.sign_event(
55
+ delegation_revoke_event(
56
+ controller.agent_id(),
57
+ 1_779_753_700_000,
58
+ 2,
59
+ {
60
+ "id": "del_01J8ZM7A3G2T9B4Q6X8R0N1P2Q",
61
+ "principal_id": "https://al.ink/yan",
62
+ "reason": "rotated_primary_agent",
63
+ },
64
+ )
65
+ )
66
+
67
+ self.assertEqual(envelope["event"]["type"], DELEGATION_REVOKE)
68
+ validate_delegation_envelope(envelope)
69
+ with self.assertRaisesRegex(Exception, "HTTPS|scopes"):
70
+ validate_delegation_grant_payload(
71
+ {
72
+ "id": "del",
73
+ "principal": {"id": "http://example.com"},
74
+ "subject": controller.agent_id(),
75
+ "scopes": [],
76
+ }
77
+ )
78
+
79
+ def test_principal_document_and_event_type_validation(self):
80
+ controller = AgentSigner.from_seed(bytes([34]) * 32)
81
+ validate_principal_document(
82
+ {
83
+ "id": "https://profiles.example.com/org/acme",
84
+ "controllers": [controller.agent_id()],
85
+ "delegations_url": "https://profiles.example.com/v1/delegations/query",
86
+ }
87
+ )
88
+ with self.assertRaises(Exception):
89
+ validate_principal_document(
90
+ {
91
+ "id": "https://profiles.example.com/org/acme",
92
+ "controllers": [],
93
+ }
94
+ )
95
+
96
+ wrong = controller.sign_event(
97
+ create_event(
98
+ DELEGATION_PROTOCOL,
99
+ "delegation.unknown",
100
+ controller.agent_id(),
101
+ 1,
102
+ 1,
103
+ {"id": "del"},
104
+ )
105
+ )
106
+ with self.assertRaisesRegex(Exception, "delegation.grant"):
107
+ validate_delegation_envelope(wrong)
108
+
109
+ def test_rejects_malformed_https_like_principal_urls(self):
110
+ signer = AgentSigner.from_seed(bytes([35]) * 32)
111
+ for principal_id in ("https://", "https://[::1", "http://example.com"):
112
+ with self.assertRaises(Exception):
113
+ validate_delegation_grant_payload(
114
+ {
115
+ "id": "del",
116
+ "principal": {"id": principal_id},
117
+ "subject": signer.agent_id(),
118
+ "scopes": ["scope"],
119
+ }
120
+ )
121
+
122
+
123
+ if __name__ == "__main__":
124
+ unittest.main()
@@ -42,7 +42,7 @@ from agent_protocols.discourse import (
42
42
  verify_server_record_chain,
43
43
  )
44
44
  from agent_protocols.errors import AgentProtocolError
45
- from agent_protocols.http_client import websocket_events_url
45
+ from agent_protocols.http_client import sse_events_url
46
46
  from agent_protocols.identity import AgentSigner, create_event
47
47
 
48
48
  PACKS_PATH = Path(__file__).resolve().parents[3] / "docs/protocols/agent-discourse/1.0.packs.json"
@@ -143,6 +143,8 @@ class DiscourseTests(unittest.TestCase):
143
143
  },
144
144
  )
145
145
  event["room_id"] = "d8ftedhpqhsusbg001tg"
146
+ event["base_seq"] = 17
147
+ event["base_hash"] = "GDt8oHZQfQ3jl5ZUfyNxKZu07yAJdDYuaw_jf_JjLYs"
146
148
  envelope = moderator.sign_event(event)
147
149
  validator = Draft202012Validator(json.loads(SCHEMA_PATH.read_text()))
148
150
 
@@ -336,7 +338,15 @@ class DiscourseTests(unittest.TestCase):
336
338
 
337
339
  def test_signs_and_validates_type_define_envelopes(self):
338
340
  signer = AgentSigner.from_seed(bytes([16]) * 32)
339
- event = type_define_event(signer.agent_id(), 100, 1, "d8ftedhpqhsusbg001tg", dict(FINDING_DEF))
341
+ event = type_define_event(
342
+ signer.agent_id(),
343
+ 100,
344
+ 1,
345
+ "d8ftedhpqhsusbg001tg",
346
+ 1,
347
+ "room-create-head",
348
+ dict(FINDING_DEF),
349
+ )
340
350
  envelope = signer.sign_event(event)
341
351
  validate_discourse_envelope(envelope)
342
352
 
@@ -371,6 +381,8 @@ class DiscourseTests(unittest.TestCase):
371
381
  {"content_type": "text/plain", "content": "hello"},
372
382
  )
373
383
  event2["room_id"] = "room123"
384
+ event2["base_seq"] = 1
385
+ event2["base_hash"] = record1["hash"]
374
386
  envelope2 = signer.sign_event(event2)
375
387
  record2 = build_server_record("room123", 2, record1["hash"], 130, envelope2)
376
388
 
@@ -383,10 +395,10 @@ class DiscourseTests(unittest.TestCase):
383
395
  with self.assertRaises(AgentProtocolError):
384
396
  verify_server_record_chain([{**record2, "pre_hash": "bad"}])
385
397
 
386
- def test_builds_websocket_event_stream_url(self):
398
+ def test_builds_sse_event_stream_url(self):
387
399
  self.assertEqual(
388
- websocket_events_url("https://api.example.com", "room123", "jwt.token"),
389
- "wss://api.example.com/v1/rooms/room123/events/live?access_token=jwt.token",
400
+ sse_events_url("https://api.example.com", "room123"),
401
+ "https://api.example.com/v1/rooms/room123/events/live",
390
402
  )
391
403
 
392
404
 
@@ -40,13 +40,15 @@ class HelperBranchTests(unittest.TestCase):
40
40
  signer = AgentSigner.from_seed(bytes([60]) * 32)
41
41
  self.assertTrue(event_requires_room_id(MESSAGE_CREATE))
42
42
  self.assertFalse(event_requires_room_id(ROOM_CREATE))
43
- event = discourse_event(MESSAGE_CREATE, signer.agent_id(), 1, 1, "room1", {"a": 1})
43
+ event = discourse_event(MESSAGE_CREATE, signer.agent_id(), 1, 1, "room1", 1, "room-create-head", {"a": 1})
44
44
  self.assertEqual(event["room_id"], "room1")
45
+ self.assertEqual(event["base_seq"], 1)
46
+ self.assertEqual(event["base_hash"], "room-create-head")
45
47
  self.assertEqual(event["protocol"], DISCOURSE_PROTOCOL)
46
48
 
47
49
  def test_validate_discourse_envelope_rejects_foreign_protocol(self):
48
50
  signer = AgentSigner.from_seed(bytes([61]) * 32)
49
- event = discourse_event(MESSAGE_CREATE, signer.agent_id(), 1, 1, "room1", {"a": 1})
51
+ event = discourse_event(MESSAGE_CREATE, signer.agent_id(), 1, 1, "room1", 1, "room-create-head", {"a": 1})
50
52
  event["protocol"] = "other/1.0"
51
53
  envelope = signer.sign_event(event)
52
54
  with self.assertRaises(AgentProtocolError):
@@ -55,7 +57,7 @@ class HelperBranchTests(unittest.TestCase):
55
57
  def test_validate_room_path_matches_mismatches_and_requires_room_id(self):
56
58
  signer = AgentSigner.from_seed(bytes([62]) * 32)
57
59
  in_room = signer.sign_event(
58
- discourse_event(MESSAGE_CREATE, signer.agent_id(), 1, 1, "room1", {"a": 1})
60
+ discourse_event(MESSAGE_CREATE, signer.agent_id(), 1, 1, "room1", 1, "room-create-head", {"a": 1})
59
61
  )
60
62
  validate_room_path(in_room, "room1")
61
63
  with self.assertRaises(AgentProtocolError):
@@ -63,7 +65,7 @@ class HelperBranchTests(unittest.TestCase):
63
65
 
64
66
  no_room = {
65
67
  "hash": "h",
66
- "event": {"type": MESSAGE_CREATE, "protocol": DISCOURSE_PROTOCOL},
68
+ "event": {"type": MESSAGE_CREATE, "protocol": DISCOURSE_PROTOCOL, "base_seq": 1, "base_hash": "room-create-head"},
67
69
  "signature": "s",
68
70
  }
69
71
  with self.assertRaises(AgentProtocolError):
@@ -169,7 +171,16 @@ class ServerRecordChainTests(unittest.TestCase):
169
171
 
170
172
  def make(seq, nonce, pre_hash):
171
173
  envelope = signer.sign_event(
172
- discourse_event(MESSAGE_CREATE, signer.agent_id(), 100, nonce, "room1", {"a": 1})
174
+ discourse_event(
175
+ MESSAGE_CREATE,
176
+ signer.agent_id(),
177
+ 100,
178
+ nonce,
179
+ "room1",
180
+ 1 if seq == 1 else seq - 1,
181
+ pre_hash or "room-create-head",
182
+ {"a": 1},
183
+ )
173
184
  )
174
185
  return build_server_record("room1", seq, pre_hash, 100 + seq, envelope)
175
186
 
@@ -2,9 +2,10 @@ import unittest
2
2
 
3
3
  import agent_protocols.http_client as http_client
4
4
  from agent_protocols.http_client import (
5
+ DelegationClient,
5
6
  DiscourseClient,
6
7
  ProfileClient,
7
- websocket_events_url,
8
+ sse_events_url,
8
9
  )
9
10
  from agent_protocols.identity import AgentSigner
10
11
 
@@ -37,6 +38,10 @@ class FakeSession:
37
38
  self.calls.append(("POST", url, headers, json))
38
39
  return self._responses.pop(0)
39
40
 
41
+ def put(self, url, json=None, headers=None):
42
+ self.calls.append(("PUT", url, headers, json))
43
+ return self._responses.pop(0)
44
+
40
45
 
41
46
  class ProfileClientTests(unittest.TestCase):
42
47
  def test_every_endpoint_uses_the_expected_method_and_path(self):
@@ -119,28 +124,96 @@ class DiscourseClientTests(unittest.TestCase):
119
124
  self.assertEqual(session.calls[10][2], {"Authorization": "Bearer jwt-d"})
120
125
  self.assertEqual(urls[11], "https://api.example.com/v1/rooms/room1/archive")
121
126
 
122
- def test_websocket_events_url_method(self):
127
+ def test_sse_events_url_method(self):
123
128
  session = FakeSession([])
124
129
  client = DiscourseClient("https://api.example.com", session=session)
125
130
  self.assertEqual(
126
- client.websocket_events_url("room1", "jwt.token"),
127
- websocket_events_url("https://api.example.com", "room1", "jwt.token"),
131
+ client.sse_events_url("room1"),
132
+ sse_events_url("https://api.example.com", "room1"),
133
+ )
134
+
135
+ def test_public_rooms_my_rooms_and_agent_status_endpoints(self):
136
+ session = FakeSession([FakeResponse({"ok": True}) for _ in range(5)])
137
+ client = DiscourseClient("https://api.example.com/", session=session)
138
+
139
+ client.public_rooms(
140
+ status="active",
141
+ tag="code review",
142
+ starts_after=10,
143
+ ends_before=20,
144
+ limit=5,
145
+ cursor="next page",
146
+ )
147
+ client.my_rooms("jwt-me")
148
+ client.agent_statuses("room1", jwt="jwt-statuses")
149
+ client.agent_status("room1", AGENT_ID, jwt="jwt-status")
150
+ client.set_agent_status("room1", "jwt-set", {"state": "idle", "expires_at": 2})
151
+
152
+ urls = [call[1] for call in session.calls]
153
+ self.assertEqual(
154
+ urls[0],
155
+ "https://api.example.com/v1/rooms/public?status=active&tag=code%20review&starts_after=10&ends_before=20&limit=5&cursor=next%20page",
128
156
  )
157
+ self.assertEqual(urls[1], "https://api.example.com/v1/me/rooms")
158
+ self.assertEqual(session.calls[1][2], {"Authorization": "Bearer jwt-me"})
159
+ self.assertEqual(urls[2], "https://api.example.com/v1/rooms/room1/agent-status")
160
+ self.assertEqual(session.calls[2][2], {"Authorization": "Bearer jwt-statuses"})
161
+ self.assertEqual(urls[3], f"https://api.example.com/v1/rooms/room1/agent-status/{AGENT_ID}")
162
+ self.assertEqual(session.calls[4][0], "PUT")
163
+ self.assertEqual(session.calls[4][2], {"Authorization": "Bearer jwt-set"})
164
+ self.assertEqual(session.calls[4][3], {"state": "idle", "expires_at": 2})
165
+
166
+
167
+ class DelegationClientTests(unittest.TestCase):
168
+ def test_every_endpoint_uses_expected_method_and_path(self):
169
+ session = FakeSession([FakeResponse({"ok": True}) for _ in range(7)])
170
+ client = DelegationClient("https://al.ink/", session=session)
171
+ envelope = {
172
+ "hash": "h",
173
+ "event": {
174
+ "protocol": "agent-delegation/1.0",
175
+ "type": "delegation.revoke",
176
+ "actor": AGENT_ID,
177
+ "created_at": 1,
178
+ "nonce": 1,
179
+ "payload": {"id": "del_1", "principal_id": "https://al.ink/yan"},
180
+ },
181
+ "signature": "s",
182
+ }
183
+
184
+ client.protocol()
185
+ client.principal("https://al.ink/yan")
186
+ client.delegation("del_1")
187
+ client.delegation_status("del_1")
188
+ client.delegation_events("del_1")
189
+ client.submit_delegation_event(envelope)
190
+ client.query_delegations({"subject": AGENT_ID, "status": "active", "limit": 20})
191
+
192
+ urls = [call[1] for call in session.calls]
193
+ self.assertEqual(urls[0], "https://al.ink/.well-known/agent-delegation")
194
+ self.assertEqual(urls[1], "https://al.ink/yan")
195
+ self.assertEqual(session.calls[1][2], {"Accept": "application/json"})
196
+ self.assertEqual(urls[2], "https://al.ink/v1/delegations/del_1")
197
+ self.assertEqual(urls[3], "https://al.ink/v1/delegations/del_1/status")
198
+ self.assertEqual(urls[4], "https://al.ink/v1/delegations/del_1/events")
199
+ self.assertEqual((session.calls[5][0], urls[5]), ("POST", "https://al.ink/v1/delegations"))
200
+ self.assertEqual((session.calls[6][0], urls[6]), ("POST", "https://al.ink/v1/delegations/query"))
201
+ self.assertEqual(session.calls[6][3]["status"], "active")
129
202
 
130
203
 
131
204
  class HelperTests(unittest.TestCase):
132
- def test_websocket_events_url_rewrites_schemes(self):
205
+ def test_sse_events_url_preserves_http_schemes(self):
133
206
  self.assertEqual(
134
- websocket_events_url("https://api.example.com", "room123", "jwt.token"),
135
- "wss://api.example.com/v1/rooms/room123/events/live?access_token=jwt.token",
207
+ sse_events_url("https://api.example.com", "room123"),
208
+ "https://api.example.com/v1/rooms/room123/events/live",
136
209
  )
137
210
  self.assertEqual(
138
- websocket_events_url("http://api.example.com/", "room 1", "a+b"),
139
- "ws://api.example.com/v1/rooms/room%201/events/live?access_token=a%2Bb",
211
+ sse_events_url("http://api.example.com/", "room 1"),
212
+ "http://api.example.com/v1/rooms/room%201/events/live",
140
213
  )
141
214
  self.assertEqual(
142
- websocket_events_url("ftp://api.example.com", "r", "t"),
143
- "ftp://api.example.com/v1/rooms/r/events/live?access_token=t",
215
+ sse_events_url("ftp://api.example.com", "r"),
216
+ "ftp://api.example.com/v1/rooms/r/events/live",
144
217
  )
145
218
 
146
219
  def test_requests_session_factory(self):
@@ -21,9 +21,12 @@ from agent_protocols.identity import (
21
21
  unix_ms,
22
22
  unix_secs,
23
23
  validate_agent_id,
24
+ verify_envelope,
24
25
  verify_event_hash_signature,
25
26
  verify_request_jwt,
26
27
  verify_timestamp,
28
+ with_mention,
29
+ with_room_head,
27
30
  with_room_id,
28
31
  )
29
32
  from agent_protocols.errors import AgentProtocolError
@@ -65,8 +68,18 @@ class AgentIdTests(unittest.TestCase):
65
68
  class EventTests(unittest.TestCase):
66
69
  def test_create_event_and_with_room_id(self):
67
70
  signer = AgentSigner.from_seed(bytes([21]) * 32)
71
+ target = AgentSigner.from_seed(bytes([22]) * 32)
68
72
  event = create_event("p", "t", signer.agent_id(), 1000, 1, {"a": 1})
69
73
  self.assertEqual(with_room_id(event, "room1")["room_id"], "room1")
74
+ room_event = with_mention(with_room_head(with_room_id(event, "room1"), 1, "room-create-head"), target.agent_id())
75
+ self.assertEqual(room_event["base_seq"], 1)
76
+ self.assertEqual(room_event["base_hash"], "room-create-head")
77
+ self.assertEqual(room_event["mentions"], [target.agent_id()])
78
+ verify_envelope(signer.sign_event(room_event))
79
+ with self.assertRaises(AgentProtocolError):
80
+ with_room_head(event, 0, "head")
81
+ with self.assertRaises(AgentProtocolError):
82
+ with_mention(event, "bad-agent")
70
83
  with self.assertRaises(AgentProtocolError):
71
84
  create_event("p", "t", "bad-actor", 1, 1, {})
72
85
 
@@ -13,6 +13,14 @@ class ProfileTests(unittest.TestCase):
13
13
  "capabilities": ["research"],
14
14
  "extra": {"domain": "research"},
15
15
  "links": [{"name": "Homepage", "url": "https://example.com", "rel": "homepage"}],
16
+ "delegations": [
17
+ {
18
+ "principal": {"id": "https://al.ink/yan", "type": "person", "name": "Yan"},
19
+ "relationship": "primary_delegate",
20
+ "scopes": ["inbox.screen"],
21
+ "credential_url": "https://al.ink/v1/delegations/del_1",
22
+ }
23
+ ],
16
24
  }
17
25
  envelope = signer.sign_event(profile_update_event(signer.agent_id(), 1_779_753_600_000, 1, payload))
18
26
 
@@ -22,6 +30,7 @@ class ProfileTests(unittest.TestCase):
22
30
  self.assertEqual(profile["name"], "ResearchAgent-v3")
23
31
  self.assertIsNone(profile["username"])
24
32
  self.assertEqual(profile["links"], payload["links"])
33
+ self.assertEqual(profile["delegations"], payload["delegations"])
25
34
  self.assertEqual(profile["extra"], payload["extra"])
26
35
  self.assertEqual(profile["updated_at"], 1_779_753_600_000)
27
36
  self.assertEqual(profile["event_id"], envelope["hash"])