uagents-core 0.1.3__py3-none-any.whl → 0.2.0__py3-none-any.whl
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.
- uagents_core/config.py +12 -1
- uagents_core/contrib/protocols/chat/__init__.py +124 -0
- uagents_core/contrib/protocols/subscriptions/__init__.py +75 -0
- uagents_core/envelope.py +19 -71
- uagents_core/helpers.py +30 -0
- uagents_core/{crypto.py → identity.py} +57 -7
- uagents_core/logger.py +32 -33
- uagents_core/models.py +9 -5
- uagents_core/protocol.py +166 -0
- uagents_core/registration.py +50 -19
- uagents_core/types.py +38 -2
- uagents_core/utils/__init__.py +5 -0
- uagents_core/utils/messages.py +153 -0
- uagents_core/utils/registration.py +148 -100
- uagents_core/utils/resolver.py +73 -0
- {uagents_core-0.1.3.dist-info → uagents_core-0.2.0.dist-info}/METADATA +10 -10
- uagents_core-0.2.0.dist-info/RECORD +19 -0
- uagents_core/communication.py +0 -76
- uagents_core/utils/communication.py +0 -130
- uagents_core-0.1.3.dist-info/RECORD +0 -15
- {uagents_core-0.1.3.dist-info → uagents_core-0.2.0.dist-info}/WHEEL +0 -0
uagents_core/protocol.py
ADDED
@@ -0,0 +1,166 @@
|
|
1
|
+
import copy
|
2
|
+
import hashlib
|
3
|
+
import json
|
4
|
+
from typing import Any, ClassVar
|
5
|
+
|
6
|
+
from pydantic import BaseModel, ValidationInfo, field_validator
|
7
|
+
|
8
|
+
from uagents_core.models import Model
|
9
|
+
|
10
|
+
|
11
|
+
class ProtocolSpecification(BaseModel):
|
12
|
+
"""
|
13
|
+
Specification for the interactions and roles of a protocol.
|
14
|
+
|
15
|
+
Args:
|
16
|
+
name (str): The name of the protocol.
|
17
|
+
version (str): The version of the protocol.
|
18
|
+
interactions (dict[type[Model], set[type[Model]]]): A mapping of models
|
19
|
+
to the corresponding set of models that are valid replies.
|
20
|
+
roles (dict[str, set[type[Model]]] | None): A mapping of role names to the set of
|
21
|
+
incoming message models that role needs to be implement handlers for.
|
22
|
+
If None, all models can be implemented by all roles.
|
23
|
+
"""
|
24
|
+
|
25
|
+
name: str = ""
|
26
|
+
version: str = "0.1.0"
|
27
|
+
interactions: dict[type[Model], set[type[Model]]]
|
28
|
+
roles: dict[str, set[type[Model]]] | None = None
|
29
|
+
|
30
|
+
SPEC_VERSION: ClassVar = "1.0"
|
31
|
+
|
32
|
+
@field_validator("roles")
|
33
|
+
@classmethod
|
34
|
+
def validate_roles(cls, roles, info: ValidationInfo):
|
35
|
+
"""
|
36
|
+
Ensure that all models included in roles are also included in the interactions.
|
37
|
+
"""
|
38
|
+
if roles is None:
|
39
|
+
return roles
|
40
|
+
|
41
|
+
interactions: dict[type[Model], set[type[Model]]] = info.data["interactions"]
|
42
|
+
interaction_models = set(interactions.keys())
|
43
|
+
|
44
|
+
for role, models in roles.items():
|
45
|
+
invalid_models = models - interaction_models
|
46
|
+
if invalid_models:
|
47
|
+
model_names = [model.__name__ for model in invalid_models]
|
48
|
+
raise ValueError(
|
49
|
+
f"Role '{role}' contains models that don't "
|
50
|
+
f"exist in interactions: {model_names}"
|
51
|
+
)
|
52
|
+
return roles
|
53
|
+
|
54
|
+
@property
|
55
|
+
def digest(self) -> str:
|
56
|
+
"""
|
57
|
+
Property to access the digest of the protocol's manifest.
|
58
|
+
|
59
|
+
Returns:
|
60
|
+
str: The digest of the protocol's manifest.
|
61
|
+
"""
|
62
|
+
return self.manifest()["metadata"]["digest"]
|
63
|
+
|
64
|
+
def manifest(self, role: str | None = None) -> dict[str, Any]:
|
65
|
+
"""
|
66
|
+
Generate the protocol's manifest, a long-form machine readable description of the
|
67
|
+
protocol details and interface.
|
68
|
+
|
69
|
+
Returns:
|
70
|
+
dict[str, Any]: The protocol's manifest.
|
71
|
+
"""
|
72
|
+
metadata = {
|
73
|
+
"name": self.name,
|
74
|
+
"version": self.version,
|
75
|
+
}
|
76
|
+
|
77
|
+
manifest = {
|
78
|
+
"version": "1.0",
|
79
|
+
"metadata": {},
|
80
|
+
"models": [],
|
81
|
+
"interactions": [],
|
82
|
+
}
|
83
|
+
|
84
|
+
if self.roles and role is not None:
|
85
|
+
interactions = {
|
86
|
+
model: replies
|
87
|
+
for model, replies in self.interactions.items()
|
88
|
+
if model in self.roles[role]
|
89
|
+
}
|
90
|
+
else:
|
91
|
+
interactions = self.interactions
|
92
|
+
|
93
|
+
all_models: dict[str, type[Model]] = {}
|
94
|
+
reply_rules: dict[str, dict[str, type[Model]]] = {}
|
95
|
+
|
96
|
+
for model, replies in interactions.items():
|
97
|
+
model_digest = Model.build_schema_digest(model)
|
98
|
+
all_models[model_digest] = model
|
99
|
+
if len(replies) == 0:
|
100
|
+
reply_rules[model_digest] = {}
|
101
|
+
else:
|
102
|
+
for reply in replies:
|
103
|
+
reply_digest = Model.build_schema_digest(reply)
|
104
|
+
all_models[reply_digest] = reply
|
105
|
+
if model_digest in reply_rules:
|
106
|
+
reply_rules[model_digest][reply_digest] = reply
|
107
|
+
else:
|
108
|
+
reply_rules[model_digest] = {reply_digest: reply}
|
109
|
+
|
110
|
+
for schema_digest, model in all_models.items():
|
111
|
+
manifest["models"].append(
|
112
|
+
{"digest": schema_digest, "schema": model.schema()}
|
113
|
+
)
|
114
|
+
|
115
|
+
for request, responses in reply_rules.items():
|
116
|
+
manifest["interactions"].append(
|
117
|
+
{
|
118
|
+
"type": "normal",
|
119
|
+
"request": request,
|
120
|
+
"responses": sorted(list(responses.keys())),
|
121
|
+
}
|
122
|
+
)
|
123
|
+
|
124
|
+
metadata["digest"] = self.compute_digest(manifest)
|
125
|
+
|
126
|
+
final_manifest: dict[str, Any] = copy.deepcopy(manifest)
|
127
|
+
final_manifest["metadata"] = metadata
|
128
|
+
|
129
|
+
return final_manifest
|
130
|
+
|
131
|
+
@staticmethod
|
132
|
+
def compute_digest(manifest: dict[str, Any]) -> str:
|
133
|
+
"""
|
134
|
+
Compute the digest of a given manifest.
|
135
|
+
|
136
|
+
Args:
|
137
|
+
manifest (dict[str, Any]): The manifest to compute the digest for.
|
138
|
+
|
139
|
+
Returns:
|
140
|
+
str: The computed digest.
|
141
|
+
"""
|
142
|
+
cleaned_manifest = {
|
143
|
+
"version": manifest["version"],
|
144
|
+
"metadata": {},
|
145
|
+
"models": sorted(manifest["models"], key=lambda x: x["digest"]),
|
146
|
+
"interactions": sorted(
|
147
|
+
manifest["interactions"], key=lambda x: x["request"]
|
148
|
+
),
|
149
|
+
}
|
150
|
+
encoded: bytes = json.dumps(
|
151
|
+
obj=cleaned_manifest, indent=None, sort_keys=True
|
152
|
+
).encode("utf8")
|
153
|
+
return f"proto:{hashlib.sha256(encoded).digest().hex()}"
|
154
|
+
|
155
|
+
|
156
|
+
def is_valid_protocol_digest(digest: str) -> bool:
|
157
|
+
"""
|
158
|
+
Check if the given string is a valid protocol digest.
|
159
|
+
|
160
|
+
Args:
|
161
|
+
digest (str): The digest to be checked.
|
162
|
+
|
163
|
+
Returns:
|
164
|
+
bool: True if the digest is valid; False otherwise.
|
165
|
+
"""
|
166
|
+
return len(digest) == 70 and digest.startswith("proto:")
|
uagents_core/registration.py
CHANGED
@@ -1,21 +1,47 @@
|
|
1
1
|
import hashlib
|
2
2
|
import json
|
3
3
|
import time
|
4
|
-
from
|
4
|
+
from abc import ABC, abstractmethod
|
5
|
+
from typing import Any
|
5
6
|
|
6
|
-
from pydantic import BaseModel, Field
|
7
|
+
from pydantic import AliasChoices, BaseModel, Field
|
7
8
|
|
8
|
-
from uagents_core.
|
9
|
-
from uagents_core.types import AddressPrefix, AgentEndpoint, AgentType
|
10
|
-
from uagents_core.utils.communication import parse_identifier
|
9
|
+
from uagents_core.identity import Identity, parse_identifier
|
10
|
+
from uagents_core.types import AddressPrefix, AgentEndpoint, AgentInfo, AgentType
|
11
11
|
|
12
12
|
|
13
|
+
class AgentRegistrationPolicy(ABC):
|
14
|
+
@abstractmethod
|
15
|
+
async def register(
|
16
|
+
self,
|
17
|
+
agent_identifier: str,
|
18
|
+
identity: Identity,
|
19
|
+
protocols: list[str],
|
20
|
+
endpoints: list[AgentEndpoint],
|
21
|
+
metadata: dict[str, Any] | None = None,
|
22
|
+
):
|
23
|
+
raise NotImplementedError
|
24
|
+
|
25
|
+
|
26
|
+
class BatchRegistrationPolicy(ABC):
|
27
|
+
@abstractmethod
|
28
|
+
async def register(self):
|
29
|
+
raise NotImplementedError
|
30
|
+
|
31
|
+
@abstractmethod
|
32
|
+
def add_agent(self, agent_info: AgentInfo, identity: Identity):
|
33
|
+
raise NotImplementedError
|
34
|
+
|
35
|
+
|
36
|
+
# Registration models
|
13
37
|
class VerifiableModel(BaseModel):
|
14
|
-
agent_identifier: str
|
15
|
-
|
16
|
-
|
38
|
+
agent_identifier: str = Field(
|
39
|
+
validation_alias=AliasChoices("agent_identifier", "agent_address")
|
40
|
+
)
|
41
|
+
signature: str | None = None
|
42
|
+
timestamp: int | None = None
|
17
43
|
|
18
|
-
def sign(self, identity: Identity):
|
44
|
+
def sign(self, identity: Identity) -> None:
|
19
45
|
self.timestamp = int(time.time())
|
20
46
|
digest = self._build_digest()
|
21
47
|
self.signature = identity.sign_digest(digest)
|
@@ -23,7 +49,9 @@ class VerifiableModel(BaseModel):
|
|
23
49
|
def verify(self) -> bool:
|
24
50
|
_, _, agent_address = parse_identifier(self.agent_identifier)
|
25
51
|
return self.signature is not None and Identity.verify_digest(
|
26
|
-
agent_address,
|
52
|
+
address=agent_address,
|
53
|
+
digest=self._build_digest(),
|
54
|
+
signature=self.signature,
|
27
55
|
)
|
28
56
|
|
29
57
|
def _build_digest(self) -> bytes:
|
@@ -38,25 +66,27 @@ class VerifiableModel(BaseModel):
|
|
38
66
|
return sha256.digest()
|
39
67
|
|
40
68
|
|
69
|
+
# AlmanacAPI related models
|
41
70
|
class AgentRegistrationAttestation(VerifiableModel):
|
42
|
-
protocols:
|
43
|
-
endpoints:
|
44
|
-
metadata:
|
71
|
+
protocols: list[str]
|
72
|
+
endpoints: list[AgentEndpoint]
|
73
|
+
metadata: dict[str, str | dict[str, str]] | None = None
|
45
74
|
|
46
75
|
|
76
|
+
# Agentverse related models
|
47
77
|
class RegistrationRequest(BaseModel):
|
48
78
|
address: str
|
49
|
-
prefix:
|
79
|
+
prefix: AddressPrefix | None = "test-agent"
|
50
80
|
challenge: str
|
51
81
|
challenge_response: str
|
52
82
|
agent_type: AgentType
|
53
|
-
endpoint:
|
83
|
+
endpoint: str | None = None
|
54
84
|
|
55
85
|
|
56
86
|
class AgentverseConnectRequest(BaseModel):
|
57
87
|
user_token: str
|
58
88
|
agent_type: AgentType
|
59
|
-
endpoint:
|
89
|
+
endpoint: str | None = None
|
60
90
|
|
61
91
|
|
62
92
|
class RegistrationResponse(BaseModel):
|
@@ -73,6 +103,7 @@ class ChallengeResponse(BaseModel):
|
|
73
103
|
|
74
104
|
class AgentUpdates(BaseModel):
|
75
105
|
name: str = Field(min_length=1, max_length=80)
|
76
|
-
readme:
|
77
|
-
avatar_url:
|
78
|
-
|
106
|
+
readme: str | None = Field(default=None, max_length=80000)
|
107
|
+
avatar_url: str | None = Field(default=None, max_length=4000)
|
108
|
+
short_description: str | None = Field(default=None, max_length=300)
|
109
|
+
agent_type: AgentType | None = "custom"
|
uagents_core/types.py
CHANGED
@@ -1,14 +1,50 @@
|
|
1
|
-
|
1
|
+
import uuid
|
2
|
+
from enum import Enum
|
3
|
+
from typing import Any, Literal
|
2
4
|
|
3
5
|
from pydantic import BaseModel
|
4
6
|
|
5
7
|
JsonStr = str
|
6
8
|
|
7
9
|
AgentType = Literal["mailbox", "proxy", "custom"]
|
8
|
-
|
9
10
|
AddressPrefix = Literal["agent", "test-agent"]
|
10
11
|
|
11
12
|
|
12
13
|
class AgentEndpoint(BaseModel):
|
13
14
|
url: str
|
14
15
|
weight: int
|
16
|
+
|
17
|
+
|
18
|
+
class AgentInfo(BaseModel):
|
19
|
+
address: str
|
20
|
+
prefix: AddressPrefix
|
21
|
+
endpoints: list[AgentEndpoint]
|
22
|
+
protocols: list[str]
|
23
|
+
metadata: dict[str, Any] | None = None
|
24
|
+
|
25
|
+
|
26
|
+
class DeliveryStatus(str, Enum):
|
27
|
+
"""Delivery status of a message."""
|
28
|
+
|
29
|
+
SENT = "sent"
|
30
|
+
DELIVERED = "delivered"
|
31
|
+
FAILED = "failed"
|
32
|
+
|
33
|
+
|
34
|
+
class MsgStatus(BaseModel):
|
35
|
+
"""
|
36
|
+
Represents the status of a sent message.
|
37
|
+
|
38
|
+
Attributes:
|
39
|
+
status (str): The delivery status of the message {'sent', 'delivered', 'failed'}.
|
40
|
+
detail (str): The details of the message delivery.
|
41
|
+
destination (str): The destination address of the message.
|
42
|
+
endpoint (str): The endpoint the message was sent to.
|
43
|
+
session (uuid.UUID | None): The session ID of the message.
|
44
|
+
"""
|
45
|
+
|
46
|
+
status: DeliveryStatus
|
47
|
+
detail: str
|
48
|
+
destination: str
|
49
|
+
endpoint: str
|
50
|
+
session: uuid.UUID | None = None
|
uagents_core/utils/__init__.py
CHANGED
@@ -0,0 +1,153 @@
|
|
1
|
+
"""
|
2
|
+
This module provides methods to enable an identity to interact with other agents.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import json
|
6
|
+
from typing import Any, Literal
|
7
|
+
from uuid import UUID, uuid4
|
8
|
+
|
9
|
+
import requests
|
10
|
+
|
11
|
+
from uagents_core.config import DEFAULT_REQUEST_TIMEOUT, AgentverseConfig
|
12
|
+
from uagents_core.envelope import Envelope
|
13
|
+
from uagents_core.helpers import weighted_random_sample
|
14
|
+
from uagents_core.identity import Identity
|
15
|
+
from uagents_core.logger import get_logger
|
16
|
+
from uagents_core.models import Model
|
17
|
+
from uagents_core.types import DeliveryStatus, MsgStatus
|
18
|
+
from uagents_core.utils.resolver import lookup_endpoint_for_agent
|
19
|
+
|
20
|
+
logger = get_logger("uagents_core.utils.messages")
|
21
|
+
|
22
|
+
|
23
|
+
def generate_message_envelope(
|
24
|
+
destination: str,
|
25
|
+
message_schema_digest: str,
|
26
|
+
message_body: Any,
|
27
|
+
sender: Identity,
|
28
|
+
*,
|
29
|
+
session_id: UUID | None = None,
|
30
|
+
protocol_digest: str | None = None,
|
31
|
+
) -> Envelope:
|
32
|
+
"""
|
33
|
+
Generate an envelope for a message to be sent to an agent.
|
34
|
+
|
35
|
+
Args:
|
36
|
+
destination (str): The address of the target agent.
|
37
|
+
message_schema_digest (str): The digest of the model that is being used
|
38
|
+
message_body (Any): The payload of the message.
|
39
|
+
sender (Identity): The identity of the sender.
|
40
|
+
session (UUID): The unique identifier for the dialogue between two agents
|
41
|
+
protocol_digest (str): The digest of the protocol that is being used
|
42
|
+
"""
|
43
|
+
json_payload = json.dumps(message_body, separators=(",", ":"))
|
44
|
+
|
45
|
+
env = Envelope(
|
46
|
+
version=1,
|
47
|
+
sender=sender.address,
|
48
|
+
target=destination,
|
49
|
+
session=session_id or uuid4(),
|
50
|
+
schema_digest=message_schema_digest,
|
51
|
+
protocol_digest=protocol_digest,
|
52
|
+
)
|
53
|
+
|
54
|
+
env.encode_payload(json_payload)
|
55
|
+
env.sign(sender)
|
56
|
+
|
57
|
+
return env
|
58
|
+
|
59
|
+
|
60
|
+
def send_message(
|
61
|
+
endpoint: str, envelope: Envelope, timeout: int = DEFAULT_REQUEST_TIMEOUT
|
62
|
+
) -> requests.Response:
|
63
|
+
"""
|
64
|
+
A helper function to send a message to an agent.
|
65
|
+
|
66
|
+
Args:
|
67
|
+
endpoint (str): The endpoint to send the message to.
|
68
|
+
envelope (Envelope): The envelope containing the message.
|
69
|
+
timeout (int, optional): Requests timeout. Defaults to DEFAULT_REQUEST_TIMEOUT.
|
70
|
+
|
71
|
+
Returns:
|
72
|
+
requests.Response: Response object from the request.
|
73
|
+
"""
|
74
|
+
response = requests.post(
|
75
|
+
url=endpoint,
|
76
|
+
headers={"content-type": "application/json"},
|
77
|
+
data=envelope.model_dump_json(),
|
78
|
+
timeout=timeout,
|
79
|
+
)
|
80
|
+
response.raise_for_status()
|
81
|
+
return response
|
82
|
+
|
83
|
+
|
84
|
+
def send_message_to_agent(
|
85
|
+
destination: str,
|
86
|
+
msg: Model,
|
87
|
+
sender: Identity,
|
88
|
+
*,
|
89
|
+
session_id: UUID | None = None,
|
90
|
+
strategy: Literal["first", "random", "all"] = "first",
|
91
|
+
agentverse_config: AgentverseConfig | None = None,
|
92
|
+
) -> list[MsgStatus]:
|
93
|
+
"""
|
94
|
+
Send a message to an agent with default settings.
|
95
|
+
|
96
|
+
Args:
|
97
|
+
destination (str): The address of the target agent.
|
98
|
+
msg (Model): The message to be sent.
|
99
|
+
sender (Identity): The identity of the sender.
|
100
|
+
session_id (UUID, optional): The unique identifier for the dialogue between two agents.
|
101
|
+
strategy (Literal["first", "random", "all"], optional): The strategy to use when
|
102
|
+
selecting an endpoint.
|
103
|
+
agentverse_config (AgentverseConfig, optional): The configuration for the agentverse.
|
104
|
+
"""
|
105
|
+
agentverse_config = agentverse_config or AgentverseConfig()
|
106
|
+
endpoints = lookup_endpoint_for_agent(
|
107
|
+
agent_identifier=destination, agentverse_config=agentverse_config
|
108
|
+
)
|
109
|
+
if not endpoints:
|
110
|
+
logger.error("No endpoints found for agent", extra={"destination": destination})
|
111
|
+
return []
|
112
|
+
|
113
|
+
env = generate_message_envelope(
|
114
|
+
destination=destination,
|
115
|
+
message_schema_digest=Model.build_schema_digest(msg),
|
116
|
+
message_body=msg.model_dump(),
|
117
|
+
sender=sender,
|
118
|
+
session_id=session_id,
|
119
|
+
)
|
120
|
+
match strategy:
|
121
|
+
case "first":
|
122
|
+
endpoints = endpoints[:1]
|
123
|
+
case "random":
|
124
|
+
endpoints = weighted_random_sample(endpoints)
|
125
|
+
|
126
|
+
endpoints: list[str] = endpoints if strategy == "all" else endpoints[:1]
|
127
|
+
|
128
|
+
result: list[MsgStatus] = []
|
129
|
+
for endpoint in endpoints:
|
130
|
+
try:
|
131
|
+
response = send_message(endpoint, env)
|
132
|
+
logger.info("Sent message to agent", extra={"agent_endpoint": endpoint})
|
133
|
+
result.append(
|
134
|
+
MsgStatus(
|
135
|
+
status=DeliveryStatus.SENT,
|
136
|
+
detail=response.text,
|
137
|
+
destination=destination,
|
138
|
+
endpoint=endpoint,
|
139
|
+
session=env.session,
|
140
|
+
)
|
141
|
+
)
|
142
|
+
except requests.RequestException as e:
|
143
|
+
logger.error("Failed to send message to agent", extra={"error": str(e)})
|
144
|
+
result.append(
|
145
|
+
MsgStatus(
|
146
|
+
status=DeliveryStatus.FAILED,
|
147
|
+
detail=response.text,
|
148
|
+
destination=destination,
|
149
|
+
endpoint=endpoint,
|
150
|
+
session=env.session,
|
151
|
+
)
|
152
|
+
)
|
153
|
+
return result
|