agent-protocols 0.1.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agent_protocols-0.1.0 → agent_protocols-0.2.2}/PKG-INFO +8 -8
- {agent_protocols-0.1.0 → agent_protocols-0.2.2}/README.md +7 -6
- {agent_protocols-0.1.0 → agent_protocols-0.2.2}/pyproject.toml +2 -2
- {agent_protocols-0.1.0 → agent_protocols-0.2.2}/src/agent_protocols/__init__.py +14 -14
- agent_protocols-0.2.2/src/agent_protocols/discourse.py +319 -0
- agent_protocols-0.2.2/src/agent_protocols/http_client.py +130 -0
- {agent_protocols-0.1.0 → agent_protocols-0.2.2}/src/agent_protocols/identity.py +93 -73
- {agent_protocols-0.1.0 → agent_protocols-0.2.2}/src/agent_protocols/profile.py +9 -7
- {agent_protocols-0.1.0 → agent_protocols-0.2.2}/src/agent_protocols.egg-info/PKG-INFO +8 -8
- {agent_protocols-0.1.0 → agent_protocols-0.2.2}/src/agent_protocols.egg-info/requires.txt +0 -1
- agent_protocols-0.2.2/tests/test_discourse.py +154 -0
- {agent_protocols-0.1.0 → agent_protocols-0.2.2}/tests/test_identity.py +45 -22
- agent_protocols-0.2.2/tests/test_profile.py +47 -0
- agent_protocols-0.1.0/src/agent_protocols/discourse.py +0 -190
- agent_protocols-0.1.0/src/agent_protocols/http_client.py +0 -75
- agent_protocols-0.1.0/tests/test_discourse.py +0 -52
- agent_protocols-0.1.0/tests/test_profile.py +0 -30
- {agent_protocols-0.1.0 → agent_protocols-0.2.2}/setup.cfg +0 -0
- {agent_protocols-0.1.0 → agent_protocols-0.2.2}/src/agent_protocols/errors.py +0 -0
- {agent_protocols-0.1.0 → agent_protocols-0.2.2}/src/agent_protocols.egg-info/SOURCES.txt +0 -0
- {agent_protocols-0.1.0 → agent_protocols-0.2.2}/src/agent_protocols.egg-info/dependency_links.txt +0 -0
- {agent_protocols-0.1.0 → agent_protocols-0.2.2}/src/agent_protocols.egg-info/top_level.txt +0 -0
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-protocols
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
|
|
5
5
|
Author: LDCLabs
|
|
6
6
|
License: MIT
|
|
7
7
|
Keywords: agent,protocol,ed25519,sdk
|
|
8
8
|
Requires-Python: >=3.10
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
|
-
Requires-Dist: base58<3,>=2.1
|
|
11
10
|
Requires-Dist: cryptography>=42
|
|
12
11
|
Requires-Dist: rfc8785<0.2,>=0.1.4
|
|
13
12
|
Provides-Extra: http
|
|
@@ -19,22 +18,23 @@ Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse prot
|
|
|
19
18
|
|
|
20
19
|
## Modules
|
|
21
20
|
|
|
22
|
-
- `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event
|
|
21
|
+
- `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event hashes, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
|
|
23
22
|
- `agent_protocols.profile`: `profile.update` payload helpers, validation, materialization.
|
|
24
|
-
- `agent_protocols.discourse`: ADP event constants,
|
|
23
|
+
- `agent_protocols.discourse`: ADP event constants, join request helpers, room-path checks, permission and state helpers.
|
|
25
24
|
- `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
|
|
26
25
|
|
|
27
26
|
## Example
|
|
28
27
|
|
|
29
28
|
```python
|
|
30
|
-
from agent_protocols import AgentSigner, materialize_profile, profile_update_event,
|
|
29
|
+
from agent_protocols import AgentSigner, ClientNonceManager, materialize_profile, profile_update_event, unix_ms
|
|
31
30
|
|
|
32
31
|
signer = AgentSigner.generate()
|
|
32
|
+
nonces = ClientNonceManager()
|
|
33
33
|
event = profile_update_event(
|
|
34
34
|
signer.agent_id(),
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
{"
|
|
35
|
+
unix_ms(),
|
|
36
|
+
nonces.next_nonce(),
|
|
37
|
+
{"id": signer.agent_id(), "name": "ResearchAgent-v3"},
|
|
38
38
|
)
|
|
39
39
|
envelope = signer.sign_event(event)
|
|
40
40
|
profile = materialize_profile(envelope)
|
|
@@ -4,22 +4,23 @@ Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse prot
|
|
|
4
4
|
|
|
5
5
|
## Modules
|
|
6
6
|
|
|
7
|
-
- `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event
|
|
7
|
+
- `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event hashes, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
|
|
8
8
|
- `agent_protocols.profile`: `profile.update` payload helpers, validation, materialization.
|
|
9
|
-
- `agent_protocols.discourse`: ADP event constants,
|
|
9
|
+
- `agent_protocols.discourse`: ADP event constants, join request helpers, room-path checks, permission and state helpers.
|
|
10
10
|
- `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
|
|
11
11
|
|
|
12
12
|
## Example
|
|
13
13
|
|
|
14
14
|
```python
|
|
15
|
-
from agent_protocols import AgentSigner, materialize_profile, profile_update_event,
|
|
15
|
+
from agent_protocols import AgentSigner, ClientNonceManager, materialize_profile, profile_update_event, unix_ms
|
|
16
16
|
|
|
17
17
|
signer = AgentSigner.generate()
|
|
18
|
+
nonces = ClientNonceManager()
|
|
18
19
|
event = profile_update_event(
|
|
19
20
|
signer.agent_id(),
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
{"
|
|
21
|
+
unix_ms(),
|
|
22
|
+
nonces.next_nonce(),
|
|
23
|
+
{"id": signer.agent_id(), "name": "ResearchAgent-v3"},
|
|
23
24
|
)
|
|
24
25
|
envelope = signer.sign_event(event)
|
|
25
26
|
profile = materialize_profile(envelope)
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "agent-protocols"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.2"
|
|
4
4
|
description = "Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
7
7
|
license = { text = "MIT" }
|
|
8
8
|
authors = [{ name = "LDCLabs" }]
|
|
9
9
|
keywords = ["agent", "protocol", "ed25519", "sdk"]
|
|
10
|
-
dependencies = ["
|
|
10
|
+
dependencies = ["cryptography>=42", "rfc8785>=0.1.4,<0.2"]
|
|
11
11
|
|
|
12
12
|
[project.optional-dependencies]
|
|
13
13
|
http = ["requests>=2.31,<3"]
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
from .errors import AgentProtocolError
|
|
2
2
|
from .identity import (
|
|
3
3
|
AGENT_ID_PREFIX,
|
|
4
|
-
EVENT_ID_PREFIX,
|
|
5
4
|
AgentSigner,
|
|
5
|
+
ClientNonceManager,
|
|
6
|
+
MAX_NONCE_HEADER,
|
|
6
7
|
MemoryNonceStore,
|
|
7
8
|
RequestBinding,
|
|
8
9
|
agent_id_from_public_key,
|
|
9
10
|
canonical_event_bytes,
|
|
10
11
|
create_event,
|
|
11
12
|
create_request_jwt_claims,
|
|
12
|
-
|
|
13
|
+
event_hash,
|
|
13
14
|
public_key_bytes,
|
|
14
|
-
random_nonce,
|
|
15
15
|
sign_event,
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
unix_ms,
|
|
17
|
+
unix_secs,
|
|
18
18
|
validate_agent_id,
|
|
19
|
+
validate_nonce,
|
|
19
20
|
verify_envelope,
|
|
20
|
-
|
|
21
|
+
verify_event_hash,
|
|
21
22
|
verify_live_envelope,
|
|
22
23
|
verify_request_jwt,
|
|
23
|
-
verify_request_jwt_live,
|
|
24
24
|
verify_signature,
|
|
25
25
|
verify_timestamp,
|
|
26
26
|
with_room_id,
|
|
@@ -29,32 +29,32 @@ from .profile import PROFILE_PROTOCOL, PROFILE_UPDATE, materialize_profile, prof
|
|
|
29
29
|
|
|
30
30
|
__all__ = [
|
|
31
31
|
"AGENT_ID_PREFIX",
|
|
32
|
-
"
|
|
32
|
+
"MAX_NONCE_HEADER",
|
|
33
33
|
"PROFILE_PROTOCOL",
|
|
34
34
|
"PROFILE_UPDATE",
|
|
35
35
|
"AgentProtocolError",
|
|
36
36
|
"AgentSigner",
|
|
37
|
+
"ClientNonceManager",
|
|
37
38
|
"MemoryNonceStore",
|
|
38
39
|
"RequestBinding",
|
|
39
40
|
"agent_id_from_public_key",
|
|
40
41
|
"canonical_event_bytes",
|
|
41
42
|
"create_event",
|
|
42
43
|
"create_request_jwt_claims",
|
|
43
|
-
"
|
|
44
|
+
"event_hash",
|
|
44
45
|
"materialize_profile",
|
|
45
46
|
"profile_update_event",
|
|
46
47
|
"public_key_bytes",
|
|
47
|
-
"random_nonce",
|
|
48
48
|
"sign_event",
|
|
49
|
-
"
|
|
50
|
-
"
|
|
49
|
+
"unix_ms",
|
|
50
|
+
"unix_secs",
|
|
51
51
|
"validate_agent_id",
|
|
52
|
+
"validate_nonce",
|
|
52
53
|
"validate_profile_update",
|
|
53
54
|
"verify_envelope",
|
|
54
|
-
"
|
|
55
|
+
"verify_event_hash",
|
|
55
56
|
"verify_live_envelope",
|
|
56
57
|
"verify_request_jwt",
|
|
57
|
-
"verify_request_jwt_live",
|
|
58
58
|
"verify_signature",
|
|
59
59
|
"verify_timestamp",
|
|
60
60
|
"with_room_id",
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
from typing import Any, Literal, TypedDict
|
|
6
|
+
|
|
7
|
+
import rfc8785
|
|
8
|
+
|
|
9
|
+
from .errors import AgentProtocolError
|
|
10
|
+
from .identity import AgentId, Envelope, Event, create_event, verify_envelope, with_room_id
|
|
11
|
+
|
|
12
|
+
DISCOURSE_PROTOCOL = "agent-discourse/1.0"
|
|
13
|
+
|
|
14
|
+
ROOM_CREATE = "room.create"
|
|
15
|
+
ROOM_JOIN = "room.join"
|
|
16
|
+
ROOM_JOIN_REVIEW = "room.join.review"
|
|
17
|
+
ROOM_LEAVE = "room.leave"
|
|
18
|
+
ROOM_MEMBER_ROLE_UPDATE = "room.member.role.update"
|
|
19
|
+
ROOM_CLOSE = "room.close"
|
|
20
|
+
ROOM_CANCEL = "room.cancel"
|
|
21
|
+
MESSAGE_CREATE = "message.create"
|
|
22
|
+
REACTION_CREATE = "reaction.create"
|
|
23
|
+
MESSAGE_PROPOSAL_CREATE = "message.proposal.create"
|
|
24
|
+
MESSAGE_POLL_CREATE = "message.poll.create"
|
|
25
|
+
MESSAGE_POLL_VOTE = "message.poll.vote"
|
|
26
|
+
MESSAGE_RESOLUTION_CREATE = "message.resolution.create"
|
|
27
|
+
SOURCE_ADD = "source.add"
|
|
28
|
+
TURN_UPDATE = "turn.update"
|
|
29
|
+
QUESTION_CREATE = "question.create"
|
|
30
|
+
ROOM_STEER = "room.steer"
|
|
31
|
+
MAP_UPDATE = "map.update"
|
|
32
|
+
ARTIFACT_CREATE = "artifact.create"
|
|
33
|
+
|
|
34
|
+
KNOWN_EVENT_TYPES = {
|
|
35
|
+
ROOM_CREATE,
|
|
36
|
+
ROOM_JOIN,
|
|
37
|
+
ROOM_JOIN_REVIEW,
|
|
38
|
+
ROOM_LEAVE,
|
|
39
|
+
ROOM_MEMBER_ROLE_UPDATE,
|
|
40
|
+
ROOM_CLOSE,
|
|
41
|
+
ROOM_CANCEL,
|
|
42
|
+
MESSAGE_CREATE,
|
|
43
|
+
REACTION_CREATE,
|
|
44
|
+
MESSAGE_PROPOSAL_CREATE,
|
|
45
|
+
MESSAGE_POLL_CREATE,
|
|
46
|
+
MESSAGE_POLL_VOTE,
|
|
47
|
+
MESSAGE_RESOLUTION_CREATE,
|
|
48
|
+
SOURCE_ADD,
|
|
49
|
+
TURN_UPDATE,
|
|
50
|
+
QUESTION_CREATE,
|
|
51
|
+
ROOM_STEER,
|
|
52
|
+
MAP_UPDATE,
|
|
53
|
+
ARTIFACT_CREATE,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
RoomState = Literal["scheduled", "active", "ended", "cancelled"]
|
|
57
|
+
Role = Literal["moderator", "expert", "participant", "observer"]
|
|
58
|
+
JoinRequestStatus = Literal["pending", "approved", "rejected", "expired"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PermissionContext(TypedDict, total=False):
|
|
62
|
+
role: Role
|
|
63
|
+
is_creator: bool
|
|
64
|
+
join_request_approved: bool
|
|
65
|
+
moderator_authorized: bool
|
|
66
|
+
expert_policy_allowed: bool
|
|
67
|
+
participant_policy_allowed: bool
|
|
68
|
+
observer_steering_allowed: bool
|
|
69
|
+
observer_poll_vote_allowed: bool
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def room_create_event(actor: AgentId, created_at: int, nonce: int, payload: dict[str, Any]) -> Event:
|
|
73
|
+
return create_event(DISCOURSE_PROTOCOL, ROOM_CREATE, actor, created_at, nonce, payload)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def discourse_event(event_type: str, actor: AgentId, created_at: int, nonce: int, room_id: str, payload: Any) -> Event:
|
|
77
|
+
return with_room_id(create_event(DISCOURSE_PROTOCOL, event_type, actor, created_at, nonce, payload), room_id)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def validate_discourse_envelope(envelope: Envelope) -> None:
|
|
81
|
+
verify_envelope(envelope)
|
|
82
|
+
event = envelope["event"]
|
|
83
|
+
protocol = event["protocol"]
|
|
84
|
+
if protocol != DISCOURSE_PROTOCOL:
|
|
85
|
+
raise AgentProtocolError("invalid_event_protocol", f"expected {DISCOURSE_PROTOCOL}, got {protocol}")
|
|
86
|
+
if event_requires_room_id(event["type"]) and "room_id" not in event:
|
|
87
|
+
raise AgentProtocolError("missing_room_id", "event requires a room_id")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def validate_room_path(envelope: Envelope, path_room_id: str) -> None:
|
|
91
|
+
actual = envelope["event"].get("room_id")
|
|
92
|
+
if actual is None and envelope["event"]["type"] == ROOM_CREATE:
|
|
93
|
+
return
|
|
94
|
+
if actual is None:
|
|
95
|
+
raise AgentProtocolError("missing_room_id", "event requires a room_id")
|
|
96
|
+
if actual != path_room_id:
|
|
97
|
+
raise AgentProtocolError("room_id_mismatch", f"expected {path_room_id}, got {actual}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def event_requires_room_id(event_type: str) -> bool:
|
|
101
|
+
return event_type != ROOM_CREATE
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def validate_room_create_payload(payload: dict[str, Any]) -> None:
|
|
105
|
+
if not str(payload.get("topic", "")).strip():
|
|
106
|
+
raise AgentProtocolError("invalid_room", "room topic must not be empty")
|
|
107
|
+
if payload.get("start_time", 0) >= payload.get("end_time", 0):
|
|
108
|
+
raise AgentProtocolError("invalid_room", "start_time must be before end_time")
|
|
109
|
+
policy = payload.get("policy") or {}
|
|
110
|
+
max_participants = policy.get("max_participants")
|
|
111
|
+
if max_participants is not None and (
|
|
112
|
+
not isinstance(max_participants, int) or max_participants < 1
|
|
113
|
+
):
|
|
114
|
+
raise AgentProtocolError("invalid_room", "max_participants must be a positive integer")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def validate_poll_create_payload(payload: dict[str, Any]) -> None:
|
|
118
|
+
if not str(payload.get("poll_id", "")).strip() or not str(payload.get("question", "")).strip():
|
|
119
|
+
raise AgentProtocolError("invalid_poll", "poll_id and question are required")
|
|
120
|
+
options = payload.get("options", [])
|
|
121
|
+
if len(options) < 2:
|
|
122
|
+
raise AgentProtocolError("invalid_poll", "poll requires at least two options")
|
|
123
|
+
option_ids: set[str] = set()
|
|
124
|
+
for option in options:
|
|
125
|
+
option_id = str(option.get("id", ""))
|
|
126
|
+
label = str(option.get("label", ""))
|
|
127
|
+
if not option_id.strip() or not label.strip():
|
|
128
|
+
raise AgentProtocolError("invalid_poll", "option id and label are required")
|
|
129
|
+
if option_id in option_ids:
|
|
130
|
+
raise AgentProtocolError("invalid_poll", "poll option ids must be unique")
|
|
131
|
+
option_ids.add(option_id)
|
|
132
|
+
min_choices = payload.get("min_choices", 1)
|
|
133
|
+
max_choices = payload.get("max_choices", 1)
|
|
134
|
+
if min_choices < 1 or max_choices < min_choices:
|
|
135
|
+
raise AgentProtocolError("invalid_poll", "invalid poll choice limits")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def validate_poll_vote_payload(payload: dict[str, Any], poll: dict[str, Any], now_ms: int | None = None) -> None:
|
|
139
|
+
if poll.get("closes_at") is not None and now_ms is not None and now_ms > poll["closes_at"]:
|
|
140
|
+
raise AgentProtocolError("poll_closed", "poll is closed")
|
|
141
|
+
min_choices = poll.get("min_choices", 1)
|
|
142
|
+
max_choices = poll.get("max_choices", 1)
|
|
143
|
+
option_ids = {option["id"] for option in poll.get("options", [])}
|
|
144
|
+
selected = payload.get("option_ids", [])
|
|
145
|
+
selected_set = set(selected)
|
|
146
|
+
if len(selected_set) != len(selected):
|
|
147
|
+
raise AgentProtocolError("invalid_poll_vote", "duplicate poll options")
|
|
148
|
+
if len(selected_set) < min_choices or len(selected_set) > max_choices:
|
|
149
|
+
raise AgentProtocolError("invalid_poll_vote", "invalid number of options")
|
|
150
|
+
if any(option_id not in option_ids for option_id in selected_set):
|
|
151
|
+
raise AgentProtocolError("invalid_poll_vote", "unknown poll option")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def server_record_hash_payload(
|
|
155
|
+
room_id: str,
|
|
156
|
+
seq: int,
|
|
157
|
+
pre_hash: str | None,
|
|
158
|
+
envelope_hash: str,
|
|
159
|
+
received_at: int,
|
|
160
|
+
) -> dict[str, Any]:
|
|
161
|
+
return {
|
|
162
|
+
"room_id": room_id,
|
|
163
|
+
"seq": seq,
|
|
164
|
+
"pre_hash": pre_hash,
|
|
165
|
+
"envelope_hash": envelope_hash,
|
|
166
|
+
"received_at": received_at,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def server_record_hash(
|
|
171
|
+
room_id: str,
|
|
172
|
+
seq: int,
|
|
173
|
+
pre_hash: str | None,
|
|
174
|
+
envelope_hash: str,
|
|
175
|
+
received_at: int,
|
|
176
|
+
) -> str:
|
|
177
|
+
return _hash_canonical_json(server_record_hash_payload(room_id, seq, pre_hash, envelope_hash, received_at))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def build_server_record(
|
|
181
|
+
room_id: str,
|
|
182
|
+
seq: int,
|
|
183
|
+
pre_hash: str | None,
|
|
184
|
+
received_at: int,
|
|
185
|
+
envelope: Envelope,
|
|
186
|
+
) -> dict[str, Any]:
|
|
187
|
+
return {
|
|
188
|
+
"room_id": room_id,
|
|
189
|
+
"seq": seq,
|
|
190
|
+
"pre_hash": pre_hash,
|
|
191
|
+
"hash": server_record_hash(room_id, seq, pre_hash, envelope["hash"], received_at),
|
|
192
|
+
"received_at": received_at,
|
|
193
|
+
"envelope": envelope,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def verify_server_record(record: dict[str, Any]) -> None:
|
|
198
|
+
expected = server_record_hash(
|
|
199
|
+
record["room_id"],
|
|
200
|
+
record["seq"],
|
|
201
|
+
record.get("pre_hash"),
|
|
202
|
+
record["envelope"]["hash"],
|
|
203
|
+
record["received_at"],
|
|
204
|
+
)
|
|
205
|
+
if record["hash"] != expected:
|
|
206
|
+
raise AgentProtocolError("invalid_record_hash", f"invalid server record hash: expected {expected}, got {record['hash']}")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def verify_server_record_chain(records: list[dict[str, Any]]) -> None:
|
|
210
|
+
previous: dict[str, Any] | None = None
|
|
211
|
+
for record in records:
|
|
212
|
+
verify_server_record(record)
|
|
213
|
+
if previous is None:
|
|
214
|
+
if record["seq"] != 1:
|
|
215
|
+
raise AgentProtocolError("invalid_record_chain", "first seq must be 1")
|
|
216
|
+
if record.get("pre_hash") is not None:
|
|
217
|
+
raise AgentProtocolError("invalid_record_chain", "first pre_hash must be null")
|
|
218
|
+
else:
|
|
219
|
+
if record["seq"] != previous["seq"] + 1:
|
|
220
|
+
raise AgentProtocolError("invalid_record_chain", "seq must increase by 1")
|
|
221
|
+
if record.get("pre_hash") != previous["hash"]:
|
|
222
|
+
raise AgentProtocolError("invalid_record_chain", "pre_hash mismatch")
|
|
223
|
+
previous = record
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def archive_events_digest(records: list[dict[str, Any]]) -> str:
|
|
227
|
+
return _hash_canonical_json(records)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def can_submit_event(event_type: str, context: PermissionContext) -> bool:
|
|
231
|
+
if event_type == ROOM_CREATE:
|
|
232
|
+
return True
|
|
233
|
+
if event_type == ROOM_JOIN:
|
|
234
|
+
return context.get("join_request_approved", False)
|
|
235
|
+
if context.get("is_creator"):
|
|
236
|
+
return event_type in KNOWN_EVENT_TYPES
|
|
237
|
+
|
|
238
|
+
role = context.get("role")
|
|
239
|
+
if role == "moderator":
|
|
240
|
+
return _moderator_can_submit(event_type, context.get("moderator_authorized", False))
|
|
241
|
+
if role == "expert":
|
|
242
|
+
return _speaker_can_submit(event_type, context.get("expert_policy_allowed", False))
|
|
243
|
+
if role == "participant":
|
|
244
|
+
return _speaker_can_submit(event_type, context.get("participant_policy_allowed", False))
|
|
245
|
+
if role == "observer":
|
|
246
|
+
return _observer_can_submit(event_type, context)
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def can_write_in_state(event_type: str, state: RoomState, *, post_end_reaction_allowed: bool = False) -> bool:
|
|
251
|
+
if state == "scheduled":
|
|
252
|
+
return event_type in {ROOM_JOIN, ROOM_JOIN_REVIEW, ROOM_CANCEL}
|
|
253
|
+
if state == "active":
|
|
254
|
+
return event_type not in {ROOM_CREATE, ROOM_CANCEL}
|
|
255
|
+
if state == "ended":
|
|
256
|
+
return post_end_reaction_allowed and event_type == REACTION_CREATE
|
|
257
|
+
if state == "cancelled":
|
|
258
|
+
return False
|
|
259
|
+
return False
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def can_accept_room_write(event_type: str, state: RoomState, context: PermissionContext, *, post_end_reaction_allowed: bool = False) -> bool:
|
|
263
|
+
return can_submit_event(event_type, context) and can_write_in_state(event_type, state, post_end_reaction_allowed=post_end_reaction_allowed)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def validate_room_write(event_type: str, state: RoomState, context: PermissionContext, *, post_end_reaction_allowed: bool = False) -> None:
|
|
267
|
+
if not can_accept_room_write(event_type, state, context, post_end_reaction_allowed=post_end_reaction_allowed):
|
|
268
|
+
raise AgentProtocolError("permission_denied", "actor lacks permission or state is not writable")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _moderator_can_submit(event_type: str, moderator_authorized: bool) -> bool:
|
|
272
|
+
allowed = {
|
|
273
|
+
ROOM_JOIN_REVIEW,
|
|
274
|
+
ROOM_CLOSE,
|
|
275
|
+
MESSAGE_CREATE,
|
|
276
|
+
SOURCE_ADD,
|
|
277
|
+
TURN_UPDATE,
|
|
278
|
+
QUESTION_CREATE,
|
|
279
|
+
ROOM_STEER,
|
|
280
|
+
MAP_UPDATE,
|
|
281
|
+
ARTIFACT_CREATE,
|
|
282
|
+
MESSAGE_PROPOSAL_CREATE,
|
|
283
|
+
MESSAGE_POLL_CREATE,
|
|
284
|
+
MESSAGE_POLL_VOTE,
|
|
285
|
+
MESSAGE_RESOLUTION_CREATE,
|
|
286
|
+
REACTION_CREATE,
|
|
287
|
+
ROOM_LEAVE,
|
|
288
|
+
}
|
|
289
|
+
return event_type in allowed or (moderator_authorized and event_type in {ROOM_MEMBER_ROLE_UPDATE, ROOM_CANCEL})
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _speaker_can_submit(event_type: str, policy_allowed: bool) -> bool:
|
|
293
|
+
allowed = {
|
|
294
|
+
MESSAGE_CREATE,
|
|
295
|
+
SOURCE_ADD,
|
|
296
|
+
ROOM_STEER,
|
|
297
|
+
MESSAGE_PROPOSAL_CREATE,
|
|
298
|
+
MESSAGE_POLL_CREATE,
|
|
299
|
+
MESSAGE_POLL_VOTE,
|
|
300
|
+
REACTION_CREATE,
|
|
301
|
+
ROOM_LEAVE,
|
|
302
|
+
}
|
|
303
|
+
policy_events = {QUESTION_CREATE, MAP_UPDATE, ARTIFACT_CREATE, MESSAGE_RESOLUTION_CREATE}
|
|
304
|
+
return event_type in allowed or (policy_allowed and event_type in policy_events)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _observer_can_submit(event_type: str, context: PermissionContext) -> bool:
|
|
308
|
+
return (
|
|
309
|
+
event_type in {REACTION_CREATE, ROOM_LEAVE}
|
|
310
|
+
or (context.get("observer_steering_allowed", False) and event_type == ROOM_STEER)
|
|
311
|
+
or (context.get("observer_poll_vote_allowed", False) and event_type == MESSAGE_POLL_VOTE)
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _hash_canonical_json(value: Any) -> str:
|
|
316
|
+
canonical = rfc8785.dumps(value)
|
|
317
|
+
data = canonical if isinstance(canonical, bytes) else canonical.encode()
|
|
318
|
+
digest = hashlib.sha3_256(data).digest()
|
|
319
|
+
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.parse import quote, urlencode
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import requests
|
|
8
|
+
except ImportError: # pragma: no cover
|
|
9
|
+
requests = None # type: ignore[assignment]
|
|
10
|
+
|
|
11
|
+
from .identity import AgentId, Envelope
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ProfileClient:
|
|
15
|
+
def __init__(self, base_url: str, session: Any | None = None):
|
|
16
|
+
self.base_url = base_url.rstrip("/")
|
|
17
|
+
self.session = session or _requests_session()
|
|
18
|
+
|
|
19
|
+
def get_profile(self, agent_id: AgentId) -> dict[str, Any]:
|
|
20
|
+
return self._get(f"/v1/profiles/{agent_id}")
|
|
21
|
+
|
|
22
|
+
def get_profiles(self, agent_ids: list[AgentId]) -> dict[str, Any]:
|
|
23
|
+
return self._post("/v1/profiles/batch", {"ids": agent_ids})
|
|
24
|
+
|
|
25
|
+
def profile_events(self, agent_id: AgentId, limit: int = 1) -> dict[str, Any]:
|
|
26
|
+
return self._get(f"/v1/profiles/{agent_id}/events?limit={limit}")
|
|
27
|
+
|
|
28
|
+
def submit_profile_update(self, envelope: Envelope) -> dict[str, Any]:
|
|
29
|
+
return self._post("/v1/profiles", envelope)
|
|
30
|
+
|
|
31
|
+
def _get(self, path: str) -> Any:
|
|
32
|
+
response = self.session.get(self.base_url + path)
|
|
33
|
+
response.raise_for_status()
|
|
34
|
+
return response.json()
|
|
35
|
+
|
|
36
|
+
def _post(self, path: str, body: Any) -> dict[str, Any]:
|
|
37
|
+
response = self.session.post(self.base_url + path, json=body)
|
|
38
|
+
response.raise_for_status()
|
|
39
|
+
return response.json()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DiscourseClient:
|
|
43
|
+
def __init__(self, base_url: str, session: Any | None = None):
|
|
44
|
+
self.base_url = base_url.rstrip("/")
|
|
45
|
+
self.session = session or _requests_session()
|
|
46
|
+
|
|
47
|
+
def protocol(self) -> dict[str, Any]:
|
|
48
|
+
return self._get("/.well-known/agent-discourse")
|
|
49
|
+
|
|
50
|
+
def create_room(self, envelope: Envelope) -> dict[str, Any]:
|
|
51
|
+
return self._post("/v1/rooms", envelope)
|
|
52
|
+
|
|
53
|
+
def room(self, room_id: str) -> dict[str, Any]:
|
|
54
|
+
return self._get(f"/v1/rooms/{room_id}")
|
|
55
|
+
|
|
56
|
+
def request_join(self, room_id: str, jwt: str, request: dict[str, Any]) -> dict[str, Any]:
|
|
57
|
+
return self._post(f"/v1/rooms/{room_id}/join-requests", request, jwt=jwt)
|
|
58
|
+
|
|
59
|
+
def join_request(self, room_id: str, request_id: str, jwt: str) -> dict[str, Any]:
|
|
60
|
+
return self._get(f"/v1/rooms/{room_id}/join-requests/{request_id}", jwt=jwt)
|
|
61
|
+
|
|
62
|
+
def join_requests(self, room_id: str, jwt: str) -> list[dict[str, Any]]:
|
|
63
|
+
return self._get(f"/v1/rooms/{room_id}/join-requests", jwt=jwt)
|
|
64
|
+
|
|
65
|
+
def join_room(self, room_id: str, envelope: Envelope) -> dict[str, Any]:
|
|
66
|
+
return self._post(f"/v1/rooms/{room_id}", envelope)
|
|
67
|
+
|
|
68
|
+
def leave_room(self, room_id: str, envelope: Envelope) -> dict[str, Any]:
|
|
69
|
+
return self._post(f"/v1/rooms/{room_id}", envelope)
|
|
70
|
+
|
|
71
|
+
def submit_event(self, room_id: str, envelope: Envelope) -> dict[str, Any]:
|
|
72
|
+
return self._post(f"/v1/rooms/{room_id}", envelope)
|
|
73
|
+
|
|
74
|
+
def events(
|
|
75
|
+
self,
|
|
76
|
+
room_id: str,
|
|
77
|
+
*,
|
|
78
|
+
after_seq: int | None = None,
|
|
79
|
+
limit: int | None = None,
|
|
80
|
+
cursor: str | None = None,
|
|
81
|
+
jwt: str | None = None,
|
|
82
|
+
) -> list[dict[str, Any]]:
|
|
83
|
+
query = urlencode(
|
|
84
|
+
{
|
|
85
|
+
key: value
|
|
86
|
+
for key, value in {
|
|
87
|
+
"after_seq": after_seq,
|
|
88
|
+
"limit": limit,
|
|
89
|
+
"cursor": cursor,
|
|
90
|
+
}.items()
|
|
91
|
+
if value is not None
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
suffix = f"?{query}" if query else ""
|
|
95
|
+
return self._get(f"/v1/rooms/{room_id}/events{suffix}", jwt=jwt)
|
|
96
|
+
|
|
97
|
+
def websocket_events_url(self, room_id: str, jwt: str) -> str:
|
|
98
|
+
return websocket_events_url(self.base_url, room_id, jwt)
|
|
99
|
+
|
|
100
|
+
def archive(self, room_id: str) -> dict[str, Any]:
|
|
101
|
+
return self._get(f"/v1/rooms/{room_id}/archive")
|
|
102
|
+
|
|
103
|
+
def _get(self, path: str, jwt: str | None = None) -> Any:
|
|
104
|
+
response = self.session.get(self.base_url + path, headers=_auth_headers(jwt))
|
|
105
|
+
response.raise_for_status()
|
|
106
|
+
return response.json()
|
|
107
|
+
|
|
108
|
+
def _post(self, path: str, body: Any, jwt: str | None = None) -> Any:
|
|
109
|
+
response = self.session.post(self.base_url + path, json=body, headers=_auth_headers(jwt))
|
|
110
|
+
response.raise_for_status()
|
|
111
|
+
return response.json()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def websocket_events_url(base_url: str, room_id: str, jwt: str) -> str:
|
|
115
|
+
websocket_base = base_url.rstrip("/")
|
|
116
|
+
if websocket_base.startswith("https://"):
|
|
117
|
+
websocket_base = "wss://" + websocket_base[len("https://") :]
|
|
118
|
+
elif websocket_base.startswith("http://"):
|
|
119
|
+
websocket_base = "ws://" + websocket_base[len("http://") :]
|
|
120
|
+
return f"{websocket_base}/v1/rooms/{quote(room_id, safe='')}/events/live?access_token={quote(jwt, safe='')}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _auth_headers(jwt: str | None) -> dict[str, str] | None:
|
|
124
|
+
return {"Authorization": f"Bearer {jwt}"} if jwt else None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _requests_session() -> Any:
|
|
128
|
+
if requests is None:
|
|
129
|
+
raise RuntimeError("Install agent-protocols[http] to use HTTP clients")
|
|
130
|
+
return requests.Session()
|