agent-protocols 0.4.0__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.
- {agent_protocols-0.4.0 → agent_protocols-0.5.0}/PKG-INFO +8 -7
- agent_protocols-0.5.0/README.md +32 -0
- {agent_protocols-0.4.0 → agent_protocols-0.5.0}/pyproject.toml +2 -2
- {agent_protocols-0.4.0 → agent_protocols-0.5.0}/src/agent_protocols/__init__.py +35 -1
- agent_protocols-0.5.0/src/agent_protocols/delegation.py +174 -0
- {agent_protocols-0.4.0 → agent_protocols-0.5.0}/src/agent_protocols/discourse.py +170 -4
- agent_protocols-0.5.0/src/agent_protocols/errors.py +6 -0
- {agent_protocols-0.4.0 → agent_protocols-0.5.0}/src/agent_protocols/http_client.py +76 -4
- {agent_protocols-0.4.0 → agent_protocols-0.5.0}/src/agent_protocols/identity.py +44 -9
- {agent_protocols-0.4.0 → agent_protocols-0.5.0}/src/agent_protocols/profile.py +11 -1
- {agent_protocols-0.4.0 → agent_protocols-0.5.0}/src/agent_protocols.egg-info/PKG-INFO +8 -7
- {agent_protocols-0.4.0 → agent_protocols-0.5.0}/src/agent_protocols.egg-info/SOURCES.txt +2 -0
- agent_protocols-0.5.0/tests/test_delegation.py +166 -0
- {agent_protocols-0.4.0 → agent_protocols-0.5.0}/tests/test_discourse.py +114 -0
- {agent_protocols-0.4.0 → agent_protocols-0.5.0}/tests/test_http_client.py +38 -0
- {agent_protocols-0.4.0 → agent_protocols-0.5.0}/tests/test_identity.py +83 -0
- {agent_protocols-0.4.0 → agent_protocols-0.5.0}/tests/test_profile.py +37 -4
- agent_protocols-0.4.0/README.md +0 -31
- agent_protocols-0.4.0/src/agent_protocols/errors.py +0 -4
- {agent_protocols-0.4.0 → agent_protocols-0.5.0}/setup.cfg +0 -0
- {agent_protocols-0.4.0 → agent_protocols-0.5.0}/src/agent_protocols.egg-info/dependency_links.txt +0 -0
- {agent_protocols-0.4.0 → agent_protocols-0.5.0}/src/agent_protocols.egg-info/requires.txt +0 -0
- {agent_protocols-0.4.0 → agent_protocols-0.5.0}/src/agent_protocols.egg-info/top_level.txt +0 -0
- {agent_protocols-0.4.0 → agent_protocols-0.5.0}/tests/test_discourse_coverage.py +0 -0
- {agent_protocols-0.4.0 → agent_protocols-0.5.0}/tests/test_identity_coverage.py +0 -0
- {agent_protocols-0.4.0 → agent_protocols-0.5.0}/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.
|
|
4
|
-
Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
|
|
3
|
+
Version: 0.5.0
|
|
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
|
|
|
@@ -41,6 +42,6 @@ envelope = signer.sign_event(event)
|
|
|
41
42
|
profile = materialize_profile(envelope)
|
|
42
43
|
```
|
|
43
44
|
|
|
44
|
-
`username`
|
|
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`.
|
|
45
46
|
|
|
46
|
-
ADP room writes
|
|
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`.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# agent-protocols Python SDK
|
|
2
|
+
|
|
3
|
+
Python SDK for the draft Agent Identity, Agent Profile, Agent Delegation, and Agent Discourse protocols.
|
|
4
|
+
|
|
5
|
+
## Modules
|
|
6
|
+
|
|
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, delegation discovery hints, validation, materialization.
|
|
9
|
+
- `agent_protocols.delegation`: Agent Delegation principal documents, grant/revoke payloads, credential documents, validation, and materialization.
|
|
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.
|
|
11
|
+
- `agent_protocols.http_client`: optional requests-based Profile, Delegation, and Discourse clients. Install with `agent-protocols[http]`.
|
|
12
|
+
|
|
13
|
+
## Example
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from agent_protocols import AgentSigner, ClientNonceManager, materialize_profile, profile_update_event, unix_ms
|
|
17
|
+
|
|
18
|
+
signer = AgentSigner.generate()
|
|
19
|
+
nonces = ClientNonceManager()
|
|
20
|
+
event = profile_update_event(
|
|
21
|
+
signer.agent_id(),
|
|
22
|
+
unix_ms(),
|
|
23
|
+
nonces.next_nonce(),
|
|
24
|
+
{"id": signer.agent_id(), "name": "ResearchAgent-v3"},
|
|
25
|
+
)
|
|
26
|
+
envelope = signer.sign_event(event)
|
|
27
|
+
profile = materialize_profile(envelope)
|
|
28
|
+
```
|
|
29
|
+
|
|
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
|
+
|
|
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,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "agent-protocols"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols"
|
|
3
|
+
version = "0.5.0"
|
|
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" }
|
|
@@ -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,10 +32,33 @@ from .identity import (
|
|
|
31
32
|
with_room_head,
|
|
32
33
|
with_room_id,
|
|
33
34
|
)
|
|
34
|
-
from .profile import
|
|
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
|
+
)
|
|
43
|
+
from .delegation import (
|
|
44
|
+
DELEGATION_GRANT,
|
|
45
|
+
DELEGATION_PROTOCOL,
|
|
46
|
+
DELEGATION_REVOKE,
|
|
47
|
+
delegation_grant_event,
|
|
48
|
+
delegation_revoke_event,
|
|
49
|
+
materialize_delegation_credential,
|
|
50
|
+
validate_delegation_envelope,
|
|
51
|
+
validate_delegation_grant_payload,
|
|
52
|
+
validate_delegation_query_request,
|
|
53
|
+
validate_delegation_revoke_payload,
|
|
54
|
+
validate_principal_document,
|
|
55
|
+
)
|
|
35
56
|
|
|
36
57
|
__all__ = [
|
|
37
58
|
"AGENT_ID_PREFIX",
|
|
59
|
+
"DELEGATION_GRANT",
|
|
60
|
+
"DELEGATION_PROTOCOL",
|
|
61
|
+
"DELEGATION_REVOKE",
|
|
38
62
|
"MAX_NONCE_HEADER",
|
|
39
63
|
"PROFILE_PROTOCOL",
|
|
40
64
|
"PROFILE_UPDATE",
|
|
@@ -46,18 +70,28 @@ __all__ = [
|
|
|
46
70
|
"agent_id_from_public_key",
|
|
47
71
|
"canonical_event_bytes",
|
|
48
72
|
"create_event",
|
|
73
|
+
"delegation_grant_event",
|
|
74
|
+
"delegation_revoke_event",
|
|
49
75
|
"create_request_jwt_claims",
|
|
50
76
|
"event_hash",
|
|
51
77
|
"event_hash_bytes",
|
|
78
|
+
"latest_profile_update",
|
|
52
79
|
"materialize_profile",
|
|
80
|
+
"materialize_delegation_credential",
|
|
53
81
|
"profile_update_event",
|
|
54
82
|
"public_key_bytes",
|
|
83
|
+
"service_origin",
|
|
55
84
|
"sign_event",
|
|
56
85
|
"sign_event_hash",
|
|
57
86
|
"unix_ms",
|
|
58
87
|
"unix_secs",
|
|
59
88
|
"validate_agent_id",
|
|
89
|
+
"validate_delegation_envelope",
|
|
90
|
+
"validate_delegation_grant_payload",
|
|
91
|
+
"validate_delegation_query_request",
|
|
92
|
+
"validate_delegation_revoke_payload",
|
|
60
93
|
"validate_nonce",
|
|
94
|
+
"validate_principal_document",
|
|
61
95
|
"validate_profile_update",
|
|
62
96
|
"verify_envelope",
|
|
63
97
|
"verify_event_hash",
|
|
@@ -0,0 +1,174 @@
|
|
|
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")
|
|
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",
|
|
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")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def validate_delegation_revoke_payload(payload: DelegationRevokePayload) -> None:
|
|
101
|
+
_validate_non_empty(payload.get("id"), "payload.id")
|
|
102
|
+
_validate_https_url(payload.get("principal_id"), "principal_id")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def validate_delegation_envelope(envelope: Envelope) -> None:
|
|
106
|
+
verify_envelope(envelope)
|
|
107
|
+
event = envelope["event"]
|
|
108
|
+
if event["protocol"] != DELEGATION_PROTOCOL:
|
|
109
|
+
raise AgentProtocolError(
|
|
110
|
+
"invalid_event_protocol",
|
|
111
|
+
f"expected {DELEGATION_PROTOCOL}, got {event['protocol']}",
|
|
112
|
+
)
|
|
113
|
+
if event["type"] == DELEGATION_GRANT:
|
|
114
|
+
validate_delegation_grant_payload(event["payload"], event["created_at"])
|
|
115
|
+
elif event["type"] == DELEGATION_REVOKE:
|
|
116
|
+
validate_delegation_revoke_payload(event["payload"])
|
|
117
|
+
else:
|
|
118
|
+
raise AgentProtocolError(
|
|
119
|
+
"invalid_event_type",
|
|
120
|
+
f"expected {DELEGATION_GRANT} or {DELEGATION_REVOKE}, got {event['type']}",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def materialize_delegation_credential(
|
|
125
|
+
envelope: Envelope,
|
|
126
|
+
*,
|
|
127
|
+
status: str = "active",
|
|
128
|
+
status_url: str | None = None,
|
|
129
|
+
updated_at: int | None = None,
|
|
130
|
+
) -> DelegationCredential:
|
|
131
|
+
validate_delegation_envelope(envelope)
|
|
132
|
+
event = envelope["event"]
|
|
133
|
+
if event["type"] != DELEGATION_GRANT:
|
|
134
|
+
raise AgentProtocolError(
|
|
135
|
+
"invalid_event_type",
|
|
136
|
+
"delegation credential materialization requires a grant event",
|
|
137
|
+
)
|
|
138
|
+
payload = event["payload"]
|
|
139
|
+
credential = {
|
|
140
|
+
"id": payload["id"],
|
|
141
|
+
"protocol": DELEGATION_PROTOCOL,
|
|
142
|
+
"principal": payload["principal"],
|
|
143
|
+
"controller": event["actor"],
|
|
144
|
+
"subject": payload["subject"],
|
|
145
|
+
"relationship": payload.get("relationship"),
|
|
146
|
+
"scopes": payload["scopes"],
|
|
147
|
+
"constraints": payload.get("constraints"),
|
|
148
|
+
"not_before": payload.get("not_before"),
|
|
149
|
+
"expires_at": payload.get("expires_at"),
|
|
150
|
+
"status": status,
|
|
151
|
+
"status_url": status_url,
|
|
152
|
+
"updated_at": updated_at if updated_at is not None else event["created_at"],
|
|
153
|
+
"event_id": envelope["hash"],
|
|
154
|
+
}
|
|
155
|
+
return credential
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _validate_principal_descriptor(value: Any) -> None:
|
|
159
|
+
if not isinstance(value, dict):
|
|
160
|
+
raise AgentProtocolError("invalid_principal", "principal must be an object")
|
|
161
|
+
_validate_https_url(value.get("id"), "principal.id")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _validate_https_url(value: Any, field: str) -> None:
|
|
165
|
+
if not isinstance(value, str):
|
|
166
|
+
raise AgentProtocolError("invalid_url", f"{field} must be an HTTPS URL")
|
|
167
|
+
parsed = urlparse(value)
|
|
168
|
+
if parsed.scheme != "https" or not parsed.netloc:
|
|
169
|
+
raise AgentProtocolError("invalid_url", f"{field} must be an HTTPS URL")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _validate_non_empty(value: Any, field: str) -> None:
|
|
173
|
+
if not isinstance(value, str) or not value.strip():
|
|
174
|
+
raise AgentProtocolError("invalid_delegation", f"{field} must not be empty")
|
|
@@ -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
|
|
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
|
|
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
|
|
|
@@ -94,6 +147,7 @@ class PermissionContext(TypedDict, total=False):
|
|
|
94
147
|
|
|
95
148
|
role: Role
|
|
96
149
|
is_creator: bool
|
|
150
|
+
public_join_allowed: bool
|
|
97
151
|
join_request_approved: bool
|
|
98
152
|
|
|
99
153
|
|
|
@@ -170,6 +224,42 @@ def event_requires_room_id(event_type: str) -> bool:
|
|
|
170
224
|
return event_type != ROOM_CREATE
|
|
171
225
|
|
|
172
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
|
+
|
|
173
263
|
def validate_discourse_envelope(envelope: Envelope) -> None:
|
|
174
264
|
verify_envelope(envelope)
|
|
175
265
|
event = envelope["event"]
|
|
@@ -221,8 +311,17 @@ def _validate_mentions(mentions: Any) -> None:
|
|
|
221
311
|
return
|
|
222
312
|
if not isinstance(mentions, list):
|
|
223
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()
|
|
224
320
|
for mention in mentions:
|
|
225
321
|
validate_agent_id(mention)
|
|
322
|
+
if mention in seen:
|
|
323
|
+
raise AgentProtocolError("invalid_event", "mentions must be unique")
|
|
324
|
+
seen.add(mention)
|
|
226
325
|
|
|
227
326
|
|
|
228
327
|
def validate_custom_event_type_name(name: str) -> None:
|
|
@@ -333,6 +432,12 @@ class TypeRegistry:
|
|
|
333
432
|
|
|
334
433
|
def define(self, definition: dict[str, Any]) -> None:
|
|
335
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
|
+
)
|
|
336
441
|
self._types[definition["type"]] = definition
|
|
337
442
|
|
|
338
443
|
def _import(self, declaration: dict[str, Any], packs: dict[str, dict[str, Any]]) -> None:
|
|
@@ -438,6 +543,57 @@ def validate_message_create_payload(payload: dict[str, Any]) -> None:
|
|
|
438
543
|
raise AgentProtocolError("invalid_event", "content_type must not be empty")
|
|
439
544
|
|
|
440
545
|
|
|
546
|
+
def validate_room_join_payload(payload: dict[str, Any]) -> None:
|
|
547
|
+
if payload.get("role") not in ROLES:
|
|
548
|
+
raise AgentProtocolError("invalid_event", f"invalid room role: {payload.get('role')}")
|
|
549
|
+
request_id = payload.get("request_id")
|
|
550
|
+
if request_id is not None and not str(request_id).strip():
|
|
551
|
+
raise AgentProtocolError("invalid_event", "request_id must not be empty")
|
|
552
|
+
if request_id is not None and "perspective" in payload:
|
|
553
|
+
raise AgentProtocolError(
|
|
554
|
+
"invalid_event",
|
|
555
|
+
"room.join payload cannot include both request_id and perspective",
|
|
556
|
+
)
|
|
557
|
+
|
|
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
|
+
|
|
441
597
|
def server_record_hash_payload(
|
|
442
598
|
room_id: str,
|
|
443
599
|
seq: int,
|
|
@@ -537,10 +693,18 @@ def can_submit_event(
|
|
|
537
693
|
if event_type == ROOM_CREATE:
|
|
538
694
|
return True
|
|
539
695
|
if event_type == ROOM_JOIN:
|
|
540
|
-
return bool(context.get("join_request_approved"))
|
|
696
|
+
return bool(context.get("public_join_allowed") or context.get("join_request_approved"))
|
|
541
697
|
if event_type == ROOM_LEAVE:
|
|
542
698
|
return is_creator or role is not None
|
|
543
|
-
if event_type in {
|
|
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
|
+
}:
|
|
544
708
|
return is_creator or role == "moderator"
|
|
545
709
|
if event_type == MESSAGE_CREATE:
|
|
546
710
|
return is_creator or role in ("moderator", "speaker")
|
|
@@ -562,7 +726,9 @@ def can_write_in_state(event_type: str, state: RoomState) -> bool:
|
|
|
562
726
|
ROOM_JOIN,
|
|
563
727
|
ROOM_JOIN_REVIEW,
|
|
564
728
|
ROOM_MEMBER_ROLE_UPDATE,
|
|
729
|
+
ROOM_MEMBER_REMOVE,
|
|
565
730
|
ROOM_LEAVE,
|
|
731
|
+
ROOM_UPDATE,
|
|
566
732
|
TYPE_DEFINE,
|
|
567
733
|
ROOM_CANCEL,
|
|
568
734
|
}
|