Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): Introducing uagents-core #597

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# The default code owners of the uagents repo.
* @jrriehl @Archento @lrahmani
* @jrriehl @Archento @lrahmani @qati

# Code owner of the integrations folder
/integrations/ @devjsc
Empty file added libs/core/README.md
Empty file.
1,551 changes: 1,551 additions & 0 deletions libs/core/poetry.lock

Large diffs are not rendered by default.

67 changes: 67 additions & 0 deletions libs/core/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
[tool.poetry]
name = "uagents-core"
version = "0.1.0"
description = "Lightweight framework for rapid agent-based development"
authors = [
"Ed FitzGerald <edward.fitzgerald@fetch.ai>",
"James Riehl <james.riehl@fetch.ai>",
"Alejandro Morales <alejandro.madrigal@fetch.ai>",
"Florian Wilde <florian.wilde@fetch.ai>",
"Attila Bagoly <attila.bagoly@fetch.ai>",
]
packages = [{include = "uagents_core"}]
license = "Apache 2.0"
readme = "README.md"

[tool.poetry.dependencies]
python = ">=3.9,<3.13"
pydantic = "~2.8"
msgpack = "^1.0.4"
bech32 = "^1.2.0"
ecdsa = "^0.19.0"
aiohttp = "^3.8.3"
requests =">=2.32.3,<3.0"
structlog = "^24.4.0"
rich = "^13.9.4"


[tool.poetry.group.dev.dependencies]
black = "^24.10.0"
aioresponses = "^0.7.4"
pytest = "^8.3.4"
pytest-asyncio = "^0.25.0"
pytest-order = "^1.3.0"
ruff = "^0.8.4"
pyright = "^1.1.391"
pre-commit = "^4.0.1"


[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

[tool.ruff]
target-version = "py310"

[tool.ruff.lint]
select = [
# pycodestyle (Errors, Warnings)
"E",
"W",
# Pyflakes
"F",
# flake8-bugbear
"B",
# flake8-simplify
"SIM",
# isort
"I",
# pep8-naming
"N",
# pylint
"PL",
]
ignore = ["PLR0913", "PLR0912", "PLR0911", "PLR2004", "PLR0915"]

[tool.ruff.lint.pycodestyle]
max-line-length = 100
Empty file.
167 changes: 167 additions & 0 deletions libs/core/uagents_core/communication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import base64
import hashlib
import json
import struct
from typing import Optional, Any
from uuid import uuid4, UUID
from dataclasses import dataclass

import requests
from pydantic import BaseModel, UUID4
import urllib.parse

from uagents_core.crypto import Identity
from uagents_core.config import DEFAULT_AGENTVERSE_URL, DEFAULT_ALMANAC_API_PATH
from uagents_core.utils import get_logger


JsonStr = str

logger = get_logger("uagents_core.communication")


class Envelope(BaseModel):
version: int
sender: str
target: str
session: UUID4
schema_digest: str
protocol_digest: str
payload: Optional[str] = None
expires: Optional[int] = None
nonce: Optional[int] = None
signature: Optional[str] = None

def encode_payload(self, value: JsonStr):
self.payload = base64.b64encode(value.encode()).decode()

def decode_payload(self) -> str:
if self.payload is None:
return ""

return base64.b64decode(self.payload).decode()

def sign(self, identity: Identity):
try:
self.signature = identity.sign_digest(self._digest())
except Exception as err:
raise ValueError(f"Failed to sign envelope: {err}") from err

def verify(self) -> bool:
if self.signature is None:
raise ValueError("Envelope signature is missing")
return Identity.verify_digest(self.sender, self._digest(), self.signature)

def _digest(self) -> bytes:
hasher = hashlib.sha256()
hasher.update(self.sender.encode())
hasher.update(self.target.encode())
hasher.update(str(self.session).encode())
hasher.update(self.schema_digest.encode())
if self.payload is not None:
hasher.update(self.payload.encode())
if self.expires is not None:
hasher.update(struct.pack(">Q", self.expires))
if self.nonce is not None:
hasher.update(struct.pack(">Q", self.nonce))
return hasher.digest()


def lookup_endpoint_for_agent(agent_address: str, *, agentverse_url: Optional[str] = None) -> str:
agentverse_url = agentverse_url or DEFAULT_AGENTVERSE_URL
almanac_api = urllib.parse.urljoin(agentverse_url, DEFAULT_ALMANAC_API_PATH)

request_meta = {
"agent_address": agent_address,
"lookup_url": almanac_api,
}
logger.debug("looking up endpoint for agent", extra=request_meta)
r = requests.get(f"{almanac_api}/agents/{agent_address}")
r.raise_for_status()

request_meta["response_status"] = r.status_code
logger.info(
"Got response looking up agent endpoint",
extra=request_meta,
)

return r.json()["endpoints"][0]["url"]


def send_message_to_agent(
sender: Identity,
target: str,
payload: Any,
protocol_digest: str,
model_digest: str,
session: UUID = uuid4(),
*,
agentverse_url: Optional[str] = None,
):
"""
Send a message to an agent.
:param session: The unique identifier for the dialogue between two agents
:param sender: The identity of the sender.
:param target: The address of the target agent.
:param protocol_digest: The digest of the protocol that is being used
:param model_digest: The digest of the model that is being used
:param payload: The payload of the message.
:return:
"""
json_payload = json.dumps(payload, separators=(",", ":"))

env = Envelope(
version=1,
sender=sender.address,
target=target,
session=session,
schema_digest=model_digest,
protocol_digest=protocol_digest,
)

env.encode_payload(json_payload)
env.sign(sender)

logger.debug("Sending message to agent", extra={"envelope": env.model_dump()})

# query the almanac to lookup the target agent
endpoint = lookup_endpoint_for_agent(target, agentverse_url=agentverse_url)

# send the envelope to the target agent
request_meta = {"agent_address": target, "agent_endpoint": endpoint}
logger.debug("Sending message to agent", extra=request_meta)
r = requests.post(
endpoint,
headers={"content-type": "application/json"},
data=env.model_dump_json(),
)
r.raise_for_status()
logger.info("Sent message to agent", extra=request_meta)


@dataclass
class AgentMessage:
# The address of the sender of the message.
sender: str
# The address of the target of the message.
target: str
# The payload of the message.
payload: Any


def parse_message_from_agent(content: JsonStr) -> AgentMessage:
"""
Parse a message from an agent.
:param content: A string containing the JSON envelope.
:return: An AgentMessage object.
"""

env = Envelope.model_validate_json(content)

if not env.verify():
raise ValueError("Invalid envelope signature")

json_payload = env.decode_payload()
payload = json.loads(json_payload)

return AgentMessage(sender=env.sender, target=env.target, payload=payload)
6 changes: 6 additions & 0 deletions libs/core/uagents_core/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@


DEFAULT_AGENTVERSE_URL = "https://agentverse.ai"
DEFAULT_ALMANAC_API_PATH = "/v1/almanac"
DEFAULT_MAILBOX_API_PATH = "/v1/agents"
DEFAULT_CHALLENGE_PATH = "/v1/auth/challenge"
142 changes: 142 additions & 0 deletions libs/core/uagents_core/crypto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import hashlib
import struct
from typing import Tuple, Union

import bech32
import ecdsa

USER_PREFIX = "user"
SHA_LENGTH = 256


def _decode_bech32(value: str) -> Tuple[str, bytes]:
prefix, data_base5 = bech32.bech32_decode(value)
data = bytes(bech32.convertbits(data_base5, 5, 8, False))
return prefix, data


def _encode_bech32(prefix: str, value: bytes) -> str:
value_base5 = bech32.convertbits(value, 8, 5)
return bech32.bech32_encode(prefix, value_base5)


def _key_derivation_hash(prefix: str, index: int) -> bytes:
hasher = hashlib.sha256()
hasher.update(prefix.encode())
assert 0 <= index < SHA_LENGTH
hasher.update(bytes([index]))
return hasher.digest()


def _seed_hash(seed: str) -> bytes:
hasher = hashlib.sha256()
hasher.update(seed.encode())
return hasher.digest()


def derive_key_from_seed(seed, prefix, index) -> bytes:
hasher = hashlib.sha256()
hasher.update(_key_derivation_hash(prefix, index))
hasher.update(_seed_hash(seed))
return hasher.digest()


def encode_length_prefixed(value: Union[str, int, bytes]) -> bytes:
if isinstance(value, str):
encoded = value.encode()
elif isinstance(value, int):
encoded = struct.pack(">Q", value)
elif isinstance(value, bytes):
encoded = value
else:
raise AssertionError()

length = len(encoded)
prefix = struct.pack(">Q", length)

return prefix + encoded


class Identity:
"""An identity is a cryptographic keypair that can be used to sign messages."""

def __init__(self, signing_key: ecdsa.SigningKey):
"""Create a new identity from a signing key."""
self._sk = signing_key

# build the address
pub_key_bytes = self._sk.get_verifying_key().to_string(encoding="compressed")
self._address = _encode_bech32("agent", pub_key_bytes)
self._pub_key = pub_key_bytes.hex()

@staticmethod
def from_seed(seed: str, index: int) -> "Identity":
"""Create a new identity from a seed and index."""
key = derive_key_from_seed(seed, "agent", index)
signing_key = ecdsa.SigningKey.from_string(
key,
curve=ecdsa.SECP256k1,
hashfunc=hashlib.sha256,
)
return Identity(signing_key)

@staticmethod
def generate() -> "Identity":
"""Generate a random new identity."""
signing_key = ecdsa.SigningKey.generate(
curve=ecdsa.SECP256k1,
hashfunc=hashlib.sha256,
)
return Identity(signing_key)

@staticmethod
def from_string(private_key_hex: str) -> "Identity":
"""Create a new identity from a private key."""
bytes_key = bytes.fromhex(private_key_hex)
signing_key = ecdsa.SigningKey.from_string(
bytes_key,
curve=ecdsa.SECP256k1,
hashfunc=hashlib.sha256,
)

return Identity(signing_key)

@property
def public_key(self) -> str:
return self._pub_key

# this is not the real private key but a signing key derived from the private key
@property
def private_key(self) -> str:
"""Property to access the private key of the identity."""
return self._sk.to_string().hex()

@property
def address(self) -> str:
"""Property to access the address of the identity."""
return self._address

def sign(self, data: bytes) -> str:
"""Sign the provided data."""
return _encode_bech32("sig", self._sk.sign(data))

def sign_digest(self, digest: bytes) -> str:
"""Sign the provided digest."""
return _encode_bech32("sig", self._sk.sign_digest(digest))

@staticmethod
def verify_digest(address: str, digest: bytes, signature: str) -> bool:
"""Verify that the signature is correct for the provided signer address and digest."""
pk_prefix, pk_data = _decode_bech32(address)
sig_prefix, sig_data = _decode_bech32(signature)

if pk_prefix != "agent":
raise ValueError("Unable to decode agent address")

if sig_prefix != "sig":
raise ValueError("Unable to decode signature")

# build the verifying key
verifying_key = ecdsa.VerifyingKey.from_string(pk_data, curve=ecdsa.SECP256k1)

return verifying_key.verify_digest(sig_data, digest)
Loading
Loading