diff --git a/examples/acp_base/helpers/acp_helper_functions.py b/examples/acp_base/helpers/acp_helper_functions.py index dfbbdaf..0e306c8 100644 --- a/examples/acp_base/helpers/acp_helper_functions.py +++ b/examples/acp_base/helpers/acp_helper_functions.py @@ -1,6 +1,6 @@ -from acp_sdk.client import VirtualsACP -from acp_sdk.configs import BASE_SEPOLIA_CONFIG -from acp_sdk.env import EnvSettings +from virtuals_acp.client import VirtualsACP +from virtuals_acp.configs import BASE_MAINNET_CONFIG +from virtuals_acp.env import EnvSettings from dotenv import load_dotenv load_dotenv(override=True) @@ -11,8 +11,14 @@ def test_helper_functions(): acp = VirtualsACP( wallet_private_key=env.WHITELISTED_WALLET_PRIVATE_KEY, agent_wallet_address=env.BUYER_AGENT_WALLET_ADDRESS, - config=BASE_SEPOLIA_CONFIG + config=BASE_MAINNET_CONFIG, + entity_id=env.BUYER_ENTITY_ID ) + + # Get agent by wallet address + agent = acp.get_agent(wallet_address=env.SELLER_AGENT_WALLET_ADDRESS) + print("\n🔵 Agent:") + print(agent) # Get active jobs active_jobs = acp.get_active_jobs(page=1, pageSize=10) diff --git a/examples/acp_base/self_evaluation/buyer.py b/examples/acp_base/self_evaluation/buyer.py index 72b11a1..cb2aa14 100644 --- a/examples/acp_base/self_evaluation/buyer.py +++ b/examples/acp_base/self_evaluation/buyer.py @@ -2,6 +2,7 @@ import time from virtuals_acp.client import VirtualsACP +from virtuals_acp.configs import BASE_MAINNET_CONFIG from virtuals_acp.job import ACPJob from virtuals_acp.models import ACPAgentSort, ACPJobPhase from virtuals_acp.env import EnvSettings @@ -47,8 +48,11 @@ def on_evaluate(job: ACPJob): agent_wallet_address=env.BUYER_AGENT_WALLET_ADDRESS, on_new_task=on_new_task, on_evaluate=on_evaluate, - entity_id=env.BUYER_ENTITY_ID + entity_id=env.BUYER_ENTITY_ID, + config=BASE_MAINNET_CONFIG ) + + print(f"ACP client: {acp.agent_address}") # Browse available agents based on a keyword and cluster name relevant_agents = acp.browse_agents( diff --git a/examples/acp_base/self_evaluation/seller.py b/examples/acp_base/self_evaluation/seller.py index 053b7bd..acee6a6 100644 --- a/examples/acp_base/self_evaluation/seller.py +++ b/examples/acp_base/self_evaluation/seller.py @@ -2,6 +2,7 @@ import json from virtuals_acp import VirtualsACP, ACPJob, ACPJobPhase +from virtuals_acp.configs import BASE_MAINNET_CONFIG from virtuals_acp.env import EnvSettings from dotenv import load_dotenv @@ -40,8 +41,11 @@ def on_new_task(job: ACPJob): wallet_private_key=env.WHITELISTED_WALLET_PRIVATE_KEY, agent_wallet_address=env.SELLER_AGENT_WALLET_ADDRESS, on_new_task=on_new_task, - entity_id=env.SELLER_ENTITY_ID + entity_id=env.SELLER_ENTITY_ID, + config=BASE_MAINNET_CONFIG ) + + print(f"ACP client: {acp_client.agent_address}") # Keep the script running to listen for new tasks while True: diff --git a/virtuals_acp/__init__.py b/virtuals_acp/__init__.py index a103e85..af1ba61 100644 --- a/virtuals_acp/__init__.py +++ b/virtuals_acp/__init__.py @@ -14,8 +14,33 @@ ) from .exceptions import ( ACPError, + ACPConnectionError, + ACPRPCConnectionError, + ACPSocketConnectionError, + ACPAuthenticationError, + ACPInvalidPrivateKeyError, + ACPInvalidAddressError, ACPApiError, + ACPApiRequestError, + ACPAgentNotFoundError, + ACPJobNotFoundError, + ACPMemoNotFoundError, ACPContractError, + ACPTransactionError, + ACPTransactionFailedError, + ACPInsufficientFundsError, + ACPGasEstimationError, + ACPContractLogParsingError, + ACPTransactionSigningError, + ACPJobError, + ACPJobCreationError, + ACPJobStateError, + ACPPaymentError, + ACPJobBudgetError, + ACPValidationError, + ACPMemoValidationError, + ACPSchemaValidationError, + ACPParameterValidationError, TransactionFailedError ) from .client import VirtualsACP @@ -35,8 +60,33 @@ "BASE_MAINNET_CONFIG", "DEFAULT_CONFIG", "ACPError", + "ACPConnectionError", + "ACPRPCConnectionError", + "ACPSocketConnectionError", + "ACPAuthenticationError", + "ACPInvalidPrivateKeyError", + "ACPInvalidAddressError", "ACPApiError", + "ACPApiRequestError", + "ACPAgentNotFoundError", + "ACPJobNotFoundError", + "ACPMemoNotFoundError", "ACPContractError", + "ACPTransactionError", + "ACPTransactionFailedError", + "ACPInsufficientFundsError", + "ACPGasEstimationError", + "ACPContractLogParsingError", + "ACPTransactionSigningError", + "ACPJobError", + "ACPJobCreationError", + "ACPJobStateError", + "ACPPaymentError", + "ACPJobBudgetError", + "ACPValidationError", + "ACPMemoValidationError", + "ACPSchemaValidationError", + "ACPParameterValidationError", "TransactionFailedError", "ACP_ABI", "ERC20_ABI", diff --git a/virtuals_acp/client.py b/virtuals_acp/client.py index 1f95f8d..85a2f31 100644 --- a/virtuals_acp/client.py +++ b/virtuals_acp/client.py @@ -14,7 +14,19 @@ import socketio import socketio.client -from virtuals_acp.exceptions import ACPApiError, ACPError +from virtuals_acp.exceptions import ( + ACPApiError, + ACPError, + ACPRPCConnectionError, + ACPSocketConnectionError, + ACPInvalidPrivateKeyError, + ACPJobCreationError, + ACPContractLogParsingError, + ACPApiRequestError, + ACPJobNotFoundError, + ACPMemoNotFoundError, + ACPAgentNotFoundError +) from virtuals_acp.models import ACPAgentSort, ACPJobPhase, MemoType, IACPAgent from virtuals_acp.contract_manager import _ACPContractManager from virtuals_acp.configs import ACPContractConfig, DEFAULT_CONFIG @@ -23,6 +35,28 @@ from virtuals_acp.memo import ACPMemo class VirtualsACP: + """ + VirtualsACP is the main client for interacting with the ACP (Agent Communication Protocol) platform. + + This client provides a comprehensive interface for: + - Discovering and browsing agents on the platform + - Creating and managing jobs between clients and service providers + - Real-time communication via WebSocket connections + - Blockchain transaction management for payments and contracts + - Job lifecycle management (initiation, negotiation, execution, evaluation) + + The client handles both on-chain interactions (via smart contracts) and off-chain + communication (via API and WebSocket connections) seamlessly. + + Attributes: + config (ACPContractConfig): Network and contract configuration + w3 (Web3): Web3 instance for blockchain interactions + entity_id (int): Unique entity identifier on the platform + contract_manager (_ACPContractManager): Handles smart contract interactions + acp_api_url (str): Base URL for ACP API endpoints + sio (socketio.Client): WebSocket client for real-time communication + """ + def __init__(self, wallet_private_key: str, entity_id: int, @@ -30,6 +64,27 @@ def __init__(self, config: ACPContractConfig = DEFAULT_CONFIG, on_new_task: Optional[Callable] = None, on_evaluate: Optional[Callable] = None): + """ + Initialize the VirtualsACP client. + + Args: + wallet_private_key (str): Private key for signing transactions (without 0x prefix) + entity_id (int): Unique entity identifier registered on the platform + agent_wallet_address (Optional[str], optional): Wallet address for the agent. + If not provided, derives from private key. Defaults to None. + config (ACPContractConfig, optional): Network configuration. + Defaults to DEFAULT_CONFIG. + on_new_task (Optional[Callable], optional): Callback function called when + receiving new job assignments. Function signature: (job: ACPJob) -> None. + Defaults to None. + on_evaluate (Optional[Callable], optional): Callback function called when + evaluation is required. Function signature: (job: ACPJob) -> Tuple[bool, str]. + Defaults to None. + + Raises: + ConnectionError: If unable to connect to the specified RPC URL + ValueError: If private key format is invalid + """ self.config = config self.w3 = Web3(Web3.HTTPProvider(config.rpc_url)) @@ -39,9 +94,12 @@ def __init__(self, self.w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0) if not self.w3.is_connected(): - raise ConnectionError(f"Failed to connect to RPC URL: {config.rpc_url}") + raise ACPRPCConnectionError(f"Failed to connect to RPC URL: {config.rpc_url}") - self.signer_account: LocalAccount = Account.from_key(wallet_private_key) + try: + self.signer_account: LocalAccount = Account.from_key(wallet_private_key) + except Exception as e: + raise ACPInvalidPrivateKeyError(f"Invalid private key format: {e}") if agent_wallet_address: self._agent_wallet_address = Web3.to_checksum_address(agent_wallet_address) @@ -163,7 +221,7 @@ def signal_handler(sig, frame): signal.signal(signal.SIGTERM, signal_handler) except Exception as e: - print(f"Failed to connect to socket server: {e}") + raise ACPSocketConnectionError(f"Failed to connect to socket server: {e}") def __del__(self): """Cleanup when the object is destroyed.""" @@ -180,6 +238,33 @@ def signer_address(self) -> str: def browse_agents(self, keyword: str, cluster: Optional[str] = None, sortBy: Optional[List[ACPAgentSort]] = None, rerank: Optional[bool] = True, top_k: Optional[int] = None) -> List[IACPAgent]: + """ + Search and browse available agents on the platform. + + This method allows you to discover agents based on various criteria including + keywords, clusters, and sorting preferences. The results can be reranked using + semantic similarity and limited to a specific number of results. + + Args: + keyword (str): Search keyword to match against agent names, descriptions, + and service offerings + cluster (Optional[str], optional): Filter agents by cluster name. + Defaults to None. + sortBy (Optional[List[ACPAgentSort]], optional): List of sorting criteria. + Available options: SUCCESS_RATE, JOB_COUNT, IS_ONLINE, etc. + Defaults to None. + rerank (Optional[bool], optional): Whether to use semantic reranking + for better relevance. Defaults to True. + top_k (Optional[int], optional): Maximum number of agents to return. + Defaults to None (no limit). + + Returns: + List[IACPAgent]: List of matching agents with their offerings and metadata + + Raises: + ACPApiError: If the API request fails + ACPError: If an unexpected error occurs during browsing + """ url = f"{self.acp_api_url}/agents?search={keyword}" if sortBy and len(sortBy) > 0: @@ -234,7 +319,7 @@ def browse_agents(self, keyword: str, cluster: Optional[str] = None, sortBy: Opt )) return agents except requests.exceptions.RequestException as e: - raise ACPApiError(f"Failed to browse agents: {e}") + raise ACPApiRequestError(f"Failed to browse agents: {e}") except Exception as e: raise ACPError(f"An unexpected error occurred while browsing agents: {e}") @@ -246,6 +331,32 @@ def initiate_job( evaluator_address: Optional[str] = None, expired_at: Optional[datetime] = None ) -> int: + """ + Create a new job request with a service provider. + + This method initiates the complete job lifecycle including: + 1. Creating the job on-chain via smart contract + 2. Setting the job budget + 3. Creating the initial service requirement memo + 4. Registering the job with the ACP API + + Args: + provider_address (str): Ethereum address of the service provider + service_requirement (Union[Dict[str, Any], str]): Description of the + required service. Can be a string or structured data matching + the provider's requirement schema. + amount (float): Payment amount in ETH for the job + evaluator_address (Optional[str], optional): Address of the evaluator. + If not provided, defaults to the client's address. Defaults to None. + expired_at (Optional[datetime], optional): Job expiration timestamp. + If not provided, defaults to 1 day from now. Defaults to None. + + Returns: + int: Unique job ID that can be used to track the job + + Raises: + Exception: If job creation fails or transaction is rejected + """ if expired_at is None: expired_at = datetime.now(timezone.utc) + timedelta(days=1) @@ -272,13 +383,13 @@ def initiate_job( ) if not contract_logs: - raise Exception("Failed to get contract logs") + raise ACPContractLogParsingError("Failed to get contract logs") try: job_id = int(Web3.to_int(hexstr=contract_logs.get("data"))) break - except (ValueError, TypeError, AttributeError): - raise Exception("Failed to parse job ID from contract logs") + except (ValueError, TypeError, AttributeError) as e: + raise ACPContractLogParsingError(f"Failed to parse job ID from contract logs: {e}") # data = response.get("data", {}) @@ -306,7 +417,7 @@ def initiate_job( raise if job_id is None or job_id == "": - raise Exception("Failed to create job") + raise ACPJobCreationError("Failed to create job") amount_in_wei = self.w3.to_wei(amount, "ether") self.contract_manager.set_budget(job_id, amount_in_wei) @@ -350,6 +461,24 @@ def respond_to_job_memo( accept: bool, reason: Optional[str] = "" ) -> str: + """ + Respond to a job memo during the negotiation phase. + + This method is typically used by service providers to accept or reject + job proposals during the negotiation phase. + + Args: + job_id (int): ID of the job being responded to + memo_id (int): ID of the specific memo being responded to + accept (bool): Whether to accept (True) or reject (False) the proposal + reason (Optional[str], optional): Reason for the decision. Defaults to "". + + Returns: + str: Transaction hash of the response + + Raises: + Exception: If the response transaction fails + """ try: data = self.contract_manager.sign_memo(memo_id, accept, reason or "") tx_hash = data.get('receipts',[])[0].get('txHash') @@ -379,6 +508,26 @@ def pay_for_job( amount: Union[float, str], reason: Optional[str] = "" ) -> Dict[str, Any]: + """ + Make payment for an accepted job. + + This method handles the complete payment process including: + 1. Approving token allowance for the contract + 2. Signing the payment memo + 3. Creating a payment confirmation memo + + Args: + job_id (int): ID of the job being paid for + memo_id (int): ID of the memo authorizing payment + amount (Union[float, str]): Payment amount in ETH + reason (Optional[str], optional): Payment reason or description. Defaults to "". + + Returns: + Dict[str, Any]: Transaction result data + + Raises: + Exception: If payment processing fails + """ amount_in_wei = self.w3.to_wei(amount, "ether") time.sleep(10) @@ -405,6 +554,19 @@ def submit_job_deliverable( job_id: int, deliverable_content: str ) -> str: + """ + Submit job deliverable to the client. + + This method is used by service providers to submit their completed work + to the client for evaluation. + + Args: + job_id (int): ID of the job for which deliverable is being submitted + deliverable_content (str): The deliverable content (URL, JSON, or text) + + Returns: + str: Transaction hash of the submission + """ data = self.contract_manager.create_memo( job_id, @@ -423,6 +585,20 @@ def evaluate_job_delivery( accept: bool, reason: Optional[str] = "" ) -> str: + """ + Evaluate a job deliverable as an evaluator. + + This method is used by evaluators to approve or reject submitted deliverables, + which determines whether the service provider gets paid. + + Args: + memo_id_of_deliverable (int): ID of the memo containing the deliverable + accept (bool): Whether to accept (True) or reject (False) the deliverable + reason (Optional[str], optional): Evaluation reason or feedback. Defaults to "". + + Returns: + str: Transaction hash of the evaluation + """ data = self.contract_manager.sign_memo(memo_id_of_deliverable, accept, reason or "") txHash = data.get('receipts',[])[0].get('transactionHash') @@ -431,6 +607,19 @@ def evaluate_job_delivery( def get_active_jobs(self, page: int = 1, pageSize: int = 10) -> List["ACPJob"]: + """ + Retrieve active jobs for the current agent. + + Args: + page (int, optional): Page number for pagination. Defaults to 1. + pageSize (int, optional): Number of jobs per page. Defaults to 10. + + Returns: + List[ACPJob]: List of active jobs + + Raises: + ACPApiError: If the API request fails + """ url = f"{self.acp_api_url}/jobs/active?pagination[page]={page}&pagination[pageSize]={pageSize}" headers = { "wallet-address": self.agent_address @@ -549,7 +738,7 @@ def get_job_by_onchain_id(self, onchain_job_id: int) -> "ACPJob": data = response.json() if data.get("error"): - raise ACPApiError(data["error"]["message"]) + raise ACPJobNotFoundError(data["error"]["message"]) memos = [] for memo in data.get("data", {}).get("memos", []): @@ -584,7 +773,7 @@ def get_memo_by_id(self, onchain_job_id: int, memo_id: int) -> 'ACPMemo': data = response.json() if data.get("error"): - raise ACPApiError(data["error"]["message"]) + raise ACPMemoNotFoundError(data["error"]["message"]) return ACPMemo( id=data.get("data", {}).get("id"), @@ -597,6 +786,19 @@ def get_memo_by_id(self, onchain_job_id: int, memo_id: int) -> 'ACPMemo': raise ACPApiError(f"Failed to get memo by ID: {e}") def get_agent(self, wallet_address: str) -> Optional[IACPAgent]: + """ + Retrieve agent information by wallet address. + + Args: + wallet_address (str): Ethereum address of the agent + + Returns: + Optional[IACPAgent]: Agent information if found, None otherwise + + Raises: + ACPApiError: If the API request fails + ACPError: If an unexpected error occurs + """ url = f"{self.acp_api_url}/agents?filters[walletAddress]={wallet_address}" try: @@ -633,7 +835,7 @@ def get_agent(self, wallet_address: str) -> Optional[IACPAgent]: ) except requests.exceptions.RequestException as e: - raise ACPApiError(f"Failed to get agent: {e}") + raise ACPApiRequestError(f"Failed to get agent: {e}") except Exception as e: raise ACPError(f"An unexpected error occurred while getting agent: {e}") diff --git a/virtuals_acp/env.py b/virtuals_acp/env.py index 987df14..1998f7b 100644 --- a/virtuals_acp/env.py +++ b/virtuals_acp/env.py @@ -1,32 +1,63 @@ from typing import Optional from pydantic_settings import BaseSettings -from pydantic import field_validator +from pydantic import field_validator, ConfigDict class EnvSettings(BaseSettings): + """Environment settings for ACP client configuration. + + Automatically loads values from .env files and environment variables. + """ + + model_config = ConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=True, + extra="ignore" + ) + + # Wallet config WHITELISTED_WALLET_PRIVATE_KEY: Optional[str] = None BUYER_AGENT_WALLET_ADDRESS: Optional[str] = None SELLER_AGENT_WALLET_ADDRESS: Optional[str] = None EVALUATOR_AGENT_WALLET_ADDRESS: Optional[str] = None + + # Twitter/Social config BUYER_GAME_TWITTER_ACCESS_TOKEN: Optional[str] = None SELLER_GAME_TWITTER_ACCESS_TOKEN: Optional[str] = None EVALUATOR_GAME_TWITTER_ACCESS_TOKEN: Optional[str] = None + + # Entity IDs BUYER_ENTITY_ID: Optional[int] = None SELLER_ENTITY_ID: Optional[int] = None EVALUATOR_ENTITY_ID: Optional[int] = None @field_validator("WHITELISTED_WALLET_PRIVATE_KEY") @classmethod - def strip_0x_prefix(cls, v: str) -> str: - if v and v.startswith("0x"): + def strip_0x_prefix(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + if v.startswith("0x"): raise ValueError("WHITELISTED_WALLET_PRIVATE_KEY must not start with '0x'. Please remove it.") - return v + return v.strip() @field_validator("BUYER_AGENT_WALLET_ADDRESS", "SELLER_AGENT_WALLET_ADDRESS", "EVALUATOR_AGENT_WALLET_ADDRESS") @classmethod - def validate_wallet_address(cls, v: str) -> str: + def validate_wallet_address(cls, v: Optional[str]) -> Optional[str]: if v is None: return None + + v = v.strip() + if not v: + return None + if not v.startswith("0x") or len(v) != 42: raise ValueError("Wallet address must start with '0x' and be 42 characters long.") - return v + + # Validate hex characters + try: + int(v, 16) + except ValueError: + raise ValueError("Wallet address must contain only valid hexadecimal characters.") + + return v.lower() \ No newline at end of file diff --git a/virtuals_acp/exceptions.py b/virtuals_acp/exceptions.py index 490def5..159a1cf 100644 --- a/virtuals_acp/exceptions.py +++ b/virtuals_acp/exceptions.py @@ -1,18 +1,148 @@ +class ACPError(Exception): + """Base exception for all ACP client errors.""" + pass +# Connection-related exceptions +class ACPConnectionError(ACPError): + """Raised when connection to external services fails.""" + pass -class ACPError(Exception): - """Base exception for ACP client errors.""" + +class ACPRPCConnectionError(ACPConnectionError): + """Raised when RPC connection to blockchain fails.""" + pass + + +class ACPSocketConnectionError(ACPConnectionError): + """Raised when WebSocket connection fails.""" + pass + + +# Authentication and authorization exceptions +class ACPAuthenticationError(ACPError): + """Raised for authentication and authorization issues.""" + pass + + +class ACPInvalidPrivateKeyError(ACPAuthenticationError): + """Raised when private key format is invalid or cannot be parsed.""" + pass + + +class ACPInvalidAddressError(ACPAuthenticationError): + """Raised when wallet address format is invalid.""" pass + +# API-related exceptions class ACPApiError(ACPError): - """Raised for errors from the ACP API.""" + """Base class for API-related errors.""" pass + +class ACPApiRequestError(ACPApiError): + """Raised when HTTP API requests fail.""" + pass + + +class ACPAgentNotFoundError(ACPApiError): + """Raised when requested agent cannot be found.""" + pass + + +class ACPJobNotFoundError(ACPApiError): + """Raised when requested job cannot be found.""" + pass + + +class ACPMemoNotFoundError(ACPApiError): + """Raised when requested memo cannot be found.""" + pass + + +# Smart contract interaction exceptions class ACPContractError(ACPError): - """Raised for errors interacting with the ACP smart contract.""" + """Base class for smart contract interaction errors.""" pass -class TransactionFailedError(ACPContractError): - """Raised when a blockchain transaction fails.""" - pass \ No newline at end of file + +class ACPTransactionError(ACPContractError): + """Base class for blockchain transaction errors.""" + pass + + +class ACPTransactionFailedError(ACPTransactionError): + """Raised when blockchain transaction fails or is rejected.""" + pass + + +class ACPInsufficientFundsError(ACPTransactionError): + """Raised when account has insufficient funds for transaction.""" + pass + + +class ACPGasEstimationError(ACPTransactionError): + """Raised when gas estimation for transaction fails.""" + pass + + +class ACPContractLogParsingError(ACPContractError): + """Raised when contract logs cannot be parsed or are missing.""" + pass + + +class ACPTransactionSigningError(ACPContractError): + """Raised when transaction signing fails.""" + pass + + +# Job lifecycle exceptions +class ACPJobError(ACPError): + """Base class for job lifecycle errors.""" + pass + + +class ACPJobCreationError(ACPJobError): + """Raised when job creation fails.""" + pass + + +class ACPJobStateError(ACPJobError): + """Raised for invalid job state transitions.""" + pass + + +class ACPPaymentError(ACPJobError): + """Raised when payment processing fails.""" + pass + + +class ACPJobBudgetError(ACPJobError): + """Raised when job budget operations fail.""" + pass + + +# Data validation exceptions +class ACPValidationError(ACPError): + """Base class for data validation errors.""" + pass + + +class ACPMemoValidationError(ACPValidationError): + """Raised when memo data validation fails.""" + pass + + +class ACPSchemaValidationError(ACPValidationError): + """Raised when requirement schema validation fails.""" + pass + + +class ACPParameterValidationError(ACPValidationError): + """Raised when function parameters are invalid.""" + pass + + +# Legacy exception for backward compatibility +TransactionFailedError = ACPTransactionFailedError \ No newline at end of file