uagents-core 0.1.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.
- uagents_core-0.1.0/PKG-INFO +27 -0
- uagents_core-0.1.0/README.md +3 -0
- uagents_core-0.1.0/pyproject.toml +67 -0
- uagents_core-0.1.0/uagents_core/__init__.py +0 -0
- uagents_core-0.1.0/uagents_core/communication.py +76 -0
- uagents_core-0.1.0/uagents_core/config.py +21 -0
- uagents_core-0.1.0/uagents_core/crypto.py +157 -0
- uagents_core-0.1.0/uagents_core/envelope.py +163 -0
- uagents_core-0.1.0/uagents_core/logger.py +37 -0
- uagents_core-0.1.0/uagents_core/models.py +38 -0
- uagents_core-0.1.0/uagents_core/registration.py +78 -0
- uagents_core-0.1.0/uagents_core/types.py +14 -0
- uagents_core-0.1.0/uagents_core/utils/__init__.py +0 -0
- uagents_core-0.1.0/uagents_core/utils/communication.py +130 -0
- uagents_core-0.1.0/uagents_core/utils/registration.py +224 -0
@@ -0,0 +1,27 @@
|
|
1
|
+
Metadata-Version: 2.1
|
2
|
+
Name: uagents-core
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: Core components for agent based systems
|
5
|
+
License: Apache 2.0
|
6
|
+
Author: Ed FitzGerald
|
7
|
+
Author-email: edward.fitzgerald@fetch.ai
|
8
|
+
Requires-Python: >=3.9,<3.13
|
9
|
+
Classifier: License :: Other/Proprietary License
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
15
|
+
Requires-Dist: aiohttp (>=3.8.3,<4.0.0)
|
16
|
+
Requires-Dist: bech32 (>=1.2.0,<2.0.0)
|
17
|
+
Requires-Dist: ecdsa (>=0.19.0,<0.20.0)
|
18
|
+
Requires-Dist: msgpack (>=1.0.4,<2.0.0)
|
19
|
+
Requires-Dist: pydantic (>=2.8,<2.9)
|
20
|
+
Requires-Dist: requests (>=2.32.3,<3.0)
|
21
|
+
Requires-Dist: rich (>=13.9.4,<14.0.0)
|
22
|
+
Requires-Dist: structlog (>=24.4.0,<25.0.0)
|
23
|
+
Description-Content-Type: text/markdown
|
24
|
+
|
25
|
+
# UAgents-Core
|
26
|
+
|
27
|
+
Core definitions and functionalities to build agent which can interact and integrate with Fetch.ai ecosystem and agent marketplace.
|
@@ -0,0 +1,67 @@
|
|
1
|
+
[tool.poetry]
|
2
|
+
name = "uagents-core"
|
3
|
+
version = "0.1.0"
|
4
|
+
description = "Core components for agent based systems"
|
5
|
+
authors = [
|
6
|
+
"Ed FitzGerald <edward.fitzgerald@fetch.ai>",
|
7
|
+
"James Riehl <james.riehl@fetch.ai>",
|
8
|
+
"Alejandro Morales <alejandro.madrigal@fetch.ai>",
|
9
|
+
"Florian Wilde <florian.wilde@fetch.ai>",
|
10
|
+
"Attila Bagoly <attila.bagoly@fetch.ai>",
|
11
|
+
]
|
12
|
+
packages = [{include = "uagents_core"}]
|
13
|
+
license = "Apache 2.0"
|
14
|
+
readme = "README.md"
|
15
|
+
|
16
|
+
[tool.poetry.dependencies]
|
17
|
+
python = ">=3.9,<3.13"
|
18
|
+
pydantic = "~2.8"
|
19
|
+
msgpack = "^1.0.4"
|
20
|
+
bech32 = "^1.2.0"
|
21
|
+
ecdsa = "^0.19.0"
|
22
|
+
aiohttp = "^3.8.3"
|
23
|
+
requests =">=2.32.3,<3.0"
|
24
|
+
structlog = "^24.4.0"
|
25
|
+
rich = "^13.9.4"
|
26
|
+
|
27
|
+
|
28
|
+
[tool.poetry.group.dev.dependencies]
|
29
|
+
black = "^24.10.0"
|
30
|
+
aioresponses = "^0.7.4"
|
31
|
+
pytest = "^8.3.4"
|
32
|
+
pytest-asyncio = "^0.25.0"
|
33
|
+
pytest-order = "^1.3.0"
|
34
|
+
ruff = "^0.8.4"
|
35
|
+
pyright = "^1.1.391"
|
36
|
+
pre-commit = "^4.0.1"
|
37
|
+
|
38
|
+
|
39
|
+
[build-system]
|
40
|
+
requires = ["poetry-core>=1.0.0"]
|
41
|
+
build-backend = "poetry.core.masonry.api"
|
42
|
+
|
43
|
+
[tool.ruff]
|
44
|
+
target-version = "py310"
|
45
|
+
|
46
|
+
[tool.ruff.lint]
|
47
|
+
select = [
|
48
|
+
# pycodestyle (Errors, Warnings)
|
49
|
+
"E",
|
50
|
+
"W",
|
51
|
+
# Pyflakes
|
52
|
+
"F",
|
53
|
+
# flake8-bugbear
|
54
|
+
"B",
|
55
|
+
# flake8-simplify
|
56
|
+
"SIM",
|
57
|
+
# isort
|
58
|
+
"I",
|
59
|
+
# pep8-naming
|
60
|
+
"N",
|
61
|
+
# pylint
|
62
|
+
"PL",
|
63
|
+
]
|
64
|
+
ignore = ["PLR0913", "PLR0912", "PLR0911", "PLR2004", "PLR0915"]
|
65
|
+
|
66
|
+
[tool.ruff.lint.pycodestyle]
|
67
|
+
max-line-length = 100
|
File without changes
|
@@ -0,0 +1,76 @@
|
|
1
|
+
import random
|
2
|
+
from typing import Any, List, Optional, Tuple
|
3
|
+
|
4
|
+
from uagents_core.config import (
|
5
|
+
AGENT_ADDRESS_LENGTH,
|
6
|
+
AGENT_PREFIX,
|
7
|
+
)
|
8
|
+
from uagents_core.crypto import is_user_address
|
9
|
+
|
10
|
+
|
11
|
+
def weighted_random_sample(
|
12
|
+
items: List[Any], weights: Optional[List[float]] = None, k: int = 1, rng=random
|
13
|
+
) -> List[Any]:
|
14
|
+
"""
|
15
|
+
Weighted random sample from a list of items without replacement.
|
16
|
+
|
17
|
+
Ref: Efraimidis, Pavlos S. "Weighted random sampling over data streams."
|
18
|
+
|
19
|
+
Args:
|
20
|
+
items (List[Any]): The list of items to sample from.
|
21
|
+
weights (Optional[List[float]]): The optional list of weights for each item.
|
22
|
+
k (int): The number of items to sample.
|
23
|
+
rng (random): The random number generator.
|
24
|
+
|
25
|
+
Returns:
|
26
|
+
List[Any]: The sampled items.
|
27
|
+
"""
|
28
|
+
if weights is None:
|
29
|
+
return rng.sample(items, k=k)
|
30
|
+
values = [rng.random() ** (1 / w) for w in weights]
|
31
|
+
order = sorted(range(len(items)), key=lambda i: values[i])
|
32
|
+
return [items[i] for i in order[-k:]]
|
33
|
+
|
34
|
+
|
35
|
+
def is_valid_address(address: str) -> bool:
|
36
|
+
"""
|
37
|
+
Check if the given string is a valid address.
|
38
|
+
|
39
|
+
Args:
|
40
|
+
address (str): The address to be checked.
|
41
|
+
|
42
|
+
Returns:
|
43
|
+
bool: True if the address is valid; False otherwise.
|
44
|
+
"""
|
45
|
+
return is_user_address(address) or (
|
46
|
+
len(address) == AGENT_ADDRESS_LENGTH and address.startswith(AGENT_PREFIX)
|
47
|
+
)
|
48
|
+
|
49
|
+
|
50
|
+
def parse_identifier(identifier: str) -> Tuple[str, str, str]:
|
51
|
+
"""
|
52
|
+
Parse an agent identifier string into prefix, name, and address.
|
53
|
+
|
54
|
+
Args:
|
55
|
+
identifier (str): The identifier string to be parsed.
|
56
|
+
|
57
|
+
Returns:
|
58
|
+
Tuple[str, str, str]: A Tuple containing the prefix, name, and address as strings.
|
59
|
+
"""
|
60
|
+
|
61
|
+
prefix = ""
|
62
|
+
name = ""
|
63
|
+
address = ""
|
64
|
+
|
65
|
+
if "://" in identifier:
|
66
|
+
prefix, identifier = identifier.split("://", 1)
|
67
|
+
|
68
|
+
if "/" in identifier:
|
69
|
+
name, identifier = identifier.split("/", 1)
|
70
|
+
|
71
|
+
if is_valid_address(identifier):
|
72
|
+
address = identifier
|
73
|
+
else:
|
74
|
+
name = identifier
|
75
|
+
|
76
|
+
return prefix, name, address
|
@@ -0,0 +1,21 @@
|
|
1
|
+
from pydantic import BaseModel
|
2
|
+
|
3
|
+
DEFAULT_AGENTVERSE_URL = "agentverse.ai"
|
4
|
+
DEFAULT_ALMANAC_API_PATH = "/v1/almanac"
|
5
|
+
DEFAULT_REGISTRATION_PATH = "/v1/agents"
|
6
|
+
DEFAULT_CHALLENGE_PATH = "/v1/auth/challenge"
|
7
|
+
|
8
|
+
DEFAULT_MAX_ENDPOINTS = 10
|
9
|
+
|
10
|
+
AGENT_ADDRESS_LENGTH = 65
|
11
|
+
AGENT_PREFIX = "agent"
|
12
|
+
|
13
|
+
|
14
|
+
class AgentverseConfig(BaseModel):
|
15
|
+
base_url: str = DEFAULT_AGENTVERSE_URL
|
16
|
+
protocol: str = "https"
|
17
|
+
http_prefix: str = "https"
|
18
|
+
|
19
|
+
@property
|
20
|
+
def url(self) -> str:
|
21
|
+
return f"{self.http_prefix}://{self.base_url}"
|
@@ -0,0 +1,157 @@
|
|
1
|
+
import base64
|
2
|
+
import hashlib
|
3
|
+
import struct
|
4
|
+
from secrets import token_bytes
|
5
|
+
from typing import Tuple, Union
|
6
|
+
|
7
|
+
import bech32
|
8
|
+
import ecdsa
|
9
|
+
from ecdsa.util import sigencode_string_canonize
|
10
|
+
|
11
|
+
USER_PREFIX = "user"
|
12
|
+
SHA_LENGTH = 256
|
13
|
+
|
14
|
+
|
15
|
+
def _decode_bech32(value: str) -> Tuple[str, bytes]:
|
16
|
+
prefix, data_base5 = bech32.bech32_decode(value)
|
17
|
+
data = bytes(bech32.convertbits(data_base5, 5, 8, False))
|
18
|
+
return prefix, data
|
19
|
+
|
20
|
+
|
21
|
+
def _encode_bech32(prefix: str, value: bytes) -> str:
|
22
|
+
value_base5 = bech32.convertbits(value, 8, 5)
|
23
|
+
return bech32.bech32_encode(prefix, value_base5)
|
24
|
+
|
25
|
+
|
26
|
+
def is_user_address(address: str) -> bool:
|
27
|
+
return address[0 : len(USER_PREFIX)] == USER_PREFIX
|
28
|
+
|
29
|
+
|
30
|
+
def generate_user_address() -> str:
|
31
|
+
return _encode_bech32(USER_PREFIX, token_bytes(32))
|
32
|
+
|
33
|
+
|
34
|
+
def _key_derivation_hash(prefix: str, index: int) -> bytes:
|
35
|
+
hasher = hashlib.sha256()
|
36
|
+
hasher.update(prefix.encode())
|
37
|
+
assert 0 <= index < SHA_LENGTH
|
38
|
+
hasher.update(bytes([index]))
|
39
|
+
return hasher.digest()
|
40
|
+
|
41
|
+
|
42
|
+
def _seed_hash(seed: str) -> bytes:
|
43
|
+
hasher = hashlib.sha256()
|
44
|
+
hasher.update(seed.encode())
|
45
|
+
return hasher.digest()
|
46
|
+
|
47
|
+
|
48
|
+
def derive_key_from_seed(seed, prefix, index) -> bytes:
|
49
|
+
hasher = hashlib.sha256()
|
50
|
+
hasher.update(_key_derivation_hash(prefix, index))
|
51
|
+
hasher.update(_seed_hash(seed))
|
52
|
+
return hasher.digest()
|
53
|
+
|
54
|
+
|
55
|
+
def encode_length_prefixed(value: Union[str, int, bytes]) -> bytes:
|
56
|
+
if isinstance(value, str):
|
57
|
+
encoded = value.encode()
|
58
|
+
elif isinstance(value, int):
|
59
|
+
encoded = struct.pack(">Q", value)
|
60
|
+
elif isinstance(value, bytes):
|
61
|
+
encoded = value
|
62
|
+
else:
|
63
|
+
raise AssertionError()
|
64
|
+
|
65
|
+
length = len(encoded)
|
66
|
+
prefix = struct.pack(">Q", length)
|
67
|
+
|
68
|
+
return prefix + encoded
|
69
|
+
|
70
|
+
|
71
|
+
class Identity:
|
72
|
+
"""An identity is a cryptographic keypair that can be used to sign messages."""
|
73
|
+
|
74
|
+
def __init__(self, signing_key: ecdsa.SigningKey):
|
75
|
+
"""Create a new identity from a signing key."""
|
76
|
+
self._sk = signing_key
|
77
|
+
|
78
|
+
# build the address
|
79
|
+
pub_key_bytes = self._sk.get_verifying_key().to_string(encoding="compressed")
|
80
|
+
self._address = _encode_bech32("agent", pub_key_bytes)
|
81
|
+
self._pub_key = pub_key_bytes.hex()
|
82
|
+
|
83
|
+
@staticmethod
|
84
|
+
def from_seed(seed: str, index: int) -> "Identity":
|
85
|
+
"""Create a new identity from a seed and index."""
|
86
|
+
key = derive_key_from_seed(seed, "agent", index)
|
87
|
+
signing_key = ecdsa.SigningKey.from_string(
|
88
|
+
key,
|
89
|
+
curve=ecdsa.SECP256k1,
|
90
|
+
hashfunc=hashlib.sha256,
|
91
|
+
)
|
92
|
+
return Identity(signing_key)
|
93
|
+
|
94
|
+
@staticmethod
|
95
|
+
def generate() -> "Identity":
|
96
|
+
"""Generate a random new identity."""
|
97
|
+
signing_key = ecdsa.SigningKey.generate(
|
98
|
+
curve=ecdsa.SECP256k1,
|
99
|
+
hashfunc=hashlib.sha256,
|
100
|
+
)
|
101
|
+
return Identity(signing_key)
|
102
|
+
|
103
|
+
@staticmethod
|
104
|
+
def from_string(private_key_hex: str) -> "Identity":
|
105
|
+
"""Create a new identity from a private key."""
|
106
|
+
bytes_key = bytes.fromhex(private_key_hex)
|
107
|
+
signing_key = ecdsa.SigningKey.from_string(
|
108
|
+
bytes_key,
|
109
|
+
curve=ecdsa.SECP256k1,
|
110
|
+
hashfunc=hashlib.sha256,
|
111
|
+
)
|
112
|
+
|
113
|
+
return Identity(signing_key)
|
114
|
+
|
115
|
+
# this is not the real private key but a signing key derived from the private key
|
116
|
+
@property
|
117
|
+
def private_key(self) -> str:
|
118
|
+
"""Property to access the private key of the identity."""
|
119
|
+
return self._sk.to_string().hex()
|
120
|
+
|
121
|
+
@property
|
122
|
+
def address(self) -> str:
|
123
|
+
"""Property to access the address of the identity."""
|
124
|
+
return self._address
|
125
|
+
|
126
|
+
@property
|
127
|
+
def pub_key(self) -> str:
|
128
|
+
return self._pub_key
|
129
|
+
|
130
|
+
def sign(self, data: bytes) -> str:
|
131
|
+
"""Sign the provided data."""
|
132
|
+
return _encode_bech32("sig", self._sk.sign(data))
|
133
|
+
|
134
|
+
def sign_b64(self, data: bytes) -> str:
|
135
|
+
raw_signature = bytes(self._sk.sign(data, sigencode=sigencode_string_canonize))
|
136
|
+
return base64.b64encode(raw_signature).decode()
|
137
|
+
|
138
|
+
def sign_digest(self, digest: bytes) -> str:
|
139
|
+
"""Sign the provided digest."""
|
140
|
+
return _encode_bech32("sig", self._sk.sign_digest(digest))
|
141
|
+
|
142
|
+
@staticmethod
|
143
|
+
def verify_digest(address: str, digest: bytes, signature: str) -> bool:
|
144
|
+
"""Verify that the signature is correct for the provided signer address and digest."""
|
145
|
+
pk_prefix, pk_data = _decode_bech32(address)
|
146
|
+
sig_prefix, sig_data = _decode_bech32(signature)
|
147
|
+
|
148
|
+
if pk_prefix != "agent":
|
149
|
+
raise ValueError("Unable to decode agent address")
|
150
|
+
|
151
|
+
if sig_prefix != "sig":
|
152
|
+
raise ValueError("Unable to decode signature")
|
153
|
+
|
154
|
+
# build the verifying key
|
155
|
+
verifying_key = ecdsa.VerifyingKey.from_string(pk_data, curve=ecdsa.SECP256k1)
|
156
|
+
|
157
|
+
return verifying_key.verify_digest(sig_data, digest)
|
@@ -0,0 +1,163 @@
|
|
1
|
+
"""Agent Envelope."""
|
2
|
+
|
3
|
+
import base64
|
4
|
+
import hashlib
|
5
|
+
import struct
|
6
|
+
import time
|
7
|
+
from typing import List, Optional
|
8
|
+
|
9
|
+
from pydantic import (
|
10
|
+
UUID4,
|
11
|
+
BaseModel,
|
12
|
+
Field,
|
13
|
+
field_serializer,
|
14
|
+
)
|
15
|
+
|
16
|
+
from uagents_core.crypto import Identity
|
17
|
+
from uagents_core.types import JsonStr
|
18
|
+
|
19
|
+
|
20
|
+
class Envelope(BaseModel):
|
21
|
+
"""
|
22
|
+
Represents an envelope for message communication between agents.
|
23
|
+
|
24
|
+
Attributes:
|
25
|
+
version (int): The envelope version.
|
26
|
+
sender (str): The sender's address.
|
27
|
+
target (str): The target's address.
|
28
|
+
session (UUID4): The session UUID that persists for back-and-forth
|
29
|
+
dialogues between agents.
|
30
|
+
schema_digest (str): The schema digest for the enclosed message.
|
31
|
+
protocol_digest (Optional[str]): The digest of the protocol associated with the message
|
32
|
+
(optional).
|
33
|
+
payload (Optional[str]): The encoded message payload of the envelope (optional).
|
34
|
+
expires (Optional[int]): The expiration timestamp (optional).
|
35
|
+
nonce (Optional[int]): The nonce value (optional).
|
36
|
+
signature (Optional[str]): The envelope signature (optional).
|
37
|
+
"""
|
38
|
+
|
39
|
+
version: int
|
40
|
+
sender: str
|
41
|
+
target: str
|
42
|
+
session: UUID4
|
43
|
+
schema_digest: str
|
44
|
+
protocol_digest: Optional[str] = None
|
45
|
+
payload: Optional[str] = None
|
46
|
+
expires: Optional[int] = None
|
47
|
+
nonce: Optional[int] = None
|
48
|
+
signature: Optional[str] = None
|
49
|
+
|
50
|
+
def encode_payload(self, value: JsonStr):
|
51
|
+
"""
|
52
|
+
Encode the payload value and store it in the envelope.
|
53
|
+
|
54
|
+
Args:
|
55
|
+
value (JsonStr): The payload value to be encoded.
|
56
|
+
"""
|
57
|
+
self.payload = base64.b64encode(value.encode()).decode()
|
58
|
+
|
59
|
+
def decode_payload(self) -> str:
|
60
|
+
"""
|
61
|
+
Decode and retrieve the payload value from the envelope.
|
62
|
+
|
63
|
+
Returns:
|
64
|
+
str: The decoded payload value, or '' if payload is not present.
|
65
|
+
"""
|
66
|
+
if self.payload is None:
|
67
|
+
return ""
|
68
|
+
|
69
|
+
return base64.b64decode(self.payload).decode()
|
70
|
+
|
71
|
+
def sign(self, identity: Identity):
|
72
|
+
"""
|
73
|
+
Sign the envelope with the provided identity.
|
74
|
+
|
75
|
+
Args:
|
76
|
+
identity (Identity): The identity to use for signing.
|
77
|
+
|
78
|
+
Raises:
|
79
|
+
ValueError: If the signature cannot be computed.
|
80
|
+
"""
|
81
|
+
try:
|
82
|
+
self.signature = identity.sign_digest(self._digest())
|
83
|
+
except Exception as err:
|
84
|
+
raise ValueError(f"Failed to sign envelope: {err}") from err
|
85
|
+
|
86
|
+
def verify(self) -> bool:
|
87
|
+
"""
|
88
|
+
Verify the envelope's signature.
|
89
|
+
|
90
|
+
Returns:
|
91
|
+
bool: True if the signature is valid.
|
92
|
+
|
93
|
+
Raises:
|
94
|
+
ValueError: If the signature is missing.
|
95
|
+
ecdsa.BadSignatureError: If the signature is invalid.
|
96
|
+
"""
|
97
|
+
if self.signature is None:
|
98
|
+
raise ValueError("Envelope signature is missing")
|
99
|
+
return Identity.verify_digest(self.sender, self._digest(), self.signature)
|
100
|
+
|
101
|
+
def _digest(self) -> bytes:
|
102
|
+
"""
|
103
|
+
Compute the digest of the envelope's content.
|
104
|
+
|
105
|
+
Returns:
|
106
|
+
bytes: The computed digest.
|
107
|
+
"""
|
108
|
+
hasher = hashlib.sha256()
|
109
|
+
hasher.update(self.sender.encode())
|
110
|
+
hasher.update(self.target.encode())
|
111
|
+
hasher.update(str(self.session).encode())
|
112
|
+
hasher.update(self.schema_digest.encode())
|
113
|
+
if self.payload is not None:
|
114
|
+
hasher.update(self.payload.encode())
|
115
|
+
if self.expires is not None:
|
116
|
+
hasher.update(struct.pack(">Q", self.expires))
|
117
|
+
if self.nonce is not None:
|
118
|
+
hasher.update(struct.pack(">Q", self.nonce))
|
119
|
+
return hasher.digest()
|
120
|
+
|
121
|
+
|
122
|
+
class EnvelopeHistoryEntry(BaseModel):
|
123
|
+
timestamp: int = Field(default_factory=lambda: int(time.time()))
|
124
|
+
version: int
|
125
|
+
sender: str
|
126
|
+
target: str
|
127
|
+
session: UUID4
|
128
|
+
schema_digest: str
|
129
|
+
protocol_digest: Optional[str] = None
|
130
|
+
payload: Optional[str] = None
|
131
|
+
|
132
|
+
@field_serializer("session")
|
133
|
+
def serialize_session(self, session: UUID4, _info):
|
134
|
+
return str(session)
|
135
|
+
|
136
|
+
@classmethod
|
137
|
+
def from_envelope(cls, envelope: Envelope):
|
138
|
+
return cls(
|
139
|
+
version=envelope.version,
|
140
|
+
sender=envelope.sender,
|
141
|
+
target=envelope.target,
|
142
|
+
session=envelope.session,
|
143
|
+
schema_digest=envelope.schema_digest,
|
144
|
+
protocol_digest=envelope.protocol_digest,
|
145
|
+
payload=envelope.decode_payload(),
|
146
|
+
)
|
147
|
+
|
148
|
+
|
149
|
+
class EnvelopeHistory(BaseModel):
|
150
|
+
envelopes: List[EnvelopeHistoryEntry]
|
151
|
+
|
152
|
+
def add_entry(self, entry: EnvelopeHistoryEntry):
|
153
|
+
self.envelopes.append(entry)
|
154
|
+
self.apply_retention_policy()
|
155
|
+
|
156
|
+
def apply_retention_policy(self):
|
157
|
+
"""Remove entries older than 24 hours"""
|
158
|
+
cutoff_time = time.time() - 86400
|
159
|
+
for e in self.envelopes:
|
160
|
+
if e.timestamp < cutoff_time:
|
161
|
+
self.envelopes.remove(e)
|
162
|
+
else:
|
163
|
+
break
|
@@ -0,0 +1,37 @@
|
|
1
|
+
import logging
|
2
|
+
import os
|
3
|
+
|
4
|
+
import structlog
|
5
|
+
|
6
|
+
_log_level_map = {
|
7
|
+
"NOTSET": logging.NOTSET,
|
8
|
+
"DEBUG": logging.DEBUG,
|
9
|
+
"INFO": logging.INFO,
|
10
|
+
"WARNING": logging.WARNING,
|
11
|
+
"ERROR": logging.ERROR,
|
12
|
+
"CRITICAL": logging.CRITICAL,
|
13
|
+
}
|
14
|
+
|
15
|
+
_log_level = os.getenv("LOG_LEVEL", "INFO")
|
16
|
+
|
17
|
+
|
18
|
+
structlog.configure(
|
19
|
+
processors=[
|
20
|
+
structlog.contextvars.merge_contextvars,
|
21
|
+
structlog.processors.add_log_level,
|
22
|
+
structlog.processors.StackInfoRenderer(),
|
23
|
+
structlog.dev.set_exc_info,
|
24
|
+
structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S", utc=False),
|
25
|
+
structlog.dev.ConsoleRenderer(),
|
26
|
+
],
|
27
|
+
wrapper_class=structlog.make_filtering_bound_logger(
|
28
|
+
_log_level_map.get(_log_level, logging.INFO)
|
29
|
+
),
|
30
|
+
context_class=dict,
|
31
|
+
logger_factory=structlog.PrintLoggerFactory(),
|
32
|
+
cache_logger_on_first_use=False,
|
33
|
+
)
|
34
|
+
|
35
|
+
|
36
|
+
def get_logger(logger_name: str):
|
37
|
+
return structlog.get_logger(logger_name)
|
@@ -0,0 +1,38 @@
|
|
1
|
+
import hashlib
|
2
|
+
from typing import Any, Dict, Type, Union
|
3
|
+
|
4
|
+
from pydantic.v1 import BaseModel, Field # noqa
|
5
|
+
|
6
|
+
|
7
|
+
# reverting back to pydantic v1 BaseModel for backwards compatibility
|
8
|
+
class Model(BaseModel):
|
9
|
+
@classmethod
|
10
|
+
def model_json_schema(cls) -> str:
|
11
|
+
return cls.schema_json()
|
12
|
+
|
13
|
+
def model_dump_json(self) -> str:
|
14
|
+
return self.json()
|
15
|
+
|
16
|
+
def model_dump(self) -> Dict[str, Any]:
|
17
|
+
return self.dict()
|
18
|
+
|
19
|
+
@classmethod
|
20
|
+
def model_validate_json(cls, obj: Any) -> "Model":
|
21
|
+
return cls.parse_raw(obj)
|
22
|
+
|
23
|
+
@classmethod
|
24
|
+
def model_validate(cls, obj: Union[Dict[str, Any], "Model"]) -> "Model":
|
25
|
+
return cls.parse_obj(obj)
|
26
|
+
|
27
|
+
@staticmethod
|
28
|
+
def build_schema_digest(model: Union["Model", Type["Model"]]) -> str:
|
29
|
+
schema = model.schema_json(indent=None, sort_keys=True)
|
30
|
+
digest = hashlib.sha256(schema.encode("utf8")).digest().hex()
|
31
|
+
|
32
|
+
return f"model:{digest}"
|
33
|
+
|
34
|
+
|
35
|
+
class ErrorMessage(Model):
|
36
|
+
"""Error message model"""
|
37
|
+
|
38
|
+
error: str
|
@@ -0,0 +1,78 @@
|
|
1
|
+
import hashlib
|
2
|
+
import json
|
3
|
+
import time
|
4
|
+
from typing import Dict, List, Optional, Union
|
5
|
+
|
6
|
+
from pydantic import BaseModel, Field
|
7
|
+
|
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
|
11
|
+
|
12
|
+
|
13
|
+
class VerifiableModel(BaseModel):
|
14
|
+
agent_identifier: str
|
15
|
+
signature: Optional[str] = None
|
16
|
+
timestamp: Optional[int] = None
|
17
|
+
|
18
|
+
def sign(self, identity: Identity):
|
19
|
+
self.timestamp = int(time.time())
|
20
|
+
digest = self._build_digest()
|
21
|
+
self.signature = identity.sign_digest(digest)
|
22
|
+
|
23
|
+
def verify(self) -> bool:
|
24
|
+
_, _, agent_address = parse_identifier(self.agent_identifier)
|
25
|
+
return self.signature is not None and Identity.verify_digest(
|
26
|
+
agent_address, self._build_digest(), self.signature
|
27
|
+
)
|
28
|
+
|
29
|
+
def _build_digest(self) -> bytes:
|
30
|
+
sha256 = hashlib.sha256()
|
31
|
+
sha256.update(
|
32
|
+
json.dumps(
|
33
|
+
self.model_dump(exclude={"signature"}),
|
34
|
+
sort_keys=True,
|
35
|
+
separators=(",", ":"),
|
36
|
+
).encode("utf-8")
|
37
|
+
)
|
38
|
+
return sha256.digest()
|
39
|
+
|
40
|
+
|
41
|
+
class AgentRegistrationAttestation(VerifiableModel):
|
42
|
+
protocols: List[str]
|
43
|
+
endpoints: List[AgentEndpoint]
|
44
|
+
metadata: Optional[Dict[str, Union[str, Dict[str, str]]]] = None
|
45
|
+
|
46
|
+
|
47
|
+
class RegistrationRequest(BaseModel):
|
48
|
+
address: str
|
49
|
+
prefix: Optional[AddressPrefix] = "test-agent"
|
50
|
+
challenge: str
|
51
|
+
challenge_response: str
|
52
|
+
agent_type: AgentType
|
53
|
+
endpoint: Optional[str] = None
|
54
|
+
|
55
|
+
|
56
|
+
class AgentverseConnectRequest(BaseModel):
|
57
|
+
user_token: str
|
58
|
+
agent_type: AgentType
|
59
|
+
endpoint: Optional[str] = None
|
60
|
+
|
61
|
+
|
62
|
+
class RegistrationResponse(BaseModel):
|
63
|
+
success: bool
|
64
|
+
|
65
|
+
|
66
|
+
class ChallengeRequest(BaseModel):
|
67
|
+
address: str
|
68
|
+
|
69
|
+
|
70
|
+
class ChallengeResponse(BaseModel):
|
71
|
+
challenge: str
|
72
|
+
|
73
|
+
|
74
|
+
class AgentUpdates(BaseModel):
|
75
|
+
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"
|
File without changes
|
@@ -0,0 +1,130 @@
|
|
1
|
+
import json
|
2
|
+
import urllib.parse
|
3
|
+
from typing import Any, List, Optional
|
4
|
+
from uuid import UUID, uuid4
|
5
|
+
|
6
|
+
import requests
|
7
|
+
|
8
|
+
from uagents_core.communication import parse_identifier, weighted_random_sample
|
9
|
+
from uagents_core.config import (
|
10
|
+
DEFAULT_ALMANAC_API_PATH,
|
11
|
+
DEFAULT_MAX_ENDPOINTS,
|
12
|
+
AgentverseConfig,
|
13
|
+
)
|
14
|
+
from uagents_core.crypto import Identity
|
15
|
+
from uagents_core.envelope import Envelope
|
16
|
+
from uagents_core.logger import get_logger
|
17
|
+
|
18
|
+
logger = get_logger("uagents_core.utils.communication")
|
19
|
+
|
20
|
+
|
21
|
+
def lookup_endpoint_for_agent(
|
22
|
+
agent_identifier: str,
|
23
|
+
*,
|
24
|
+
max_endpoints: int = DEFAULT_MAX_ENDPOINTS,
|
25
|
+
agentverse_config: Optional[AgentverseConfig] = None,
|
26
|
+
) -> List[str]:
|
27
|
+
"""
|
28
|
+
Look up the endpoints for an agent using the Almanac API.
|
29
|
+
|
30
|
+
Args:
|
31
|
+
destination (str): The destination address to look up.
|
32
|
+
|
33
|
+
Returns:
|
34
|
+
List[str]: The endpoint(s) for the agent.
|
35
|
+
"""
|
36
|
+
_, _, agent_address = parse_identifier(agent_identifier)
|
37
|
+
|
38
|
+
agentverse_config = agentverse_config or AgentverseConfig()
|
39
|
+
almanac_api = urllib.parse.urljoin(agentverse_config.url, DEFAULT_ALMANAC_API_PATH)
|
40
|
+
|
41
|
+
request_meta: dict[str, Any] = {
|
42
|
+
"agent_address": agent_address,
|
43
|
+
"lookup_url": almanac_api,
|
44
|
+
}
|
45
|
+
logger.debug("looking up endpoint for agent", extra=request_meta)
|
46
|
+
r = requests.get(f"{almanac_api}/agents/{agent_address}")
|
47
|
+
r.raise_for_status()
|
48
|
+
|
49
|
+
request_meta["response_status"] = r.status_code
|
50
|
+
logger.info(
|
51
|
+
"Got response looking up agent endpoint",
|
52
|
+
extra=request_meta,
|
53
|
+
)
|
54
|
+
|
55
|
+
endpoints = r.json().get("endpoints", [])
|
56
|
+
|
57
|
+
if len(endpoints) > 0:
|
58
|
+
endpoints = [val.get("url") for val in endpoints]
|
59
|
+
weights = [val.get("weight") for val in endpoints]
|
60
|
+
return weighted_random_sample(
|
61
|
+
endpoints,
|
62
|
+
weights=weights,
|
63
|
+
k=min(max_endpoints, len(endpoints)),
|
64
|
+
)
|
65
|
+
|
66
|
+
return []
|
67
|
+
|
68
|
+
|
69
|
+
def send_message(
|
70
|
+
destination: str,
|
71
|
+
message_schema_digest: str,
|
72
|
+
message_body: Any,
|
73
|
+
sender: Identity,
|
74
|
+
*,
|
75
|
+
session_id: Optional[UUID] = None,
|
76
|
+
protocol_digest: Optional[str] = None,
|
77
|
+
agentverse_config: Optional[AgentverseConfig] = None,
|
78
|
+
):
|
79
|
+
"""
|
80
|
+
Send a message (dict) to an agent.
|
81
|
+
|
82
|
+
Args:
|
83
|
+
destination (str): The address of the target agent.
|
84
|
+
message_schema_digest (str): The digest of the model that is being used
|
85
|
+
message_body (Any): The payload of the message.
|
86
|
+
sender (Identity): The identity of the sender.
|
87
|
+
session (UUID): The unique identifier for the dialogue between two agents
|
88
|
+
protocol_digest (str): The digest of the protocol that is being used
|
89
|
+
agentverse_config (AgentverseConfig): The configuration for the agentverse API
|
90
|
+
Returns:
|
91
|
+
None
|
92
|
+
"""
|
93
|
+
json_payload = json.dumps(message_body, separators=(",", ":"))
|
94
|
+
|
95
|
+
env = Envelope(
|
96
|
+
version=1,
|
97
|
+
sender=sender.address,
|
98
|
+
target=destination,
|
99
|
+
session=session_id or uuid4(),
|
100
|
+
schema_digest=message_schema_digest,
|
101
|
+
protocol_digest=protocol_digest,
|
102
|
+
)
|
103
|
+
|
104
|
+
env.encode_payload(json_payload)
|
105
|
+
env.sign(sender)
|
106
|
+
|
107
|
+
logger.debug("Sending message to agent", extra={"envelope": env.model_dump()})
|
108
|
+
|
109
|
+
# query the almanac to lookup the destination agent
|
110
|
+
agentverse_config = agentverse_config or AgentverseConfig()
|
111
|
+
endpoints = lookup_endpoint_for_agent(
|
112
|
+
destination, agentverse_config=agentverse_config
|
113
|
+
)
|
114
|
+
|
115
|
+
if len(endpoints) == 0:
|
116
|
+
logger.error(
|
117
|
+
"No endpoints found for agent", extra={"agent_address": destination}
|
118
|
+
)
|
119
|
+
return
|
120
|
+
|
121
|
+
# send the envelope to the destination agent
|
122
|
+
request_meta = {"agent_address": destination, "agent_endpoint": endpoints[0]}
|
123
|
+
logger.debug("Sending message to agent", extra=request_meta)
|
124
|
+
r = requests.post(
|
125
|
+
endpoints[0],
|
126
|
+
headers={"content-type": "application/json"},
|
127
|
+
data=env.model_dump_json(),
|
128
|
+
)
|
129
|
+
r.raise_for_status()
|
130
|
+
logger.info("Sent message to agent", extra=request_meta)
|
@@ -0,0 +1,224 @@
|
|
1
|
+
import urllib.parse
|
2
|
+
from typing import List, Optional
|
3
|
+
|
4
|
+
import requests
|
5
|
+
|
6
|
+
from uagents_core.config import (
|
7
|
+
DEFAULT_ALMANAC_API_PATH,
|
8
|
+
DEFAULT_CHALLENGE_PATH,
|
9
|
+
DEFAULT_REGISTRATION_PATH,
|
10
|
+
AgentverseConfig,
|
11
|
+
)
|
12
|
+
from uagents_core.crypto import Identity
|
13
|
+
from uagents_core.logger import get_logger
|
14
|
+
from uagents_core.registration import (
|
15
|
+
AgentRegistrationAttestation,
|
16
|
+
AgentUpdates,
|
17
|
+
AgentverseConnectRequest,
|
18
|
+
ChallengeRequest,
|
19
|
+
ChallengeResponse,
|
20
|
+
RegistrationRequest,
|
21
|
+
RegistrationResponse,
|
22
|
+
)
|
23
|
+
from uagents_core.types import AgentEndpoint
|
24
|
+
|
25
|
+
logger = get_logger("uagents_core.utils.registration")
|
26
|
+
|
27
|
+
|
28
|
+
def register_in_almanac(
|
29
|
+
request: AgentverseConnectRequest,
|
30
|
+
identity: Identity,
|
31
|
+
*,
|
32
|
+
protocol_digests: List[str],
|
33
|
+
agentverse_config: Optional[AgentverseConfig] = None,
|
34
|
+
):
|
35
|
+
"""
|
36
|
+
Register the agent with the Almanac API.
|
37
|
+
|
38
|
+
Args:
|
39
|
+
request (AgentverseConnectRequest): The request containing the agent details.
|
40
|
+
identity (Identity): The identity of the agent.
|
41
|
+
protocol_digest (List[str]): The digest of the protocol that the agent supports
|
42
|
+
agentverse_config (AgentverseConfig): The configuration for the agentverse API
|
43
|
+
"""
|
44
|
+
|
45
|
+
# get the almanac API endpoint
|
46
|
+
agentverse_config = agentverse_config or AgentverseConfig()
|
47
|
+
almanac_api = urllib.parse.urljoin(agentverse_config.url, DEFAULT_ALMANAC_API_PATH)
|
48
|
+
|
49
|
+
# get the agent address
|
50
|
+
agent_address = identity.address
|
51
|
+
|
52
|
+
registration_metadata = {
|
53
|
+
"almanac_endpoint": almanac_api,
|
54
|
+
"agent_address": agent_address,
|
55
|
+
"agent_endpoint": request.endpoint or "",
|
56
|
+
"protocol_digest": ",".join(protocol_digests),
|
57
|
+
}
|
58
|
+
if request.endpoint is None:
|
59
|
+
if request.agent_type == "mailbox":
|
60
|
+
request.endpoint = f"{agentverse_config.url}/v1/submit"
|
61
|
+
elif request.agent_type == "proxy":
|
62
|
+
request.endpoint = f"{agentverse_config.url}/v1/proxy/submit"
|
63
|
+
|
64
|
+
if request.endpoint is None:
|
65
|
+
logger.warning(
|
66
|
+
"No endpoint provided for agent registration",
|
67
|
+
extra=registration_metadata,
|
68
|
+
)
|
69
|
+
return
|
70
|
+
|
71
|
+
logger.info(
|
72
|
+
"Registering with Almanac API",
|
73
|
+
extra=registration_metadata,
|
74
|
+
)
|
75
|
+
|
76
|
+
# create the attestation
|
77
|
+
attestation = AgentRegistrationAttestation(
|
78
|
+
agent_identifier=agent_address,
|
79
|
+
protocols=protocol_digests,
|
80
|
+
endpoints=[
|
81
|
+
AgentEndpoint(url=request.endpoint, weight=1),
|
82
|
+
],
|
83
|
+
metadata=None,
|
84
|
+
)
|
85
|
+
|
86
|
+
# sign the attestation
|
87
|
+
attestation.sign(identity)
|
88
|
+
|
89
|
+
# submit the attestation to the API
|
90
|
+
r = requests.post(
|
91
|
+
f"{almanac_api}/agents",
|
92
|
+
headers={"content-type": "application/json"},
|
93
|
+
data=attestation.model_dump_json(),
|
94
|
+
)
|
95
|
+
r.raise_for_status()
|
96
|
+
logger.debug(
|
97
|
+
"Agent attestation submitted",
|
98
|
+
extra=registration_metadata,
|
99
|
+
)
|
100
|
+
|
101
|
+
|
102
|
+
def register_in_agentverse(
|
103
|
+
request: AgentverseConnectRequest,
|
104
|
+
identity: Identity,
|
105
|
+
agent_details: Optional[AgentUpdates] = None,
|
106
|
+
*,
|
107
|
+
agentverse_config: Optional[AgentverseConfig] = None,
|
108
|
+
):
|
109
|
+
"""
|
110
|
+
Register the agent with the Agentverse API.
|
111
|
+
|
112
|
+
Args:
|
113
|
+
request (AgentverseConnectRequest): The request containing the agent details.
|
114
|
+
identity (Identity): The identity of the agent.
|
115
|
+
agent_details (Optional[AgentUpdates]): The agent details to update.
|
116
|
+
agentverse_config (AgentverseConfig): The configuration for the agentverse API
|
117
|
+
Returns:
|
118
|
+
None
|
119
|
+
"""
|
120
|
+
|
121
|
+
# API endpoints
|
122
|
+
agentverse_config = agentverse_config or AgentverseConfig()
|
123
|
+
registration_api = urllib.parse.urljoin(
|
124
|
+
agentverse_config.url, DEFAULT_REGISTRATION_PATH
|
125
|
+
)
|
126
|
+
challenge_api = urllib.parse.urljoin(agentverse_config.url, DEFAULT_CHALLENGE_PATH)
|
127
|
+
|
128
|
+
# get the agent address
|
129
|
+
agent_address = identity.address
|
130
|
+
|
131
|
+
registration_metadata = {
|
132
|
+
"registration_api": registration_api,
|
133
|
+
"challenge_api": challenge_api,
|
134
|
+
"agent_address": agent_address,
|
135
|
+
"agent_endpoint": request.endpoint or "",
|
136
|
+
"agent_type": request.agent_type,
|
137
|
+
"agent_name": agent_details.name if agent_details else "",
|
138
|
+
}
|
139
|
+
|
140
|
+
# check to see if the agent exists
|
141
|
+
r = requests.get(
|
142
|
+
f"{registration_api}/{agent_address}",
|
143
|
+
headers={
|
144
|
+
"content-type": "application/json",
|
145
|
+
"authorization": f"Bearer {request.user_token}",
|
146
|
+
},
|
147
|
+
)
|
148
|
+
|
149
|
+
# if it doesn't then create it
|
150
|
+
if r.status_code == 404:
|
151
|
+
logger.debug(
|
152
|
+
"Agent did not exist on agentverse; registering it",
|
153
|
+
extra=registration_metadata,
|
154
|
+
)
|
155
|
+
|
156
|
+
challenge_request = ChallengeRequest(address=identity.address)
|
157
|
+
logger.debug(
|
158
|
+
"Requesting mailbox access challenge",
|
159
|
+
extra=registration_metadata,
|
160
|
+
)
|
161
|
+
r = requests.post(
|
162
|
+
challenge_api,
|
163
|
+
data=challenge_request.model_dump_json(),
|
164
|
+
headers={
|
165
|
+
"content-type": "application/json",
|
166
|
+
"Authorization": f"Bearer {request.user_token}",
|
167
|
+
},
|
168
|
+
)
|
169
|
+
r.raise_for_status()
|
170
|
+
challenge = ChallengeResponse.model_validate_json(r.text)
|
171
|
+
registration_payload = RegistrationRequest(
|
172
|
+
address=identity.address,
|
173
|
+
challenge=challenge.challenge,
|
174
|
+
challenge_response=identity.sign(challenge.challenge.encode()),
|
175
|
+
endpoint=request.endpoint,
|
176
|
+
agent_type=request.agent_type,
|
177
|
+
).model_dump_json()
|
178
|
+
r = requests.post(
|
179
|
+
registration_api,
|
180
|
+
headers={
|
181
|
+
"content-type": "application/json",
|
182
|
+
"authorization": f"Bearer {request.user_token}",
|
183
|
+
},
|
184
|
+
data=registration_payload,
|
185
|
+
)
|
186
|
+
if r.status_code == 409:
|
187
|
+
logger.info(
|
188
|
+
"Agent already registered with Agentverse",
|
189
|
+
extra=registration_metadata,
|
190
|
+
)
|
191
|
+
else:
|
192
|
+
r.raise_for_status()
|
193
|
+
registration_response = RegistrationResponse.model_validate_json(r.text)
|
194
|
+
if registration_response.success:
|
195
|
+
logger.info(
|
196
|
+
f"Successfully registered as {request.agent_type} agent in Agentverse",
|
197
|
+
extra=registration_metadata,
|
198
|
+
)
|
199
|
+
if not agent_details:
|
200
|
+
logger.debug(
|
201
|
+
"No agent details provided; skipping agent update",
|
202
|
+
extra=registration_metadata,
|
203
|
+
)
|
204
|
+
return
|
205
|
+
|
206
|
+
# update the readme and the title of the agent to make it easier to find
|
207
|
+
logger.debug(
|
208
|
+
"Registering agent title and readme with Agentverse",
|
209
|
+
extra=registration_metadata,
|
210
|
+
)
|
211
|
+
update = AgentUpdates(name=agent_details.name, readme=agent_details.readme)
|
212
|
+
r = requests.put(
|
213
|
+
f"{registration_api}/{agent_address}",
|
214
|
+
headers={
|
215
|
+
"content-type": "application/json",
|
216
|
+
"authorization": f"Bearer {request.user_token}",
|
217
|
+
},
|
218
|
+
data=update.model_dump_json(),
|
219
|
+
)
|
220
|
+
r.raise_for_status()
|
221
|
+
logger.info(
|
222
|
+
"Completed registering agent with Agentverse",
|
223
|
+
extra=registration_metadata,
|
224
|
+
)
|