uagents-core 0.1.3__py3-none-any.whl → 0.2.1__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.
@@ -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:")
@@ -1,21 +1,47 @@
1
1
  import hashlib
2
2
  import json
3
3
  import time
4
- from typing import Dict, List, Optional, Union
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.crypto import Identity
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
- signature: Optional[str] = None
16
- timestamp: Optional[int] = None
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, self._build_digest(), self.signature
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: List[str]
43
- endpoints: List[AgentEndpoint]
44
- metadata: Optional[Dict[str, Union[str, Dict[str, str]]]] = None
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: Optional[AddressPrefix] = "test-agent"
79
+ prefix: AddressPrefix | None = "test-agent"
50
80
  challenge: str
51
81
  challenge_response: str
52
82
  agent_type: AgentType
53
- endpoint: Optional[str] = None
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: Optional[str] = None
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: Optional[str] = Field(default=None, max_length=80000)
77
- avatar_url: Optional[str] = Field(default=None, max_length=4000)
78
- agent_type: Optional[AgentType] = "custom"
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
- from typing import Literal
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
@@ -0,0 +1,5 @@
1
+ """
2
+ Helper functions for working with the Fetch.ai uagents-core package.
3
+
4
+ All methods in this module act synchronously / blocking.
5
+ """
@@ -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