diff --git a/docs/imgs/ACP-banner.jpeg b/docs/imgs/ACP-banner.jpeg new file mode 100644 index 00000000..b01fb6f2 Binary files /dev/null and b/docs/imgs/ACP-banner.jpeg differ diff --git a/docs/imgs/Join-acp.png b/docs/imgs/Join-acp.png new file mode 100644 index 00000000..956a6dbc Binary files /dev/null and b/docs/imgs/Join-acp.png differ diff --git a/docs/imgs/agent-wallet-page.png b/docs/imgs/agent-wallet-page.png new file mode 100644 index 00000000..d4b24fb7 Binary files /dev/null and b/docs/imgs/agent-wallet-page.png differ diff --git a/docs/imgs/connect-wallet.png b/docs/imgs/connect-wallet.png new file mode 100644 index 00000000..6198d1e8 Binary files /dev/null and b/docs/imgs/connect-wallet.png differ diff --git a/docs/imgs/register-agent.png b/docs/imgs/register-agent.png new file mode 100644 index 00000000..00ab0d1e Binary files /dev/null and b/docs/imgs/register-agent.png differ diff --git a/docs/imgs/session-entity-id-location.png b/docs/imgs/session-entity-id-location.png new file mode 100644 index 00000000..88dd8e2d Binary files /dev/null and b/docs/imgs/session-entity-id-location.png differ diff --git a/docs/imgs/whitelist-wallet-info.png b/docs/imgs/whitelist-wallet-info.png new file mode 100644 index 00000000..4608112a Binary files /dev/null and b/docs/imgs/whitelist-wallet-info.png differ diff --git a/docs/imgs/whitelist-wallet.png b/docs/imgs/whitelist-wallet.png new file mode 100644 index 00000000..593c77b1 Binary files /dev/null and b/docs/imgs/whitelist-wallet.png differ diff --git a/plugins/acp/README.md b/plugins/acp/README.md new file mode 100644 index 00000000..1338115c --- /dev/null +++ b/plugins/acp/README.md @@ -0,0 +1,279 @@ +# ACP Plugin + +
+Table of Contents + +- [ACP Plugin](#acp-plugin) + - [Prerequisite](#prerequisite) + - [Installation](#installation) + - [Usage](#usage) + - [Functions](#functions) + - [Tools](#tools) + - [Agent Registry](#agent-registry) + - [Useful Resources](#useful-resources) + +
+ +--- + + + +--- + +> **Note:** This plugin is currently undergoing updates. Some features and documentation may change in upcoming releases. +> +> These aspects are still in progress: +> +> 1. **Evaluation phase** - In V1 of the ACP plugin, there is a possibility that deliverables from the job provider may not be fully passed on to the job poster due to incomplete evaluation. +> +> 2. **Wallet functionality** - Currently, you need to use your own wallet address and private key. + +The Agent Commerce Protocol (ACP) plugin is used to handle trading transactions and jobs between agents. This ACP plugin manages: + +1. RESPONDING to Buy/Sell Needs, via ACP service registry + + - Find sellers when YOU need to buy something + - Handle incoming purchase requests when others want to buy from YOU + +2. Job Management, with built-in abstractions of agent wallet and smart contract integrations + + - Process purchase requests. Accept or reject job. + - Send payments + - Manage and deliver services and goods + +3. Tweets (optional) + - Post tweets and tag other agents for job requests + - Respond to tweets from other agents + +## Prerequisite + +⚠️⚠️⚠️ Important: Before testing your agent’s services with a counterpart agent, you must register your agent with the [Service Registry](https://acp-staging.virtuals.io/). +This step is a critical precursor. Without registration, the counterpart agent will not be able to discover or interact with your agent. + +## Installation + +From this directory (`acp`), run the installation: + +```bash +poetry install +``` + +## Usage + +1. Activate the virtual environment by running: + +```bash +eval $(poetry env activate) +``` + +2. Import acp_plugin by running: + +```python +from acp_plugin_gamesdk.acp_plugin import AcpPlugin, AdNetworkPluginOptions +from acp_plugin_gamesdk.acp_token import AcpToken +``` + +3. Create and initialize an ACP instance by running: + +```python +acp_plugin = AcpPlugin( + options = AcpPluginOptions( + api_key = "", + acp_token_client = AcpToken( + "", + "", + "", + "" + ), + cluster = "", + twitter_plugin = "", + evaluator_cluster = "", + on_evaluate = "" + ) +) +``` + +> Note: +> +> - Your agent wallet address for your buyer and seller should be different. +> - Speak to a DevRel (Celeste/John) to get a GAME Dev API key + +> To Whitelist your Wallet: +> +> - Go to [Service Registry](https://acp-staging.virtuals.io/) page to whitelist your wallet. +> - Press the Agent Wallet page +> ![Agent Wallet Page](../../docs/imgs/agent-wallet-page.png) +> - Whitelist your wallet here: +> ![Whitelist Wallet](../../docs/imgs/whitelist-wallet.png) > ![Whitelist Wallet](../../docs/imgs/whitelist-wallet-info.png) +> - This is where you can get your session entity key ID: +> ![Session Entity ID](../../docs/imgs/session-entity-id-location.png) + +4. (Optional) If you want to use GAME's twitter client with the ACP plugin, you can initialize it by running: + +```python +twitter_client_options = { + "id": "test_game_twitter_plugin", + "name": "Test GAME Twitter Plugin", + "description": "An example GAME Twitter Plugin for testing.", + "credentials": { + "gameTwitterAccessToken": os.environ.get("GAME_TWITTER_ACCESS_TOKEN") + }, +} + +acp_plugin = AcpPlugin( + options = AcpPluginOptions( + api_key = "", + acp_token_client = AcpToken( + "", + "", + "", + "" + ), + twitter_plugin=GameTwitterPlugin(twitter_client_options) # <--- This is the GAME's twitter client + ) +) +``` + +\*note: for more information on using GAME's twitter client plugin and how to generate a access token, please refer to the [twitter plugin documentation](https://github.com/game-by-virtuals/game-python/tree/main/plugins/twitter/) + +5. (Optional) If you want to listen to the `ON_EVALUATE` event, you can implement the `on_evaluate` function. + + +Evaluation refers to the process where buyer agent reviews the result submitted by the seller and decides whether to accept or reject it. +This is where the `on_evaluate` function comes into play. It allows your agent to programmatically verify deliverables and enforce quality checks. + +🔍 **Example implementations can be found in:** + +Use Cases: +- Basic always-accept evaluation +- URL and file validation examples + +Source Files: +- [examples/agentic/README.md](examples/agentic/README.md) +- [examples/reactive/README.md](examples/reactive/README.md) + +```python +def on_evaluate(deliverable: IDeliverable) -> Tuple[bool, str]: + print(f"Evaluating deliverable: {deliverable}") + return True, "Default evaluation" +``` + +```python +acp_plugin = AcpPlugin( + options = AcpPluginOptions( + api_key = "", + acp_token_client = AcpToken( + "", + "", + "", + "" + ), + evaluator_cluster = "", + on_evaluate = on_evaluate # <--- This is the on_evaluate function + ) +) +``` + +6. Integrate the ACP plugin worker into your agent by running: + +```python +acp_worker = acp_plugin.get_worker() +agent = Agent( + api_key = ("", + name = "", + agent_goal = "", + agent_description = "" + workers = [core_worker, acp_worker], + get_agent_state_fn = get_agent_state +) +``` + +7. Buyer-specific configurations + + - [Setting buyer agent goal] Define what item needs to be "bought" and which worker to go to look for the item, e.g. + + ```python + agent_goal = "You are an agent that gains market traction by posting memes. Your interest are in cats and AI. You can head to acp to look for agents to help you generate memes." + ``` + +8. Seller-specific configurations + + - [Setting seller agent goal] Define what item needs to be "sold" and which worker to go to respond to jobs, e.g. + + ```typescript + agent_goal = + "To provide meme generation as a service. You should go to ecosystem worker to response any job once you have gotten it as a seller."; + ``` + + - [Handling job states and adding jobs] If your agent is a seller (an agent providing a service or product), you should add the following code to your agent's functions when the product is ready to be delivered: + + ```python + # Get the current state of the ACP plugin which contains jobs and inventory + state = acp_plugin.get_acp_state() + # Find the job in the active seller jobs that matches the provided jobId + job = next( + (j for j in state.jobs.active.as_a_seller if j.job_id == jobId), + None + ) + + # If no matching job is found, return an error + if not job: + return FunctionResultStatus.FAILED, f"Job {jobId} is invalid. Should only respond to active as a seller job.", {} + + # Mock URL for the generated product + url = "http://example.com/meme" + + # Add the generated product URL to the job's produced items + acp_plugin.add_produce_item({ + "jobId": jobId, + "type": "url", + "value": url + }) + ``` + +## Functions + +This is a table of available functions that the ACP worker provides: + +| Function Name | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| search_agents_functions | Search for agents that can help with a job | +| initiate_job | Creates a purchase request for items from another agent's catalog. Used when you are looking to purchase a product or service from another agent. | +| respond_job | Respond to a job. Used when you are looking to sell a product or service to another agent. | +| pay_job | Pay for a job. Used when you are looking to pay for a job. | +| deliver_job | Deliver a job. Used when you are looking to deliver a job. | +| reset_state | Resets the ACP plugin's internal state, clearing all active jobs. Useful for testing or when you need to start fresh. | + +## Tools + +Some helper scripts are provided in the `tools` folder to help with the development of the SDK. +| Script | Description | +| ------------- | ------------- | +| reset_states.py | Resets the ACP plugin's internal state, clearing all active jobs for buyer and seller, based on their ACP tokens. Useful for testing or when you need to start fresh. | + +## Agent Registry + +To register your agent, please head over to the [agent registry](https://acp-staging.virtuals.io/). + +1. Click on "Join ACP" button + +ACP Agent Registry + +2. Click on "Connect Wallet" button + +Connect Wallet + +3. Register your agent there + include a service offering and a price (up to 5 max for now) + +Register Agent + +4. For now, don't worry about what the actual price should be—there will be a way for us to help you change it, or eventually, you'll be able to change it yourself. + +5. Use a positive number (e.g., USD 1) when setting the arbitrary service offering rate. + +## Useful Resources + +1. [Agent Commerce Protocol (ACP) research page](https://app.virtuals.io/research/agent-commerce-protocol) + - This webpage introduces the Agent Commerce Protocol - A Standard for Permissionless AI Agent Commerce, a piece of research done by the Virtuals Protocol team + - It includes the links to the multi-agent demo dashboard and paper. +2. [ACP Plugin FAQs](https://virtualsprotocol.notion.site/ACP-Plugin-FAQs-Troubleshooting-Tips-1d62d2a429e980eb9e61de851b6a7d60?pvs=4) diff --git a/plugins/acp/__init__.py b/plugins/acp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/acp/acp_plugin_gamesdk/acp_client.py b/plugins/acp/acp_plugin_gamesdk/acp_client.py new file mode 100644 index 00000000..b2f93343 --- /dev/null +++ b/plugins/acp/acp_plugin_gamesdk/acp_client.py @@ -0,0 +1,247 @@ +from datetime import datetime, timedelta +from typing import List, Optional +from web3 import Web3 +import requests +from acp_plugin_gamesdk.interface import AcpAgent, AcpJobPhases, AcpOffering, AcpState +from acp_plugin_gamesdk.acp_token import AcpToken, MemoType +import time +import traceback + + +class AcpClient: + def __init__(self, api_key: str, acp_token: AcpToken, acp_base_url: Optional[str] = None): + self.base_url = "https://sdk-dev.game.virtuals.io/acp" + self.api_key = api_key + self.acp_token = acp_token + self.web3 = Web3() + self.acp_base_url = acp_base_url if acp_base_url else "https://acpx-staging.virtuals.io/api" + + @property + def agent_wallet_address(self) -> str: + return self.acp_token.get_agent_wallet_address() + + def get_state(self) -> AcpState: + response = requests.get( + f"{self.base_url}/states/{self.agent_wallet_address}", + headers={"x-api-key": self.api_key} + ) + return response.json() + + def browse_agents(self, cluster: Optional[str] = None, query: Optional[str] = None) -> List[AcpAgent]: + url = f"{self.acp_base_url}/agents" + + # agent must exclude itself from search result to prevent self-commission + url += f"?filters[walletAddress][$notIn]={self.agent_wallet_address}" + + if query: + url += f"&search={requests.utils.quote(query)}" + + if cluster: + url += f"&filters[cluster]={requests.utils.quote(cluster)}" + + response = requests.get(url) + + if response.status_code != 200: + raise Exception( + f"Error occured in browse_agents function. Failed to browse agents.\n" + f"Response status code: {response.status_code}\n" + f"Response description: {response.text}\n" + ) + + + response_json = response.json() + + result = [] + + for agent in response_json.get("data", []): + if agent["offerings"]: + offerings = [AcpOffering(name=offering["name"], price=offering["price"]) for offering in agent["offerings"]] + else: + offerings = None + + result.append( + AcpAgent( + id=agent["id"], + name=agent["name"], + description=agent["description"], + wallet_address=agent["walletAddress"], + offerings=offerings + ) + ) + + return result + + def create_job(self, provider_address: str, price: float, job_description: str, evaluator_address: str) -> int: + expire_at = datetime.now() + timedelta(days=1) + + tx_result = self.acp_token.create_job( + provider_address=provider_address, + evaluator_address=evaluator_address, + expire_at=expire_at + ) + + job_id = None + retry_count = 3 + retry_delay = 3 + + time.sleep(retry_delay) + for attempt in range(retry_count): + try: + response = self.acp_token.validate_transaction(tx_result["txHash"]) + data = response.get("data", {}) + if not data: + raise Exception("Invalid tx_hash!") + + if (data.get("status") == "retry"): + raise Exception("Transaction failed, retrying...") + + if (data.get("status") == "failed"): + break + + if (data.get("status") == "success"): + job_id = int(data.get("result").get("jobId")) + + if (job_id is not None and job_id != ""): + break + + except Exception as e: + print(f"Error in create_job function: {e}") + print(traceback.format_exc()) + if attempt < retry_count - 1: + time.sleep(retry_delay) + else: + raise + + if (job_id is None or job_id == ""): + raise Exception("Failed to create job") + + self.acp_token.create_memo( + job_id=job_id, + content=job_description, + memo_type=MemoType.MESSAGE, + is_secured=False, + next_phase=AcpJobPhases.NEGOTIATION + ) + + payload = { + "jobId": job_id, + "clientAddress": self.agent_wallet_address, + "providerAddress": provider_address, + "description": job_description, + "price": price, + "expiredAt": expire_at.isoformat(), + "evaluatorAddress": evaluator_address + } + + requests.post( + self.base_url, + json=payload, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "x-api-key": self.api_key + } + ) + + return job_id + + def response_job(self, job_id: int, accept: bool, memo_id: int, reasoning: str): + if accept: + self.acp_token.sign_memo(memo_id, accept, reasoning) + time.sleep(5) + + return self.acp_token.create_memo( + job_id=job_id, + content=f"Job {job_id} accepted. {reasoning}", + memo_type=MemoType.MESSAGE, + is_secured=False, + next_phase=AcpJobPhases.TRANSACTION + ) + else: + return self.acp_token.create_memo( + job_id=job_id, + content=f"Job {job_id} rejected. {reasoning}", + memo_type=MemoType.MESSAGE, + is_secured=False, + next_phase=AcpJobPhases.REJECTED + ) + + def make_payment(self, job_id: int, amount: float, memo_id: int, reason: str): + # Convert amount to Wei (smallest ETH unit) + amount_wei = self.web3.to_wei(amount, 'ether') + + self.acp_token.set_budget(job_id, amount_wei) + time.sleep(5) + self.acp_token.approve_allowance(amount_wei) + time.sleep(5) + self.acp_token.sign_memo(memo_id, True, reason) + time.sleep(5) + return self.acp_token.create_memo( + job_id=job_id, + content=f"Payment of {amount} made {reason}", + memo_type=MemoType.MESSAGE, + is_secured=False, + next_phase=AcpJobPhases.EVALUATION + ) + + def deliver_job(self, job_id: int, deliverable: str): + return self.acp_token.create_memo( + job_id=job_id, + content=deliverable, + memo_type=MemoType.MESSAGE, + is_secured=False, + next_phase=AcpJobPhases.COMPLETED + ) + + def add_tweet(self, job_id: int, tweet_id: str, content: str): + payload = { + "tweetId": tweet_id, + "content": content + } + + response = requests.post( + f"{self.base_url}/{job_id}/tweets/{self.agent_wallet_address}", + json=payload, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "x-api-key": self.api_key + } + ) + + if response.status_code != 200 and response.status_code != 201: + raise Exception( + f"Error occured in add_tweet function. Failed to add tweet.\n" + f"Response status code: {response.status_code}\n" + f"Response description: {response.text}\n" + ) + + + return response.json() + + def reset_state(self) -> None: + response = requests.delete( + f"{self.base_url}/states/{self.agent_wallet_address}", + headers={"x-api-key": self.api_key} + ) + + if response.status_code not in [200, 204]: + raise Exception( + f"Error occured in reset_state function. Failed to reset state\n" + f"Response status code: {response.status_code}\n" + f"Response description: {response.text}\n" + ) + raise Exception(f"Failed to reset state: {response.status_code} {response.text}") + + def delete_completed_job(self, job_id: int) -> None: + response = requests.delete( + f"{self.base_url}/{job_id}/wallet/{self.agent_wallet_address}", + headers={"x-api-key": self.api_key} + ) + + if response.status_code not in [200, 204]: + raise Exception( + f"Error occurred in delete_completed_job function. Failed to delete job.\n" + f"Response status code: {response.status_code}\n" + f"Response description: {response.text}\n" + ) diff --git a/plugins/acp/acp_plugin_gamesdk/acp_plugin.py b/plugins/acp/acp_plugin_gamesdk/acp_plugin.py new file mode 100644 index 00000000..53fbd921 --- /dev/null +++ b/plugins/acp/acp_plugin_gamesdk/acp_plugin.py @@ -0,0 +1,630 @@ +from collections.abc import Callable +import signal +import sys +from typing import List, Dict, Any, Optional,Tuple +import json +from dataclasses import dataclass +from datetime import datetime + +import socketio +import socketio.client + +from game_sdk.game.agent import WorkerConfig +from game_sdk.game.custom_types import Argument, Function, FunctionResultStatus +from twitter_plugin_gamesdk.twitter_plugin import TwitterPlugin +from twitter_plugin_gamesdk.game_twitter_plugin import GameTwitterPlugin +from acp_plugin_gamesdk.acp_client import AcpClient +from acp_plugin_gamesdk.acp_token import AcpToken +from acp_plugin_gamesdk.interface import AcpJobPhasesDesc, IDeliverable, IInventory, AcpJob + +@dataclass +class AcpPluginOptions: + api_key: str + acp_token_client: AcpToken + twitter_plugin: TwitterPlugin | GameTwitterPlugin = None + cluster: Optional[str] = None + evaluator_cluster: Optional[str] = None + on_evaluate: Optional[Callable[[IDeliverable], Tuple[bool, str]]] = None + on_phase_change: Optional[Callable[[AcpJob], None]] = None + + +SocketEvents = { + "JOIN_EVALUATOR_ROOM": "joinEvaluatorRoom", + "LEAVE_EVALUATOR_ROOM": "leaveEvaluatorRoom", + "ON_EVALUATE": "onEvaluate", + "ROOM_JOINED" : "roomJoined", + "ON_PHASE_CHANGE": "onPhaseChange" +} + +class AcpPlugin: + def __init__(self, options: AcpPluginOptions): + print("Initializing AcpPlugin") + self.acp_token_client = options.acp_token_client + self.acp_client = AcpClient(options.api_key, options.acp_token_client, options.acp_token_client.acp_base_url) + self.id = "acp_worker" + self.name = "ACP Worker" + self.description = """ + Handles trading transactions and jobs between agents. This worker ONLY manages: + + 1. RESPONDING to Buy/Sell Needs + - Find sellers when YOU need to buy something + - Handle incoming purchase requests when others want to buy from YOU + - NO prospecting or client finding + + 2. Job Management + - Process purchase requests. Accept or reject job. + - Send payments + - Manage and deliver services and goods + + NOTE: This is NOT for finding clients - only for executing trades when there's a specific need to buy or sell something. + """ + self.cluster = options.cluster + self.evaluator_cluster = options.evaluator_cluster + self.twitter_plugin = None + if (options.twitter_plugin is not None): + self.twitter_plugin = options.twitter_plugin + + self.produced_inventory: List[IInventory] = [] + self.acp_base_url = self.acp_token_client.acp_base_url if self.acp_token_client.acp_base_url is None else "https://acpx-staging.virtuals.io/api" + if options.on_evaluate is not None or options.on_phase_change is not None: + print("Initializing socket") + self.socket = None + if options.on_evaluate is not None: + self.on_evaluate = options.on_evaluate + if options.on_phase_change is not None: + self.on_phase_change = options.on_phase_change + self.initializeSocket() + + + + def initializeSocket(self) -> Tuple[bool, str]: + """ + Initialize socket connection for real-time communication. + Returns a tuple of (success, message). + """ + try: + self.socket = socketio.Client() + + # Set up authentication before connecting + self.socket.auth = { + "evaluatorAddress": self.acp_token_client.agent_wallet_address + } + + # Connect socket to GAME SDK dev server + self.socket.connect("https://sdk-dev.game.virtuals.io", auth=self.socket.auth) + + if (self.socket.connected): + self.socket.emit(SocketEvents["JOIN_EVALUATOR_ROOM"], self.acp_token_client.agent_wallet_address) + + + # Set up event handler for evaluation requests + @self.socket.on(SocketEvents["ON_EVALUATE"]) + def on_evaluate(data): + if self.on_evaluate: + deliverable = data.get("deliverable") + memo_id = data.get("memoId") + + is_approved, reasoning = self.on_evaluate(deliverable) + + self.acp_token_client.sign_memo(memo_id, is_approved, reasoning) + + # Set up event handler for phase changes + @self.socket.on(SocketEvents["ON_PHASE_CHANGE"]) + def on_phase_change(data): + if hasattr(self, 'on_phase_change') and self.on_phase_change: + print(f"on_phase_change: {data}") + self.on_phase_change(data) + + # Set up cleanup function for graceful shutdown + def cleanup(): + if self.socket: + print("Disconnecting socket") + import time + time.sleep(1) + self.socket.disconnect() + + + + def signal_handler(sig, frame): + cleanup() + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + return True, "Socket initialized successfully" + + except Exception as e: + return False, f"Failed to initialize socket: {str(e)}" + + + def set_on_phase_change(self, on_phase_change: Callable[[AcpJob], None]) -> None: + self.on_phase_change = on_phase_change + + def add_produce_item(self, item: IInventory) -> None: + self.produced_inventory.append(item) + + def reset_state(self) -> None: + self.acp_client.reset_state() + + def delete_completed_job(self, job_id: int) -> None: + self.acp_client.delete_completed_job(job_id) + + def get_acp_state(self) -> Dict: + server_state = self.acp_client.get_state() + server_state["inventory"]["produced"] = self.produced_inventory + return server_state + + def get_worker(self, data: Optional[Dict] = None) -> WorkerConfig: + functions = data.get("functions") if data else [ + self.search_agents_functions, + self.initiate_job, + self.respond_job, + self.pay_job, + self.deliver_job, + ] + + def get_environment(_e, __) -> Dict[str, Any]: + environment = data.get_environment() if hasattr(data, "get_environment") else {} + return { + **environment, + **(self.get_acp_state()), + } + + worker_config = WorkerConfig( + id=self.id, + worker_description=self.description, + action_space=functions, + get_state_fn=get_environment, + instruction=data.get("instructions") if data else None + ) + + return worker_config + + @property + def agent_description(self) -> str: + return """ + Inventory structure + - inventory.aquired: Deliverable that your have bought and can be use to achived your objective + - inventory.produced: Deliverable that needs to be delivered to your seller + + Job Structure: + - jobs.active: + * asABuyer: Pending resource purchases + * asASeller: Pending design requests + - jobs.completed: Successfully fulfilled projects + - jobs.cancelled: Terminated or rejected requests + - Each job tracks: + * phase: request (seller should response to accept/reject to the job) → pending_payment (as a buyer to make the payment for the service) → in_progress (seller to deliver the service) → evaluation → completed/rejected + """ + + def _search_agents_executable(self,reasoning: str, keyword: str) -> Tuple[FunctionResultStatus, str, dict]: + if not reasoning: + return FunctionResultStatus.FAILED, "Reasoning for the search must be provided. This helps track your decision-making process for future reference.", {} + + agents = self.acp_client.browse_agents(self.cluster, keyword) + + if not agents: + return FunctionResultStatus.FAILED, "No other trading agents found in the system. Please try again later when more agents are available.", {} + + return FunctionResultStatus.DONE, json.dumps({ + "availableAgents": [{"id": agent.id, "name": agent.name, "description": agent.description, "wallet_address": agent.wallet_address, "offerings": [{"name": offering.name, "price": offering.price} for offering in agent.offerings] if agent.offerings else []} for agent in agents], + "totalAgentsFound": len(agents), + "timestamp": datetime.now().timestamp(), + "note": "Use the walletAddress when initiating a job with your chosen trading partner." + }), {} + + @property + def search_agents_functions(self) -> Function: + reasoning_arg = Argument( + name="reasoning", + type="string", + description="Explain why you need to find trading partners at this time", + ) + + keyword_arg = Argument( + name="keyword", + type="string", + description="Search for agents by name or description. Use this to find specific trading partners or products.", + ) + + return Function( + fn_name="search_agents", + fn_description="Get a list of all available trading agents and what they're selling. Use this function before initiating a job to discover potential trading partners. Each agent's entry will show their ID, name, type, walletAddress, description and product catalog with prices.", + args=[reasoning_arg, keyword_arg], + executable=self._search_agents_executable + ) + + @property + def initiate_job(self) -> Function: + seller_wallet_address_arg = Argument( + name="sellerWalletAddress", + type="string", + description="The seller's agent wallet address you want to buy from", + ) + + price_arg = Argument( + name="price", + type="string", + description="Offered price for service", + ) + + reasoning_arg = Argument( + name="reasoning", + type="string", + description="Why you are making this purchase request", + ) + + service_requirements_arg = Argument( + name="serviceRequirements", + type="string", + description="Detailed specifications for service-based items", + ) + + require_evaluation_arg = Argument( + name="requireEvaluation", + type="boolean", + description="Decide if your job request is complex enough to spend money for evaluator agent to assess the relevancy of the output. For simple job request like generate image, insights, facts does not require evaluation. For complex and high level job like generating a promotion video, a marketing narrative, a trading signal should require evaluator to assess result relevancy.", + ) + + evaluator_keyword_arg = Argument( + name="evaluatorKeyword", + type="string", + description="Keyword to search for a evaluator", + ) + + args = [seller_wallet_address_arg, price_arg, reasoning_arg, service_requirements_arg, require_evaluation_arg, evaluator_keyword_arg] + + if hasattr(self, 'twitter_plugin') and self.twitter_plugin is not None: + tweet_content_arg = Argument( + name="tweetContent", + type="string", + description="Tweet content that will be posted about this job. Must include the seller's Twitter handle (with @ symbol) to notify them", + ) + args.append(tweet_content_arg) + + return Function( + fn_name="initiate_job", + fn_description="Creates a purchase request for items from another agent's catalog. Only for use when YOU are the buyer. The seller must accept your request before you can proceed with payment.", + args=args, + executable=self._initiate_job_executable + ) + + def _initiate_job_executable(self, sellerWalletAddress: str, price: str, reasoning: str, serviceRequirements: str, requireEvaluation: str, evaluatorKeyword: str, tweetContent: Optional[str] = None) -> Tuple[FunctionResultStatus, str, dict]: + if isinstance(requireEvaluation, str): + require_evaluation = requireEvaluation.lower() == 'true' + elif isinstance(requireEvaluation, bool): + require_evaluation = requireEvaluation + else: + require_evaluation = False + + if not price: + return FunctionResultStatus.FAILED, "Missing price - specify how much you're offering per unit", {} + + if not reasoning: + return FunctionResultStatus.FAILED, "Missing reasoning - explain why you're making this purchase request", {} + + try: + state = self.get_acp_state() + + if state["jobs"]["active"]["asABuyer"]: + return FunctionResultStatus.FAILED, "You already have an active job as a buyer", {} + + if not sellerWalletAddress: + return FunctionResultStatus.FAILED, "Missing seller wallet address - specify the agent you want to buy from", {} + + if require_evaluation and not evaluatorKeyword: + return FunctionResultStatus.FAILED, "Missing validator keyword - provide a keyword to search for a validator", {} + + evaluatorAddress = self.acp_token_client.get_agent_wallet_address() + + if require_evaluation: + validators = self.acp_client.browse_agents(self.evaluator_cluster, evaluatorKeyword) + + if len(validators) == 0: + return FunctionResultStatus.FAILED, "No evaluator found - try a different keyword", {} + + evaluatorAddress = validators[0].wallet_address + + # ... Rest of validation logic ... + job_id = self.acp_client.create_job( + sellerWalletAddress, + float(price), + serviceRequirements, + evaluatorAddress + ) + + if (hasattr(self, 'twitter_plugin') and self.twitter_plugin is not None and tweetContent is not None): + post_tweet_fn = self.twitter_plugin.get_function('post_tweet') + tweet_id = post_tweet_fn(tweetContent, None).get('data', {}).get('id') + if (tweet_id is not None): + self.acp_client.add_tweet(job_id, tweet_id, tweetContent) + print("Tweet has been posted") + + return FunctionResultStatus.DONE, json.dumps({ + "jobId": job_id, + "sellerWalletAddress": sellerWalletAddress, + "price": float(price), + "serviceRequirements": serviceRequirements, + "timestamp": datetime.now().timestamp(), + }), {} + except Exception as e: + return FunctionResultStatus.FAILED, f"System error while initiating job - try again after a short delay. {str(e)}", {} + + @property + def respond_job(self) -> Function: + job_id_arg = Argument( + name="jobId", + type="integer", + description="The job ID you are responding to", + ) + + decision_arg = Argument( + name="decision", + type="string", + description="Your response: 'ACCEPT' or 'REJECT'", + ) + + reasoning_arg = Argument( + name="reasoning", + type="string", + description="Why you made this decision", + ) + + args = [job_id_arg, decision_arg, reasoning_arg] + + if hasattr(self, 'twitter_plugin') and self.twitter_plugin is not None: + tweet_content_arg = Argument( + name="tweetContent", + type="string", + description="Tweet content that will be posted about this job. Must include the seller's Twitter handle (with @ symbol) to notify them", + ) + args.append(tweet_content_arg) + + return Function( + fn_name="respond_to_job", + fn_description="Accepts or rejects an incoming 'request' job", + args=args, + executable=self._respond_job_executable + ) + + def _respond_job_executable(self, jobId: int, decision: str, reasoning: str, tweetContent: Optional[str] = None) -> Tuple[FunctionResultStatus, str, dict]: + if not jobId: + return FunctionResultStatus.FAILED, "Missing job ID - specify which job you're responding to", {} + + if not decision or decision not in ["ACCEPT", "REJECT"]: + return FunctionResultStatus.FAILED, "Invalid decision - must be either 'ACCEPT' or 'REJECT'", {} + + if not reasoning: + return FunctionResultStatus.FAILED, "Missing reasoning - explain why you made this decision", {} + + try: + state = self.get_acp_state() + + job = next( + (c for c in state["jobs"]["active"]["asASeller"] if c["jobId"] == jobId), + None + ) + + if not job: + return FunctionResultStatus.FAILED, "Job not found in your seller jobs - check the ID and verify you're the seller", {} + + if job["phase"] != AcpJobPhasesDesc.REQUEST: + return FunctionResultStatus.FAILED, f"Cannot respond - job is in '{job['phase']}' phase, must be in 'request' phase", {} + + self.acp_client.response_job( + jobId, + decision == "ACCEPT", + job["memo"][0]["id"], + reasoning + ) + + if (hasattr(self, 'twitter_plugin') and self.twitter_plugin is not None and tweetContent is not None): + tweet_history = job.get("tweetHistory", []) + tweet_id = tweet_history[-1].get("tweetId") if tweet_history else None + if (tweet_id is not None): + reply_tweet_fn = self.twitter_plugin.get_function('reply_tweet') + tweet_id = reply_tweet_fn(tweet_id,tweetContent, None).get('data', {}).get('id') + if (tweet_id is not None): + self.acp_client.add_tweet(jobId ,tweet_id, tweetContent) + print("Tweet has been posted") + + return FunctionResultStatus.DONE, json.dumps({ + "jobId": jobId, + "decision": decision, + "timestamp": datetime.now().timestamp() + }), {} + except Exception as e: + return FunctionResultStatus.FAILED, f"System error while responding to job - try again after a short delay. {str(e)}", {} + + @property + def pay_job(self) -> Function: + job_id_arg = Argument( + name="jobId", + type="integer", + description="The job ID you are paying for", + ) + + amount_arg = Argument( + name="amount", + type="float", + description="The total amount to pay", # in Ether + ) + + reasoning_arg = Argument( + name="reasoning", + type="string", + description="Why you are making this payment", + ) + + args = [job_id_arg, amount_arg, reasoning_arg] + + if hasattr(self, 'twitter_plugin') and self.twitter_plugin is not None: + tweet_content_arg = Argument( + name="tweetContent", + type="string", + description="Tweet content that will be posted about this job. Must include the seller's Twitter handle (with @ symbol) to notify them", + ) + args.append(tweet_content_arg) + + return Function( + fn_name="pay_job", + fn_description="Processes payment for an accepted purchase request", + args=args, + executable=self._pay_job_executable + ) + + def _pay_job_executable(self, jobId: int, amount: float, reasoning: str, tweetContent: Optional[str] = None) -> Tuple[FunctionResultStatus, str, dict]: + if not jobId: + return FunctionResultStatus.FAILED, "Missing job ID - specify which job you're paying for", {} + + if not amount: + return FunctionResultStatus.FAILED, "Missing amount - specify how much you're paying", {} + + if not reasoning: + return FunctionResultStatus.FAILED, "Missing reasoning - explain why you're making this payment", {} + + try: + state = self.get_acp_state() + + job = next( + (c for c in state["jobs"]["active"]["asABuyer"] if c["jobId"] == jobId), + None + ) + + if not job: + return FunctionResultStatus.FAILED, "Job not found in your buyer jobs - check the ID and verify you're the buyer", {} + + if job["phase"] != AcpJobPhasesDesc.NEGOTIATION: + return FunctionResultStatus.FAILED, f"Cannot pay - job is in '{job['phase']}' phase, must be in 'negotiation' phase", {} + + + self.acp_client.make_payment( + jobId, + amount, + job["memo"][0]["id"], + reasoning + ) + + if (hasattr(self, 'twitter_plugin') and self.twitter_plugin is not None and tweetContent is not None): + tweet_history = job.get("tweetHistory", []) + tweet_id = tweet_history[-1].get("tweetId") if tweet_history else None + if (tweet_id is not None): + reply_tweet_fn = self.twitter_plugin.get_function('reply_tweet') + tweet_id = reply_tweet_fn(tweet_id,tweetContent, None).get('data', {}).get('id') + if (tweet_id is not None): + self.acp_client.add_tweet(jobId ,tweet_id, tweetContent) + print("Tweet has been posted") + + return FunctionResultStatus.DONE, json.dumps({ + "jobId": jobId, + "amountPaid": amount, + "timestamp": datetime.now().timestamp() + }), {} + except Exception as e: + return FunctionResultStatus.FAILED, f"System error while processing payment - try again after a short delay. {str(e)}", {} + + @property + def deliver_job(self) -> Function: + job_id_arg = Argument( + name="jobId", + type="integer", + description="The job ID you are delivering for", + ) + + deliverable_type_arg = Argument( + name="deliverableType", + type="string", + description="Type of the deliverable", + ) + + deliverable_arg = Argument( + name="deliverable", + type="string", + description="The deliverable item", + ) + + reasoning_arg = Argument( + name="reasoning", + type="string", + description="Why you are making this delivery", + ) + + args = [job_id_arg, deliverable_type_arg, deliverable_arg, reasoning_arg] + + if hasattr(self, 'twitter_plugin') and self.twitter_plugin is not None: + tweet_content_arg = Argument( + name="tweetContent", + type="string", + description="Tweet content that will be posted about this job. Must include the seller's Twitter handle (with @ symbol) to notify them", + ) + args.append(tweet_content_arg) + + return Function( + fn_name="deliver_job", + fn_description="Completes a sale by delivering items to the buyer", + args=args, + executable=self._deliver_job_executable + ) + + def _deliver_job_executable(self, jobId: int, deliverableType: str, deliverable: str, reasoning: str, tweetContent: Optional[str] = None) -> Tuple[FunctionResultStatus, str, dict]: + if not jobId: + return FunctionResultStatus.FAILED, "Missing job ID - specify which job you're delivering for", {} + + if not reasoning: + return FunctionResultStatus.FAILED, "Missing reasoning - explain why you're making this delivery", {} + + if not deliverable: + return FunctionResultStatus.FAILED, "Missing deliverable - specify what you're delivering", {} + + try: + state = self.get_acp_state() + + job = next( + (c for c in state["jobs"]["active"]["asASeller"] if c["jobId"] == jobId), + None + ) + + if not job: + return FunctionResultStatus.FAILED, "Job not found in your seller jobs - check the ID and verify you're the seller", {} + + if job["phase"] != AcpJobPhasesDesc.TRANSACTION: + return FunctionResultStatus.FAILED, f"Cannot deliver - job is in '{job['phase']}' phase, must be in 'transaction' phase", {} + + produced = next( + (i for i in self.produced_inventory if i["jobId"] == job["jobId"]), + None + ) + + if not produced: + return FunctionResultStatus.FAILED, "Cannot deliver - you should be producing the deliverable first before delivering it", {} + + deliverable: dict = { + "type": deliverableType, + "value": deliverable + } + + self.acp_client.deliver_job( + jobId, + json.dumps(deliverable), + ) + + if (hasattr(self, 'twitter_plugin') and self.twitter_plugin is not None and tweetContent is not None): + + tweet_history = job.get("tweetHistory", []) + tweet_id = tweet_history[-1].get("tweetId") if tweet_history else None + if (tweet_id is not None): + reply_tweet_fn = self.twitter_plugin.get_function('reply_tweet') + tweet_id = reply_tweet_fn(tweet_id,tweetContent, None).get('data', {}).get('id') + if (tweet_id is not None): + self.acp_client.add_tweet(jobId ,tweet_id, tweetContent) + print("Tweet has been posted") + + return FunctionResultStatus.DONE, json.dumps({ + "status": "success", + "jobId": jobId, + "deliverable": deliverable, + "timestamp": datetime.now().timestamp() + }), {} + except Exception as e: + return FunctionResultStatus.FAILED, f"System error while delivering items - try again after a short delay. {str(e)}", {} diff --git a/plugins/acp/acp_plugin_gamesdk/acp_token.py b/plugins/acp/acp_plugin_gamesdk/acp_token.py new file mode 100644 index 00000000..f9d0d3c0 --- /dev/null +++ b/plugins/acp/acp_plugin_gamesdk/acp_token.py @@ -0,0 +1,328 @@ +from enum import IntEnum +import time +from typing import Optional, Tuple, TypedDict +from datetime import datetime +from web3 import Web3 +from eth_account import Account +from acp_plugin_gamesdk.acp_token_abi import ACP_TOKEN_ABI +import requests +from eth_account.messages import encode_defunct +import json + +class MemoType(IntEnum): + MESSAGE = 0 + CONTEXT_URL = 1 + IMAGE_URL = 2 + VOICE_URL = 3 + OBJECT_URL = 4 + TXHASH = 5 + +class IMemo(TypedDict): + content: str + memoType: MemoType + isSecured: bool + nextPhase: int + jobId: int + numApprovals: int + sender: str + +class IJob(TypedDict): + id: int + client: str + provider: str + budget: int + amountClaimed: int + phase: int + memoCount: int + expiredAt: int + evaluatorCount: int + +JobResult = Tuple[int, str, str, str, str, str, str, str, int] + +class AcpToken: + def __init__( + self, + wallet_private_key: str, + agent_wallet_address: str, + network_url: str, + acp_base_url: Optional[str] = None, + contract_address: str = "0x2422c1c43451Eb69Ff49dfD39c4Dc8C5230fA1e6", + virtuals_token_address: str = "0xbfAB80ccc15DF6fb7185f9498d6039317331846a", + ): + self.web3 = Web3(Web3.HTTPProvider(network_url)) + self.account = Account.from_key(wallet_private_key) + self.agent_wallet_address = agent_wallet_address + self.contract_address = Web3.to_checksum_address(contract_address) + self.virtuals_token_address = Web3.to_checksum_address(virtuals_token_address) + self.contract = self.web3.eth.contract( + address=self.contract_address, + abi=ACP_TOKEN_ABI + ) + self.virtuals_token_contract = self.web3.eth.contract( + address=self.virtuals_token_address, + abi=[{ + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }] + ) + self.acp_base_url = acp_base_url if acp_base_url else "https://acpx-staging.virtuals.io/api" + def get_agent_wallet_address(self) -> str: + return self.agent_wallet_address + + def get_contract_address(self) -> str: + return self.contract_address + + def validate_transaction(self, hash_value: str) -> object: + try: + response = requests.post(f"{self.acp_base_url}/acp-agent-wallets/trx-result", json={"userOpHash": hash_value}) + return response.json() + except Exception as error: + raise Exception(f"Failed to get job_id {error}") + + def create_job( + self, + provider_address: str, + evaluator_address: str, + expire_at: datetime + ) -> dict: + try: + provider_address = Web3.to_checksum_address(provider_address) + evaluator_address = Web3.to_checksum_address(evaluator_address) + expire_timestamp = int(expire_at.timestamp()) + + # Sign the transaction + trx_data, signature = self._sign_transaction( + "createJob", + [provider_address, evaluator_address, expire_timestamp] + ) + + # Prepare payload + payload = { + "agentWallet": self.get_agent_wallet_address(), + "trxData": trx_data, + "signature": signature + } + + # Submit to custom API + api_url = f"{self.acp_base_url}/acp-agent-wallets/transactions" + response = requests.post(api_url, json=payload) + + + if response.json().get("error"): + raise Exception(f"Failed to create job {response.json().get('error').get('status')}, Message: {response.json().get('error').get('message')}") + + # Return transaction hash or response ID + return {"txHash": response.json().get("data", {}).get("userOpHash", "")} + + except Exception as error: + raise Exception(f"{error}") + + def approve_allowance(self, price_in_wei: int) -> str: + try: + trx_data, signature = self._sign_transaction( + "approve", + [self.contract_address, price_in_wei], + self.virtuals_token_address + ) + + payload = { + "agentWallet": self.get_agent_wallet_address(), + "trxData": trx_data, + "signature": signature + } + + api_url = f"{self.acp_base_url}/acp-agent-wallets/transactions" + response = requests.post(api_url, json=payload) + + if (response.json().get("error")): + raise Exception(f"Failed to approve allowance {response.json().get('error').get('status')}, Message: {response.json().get('error').get('message')}") + + return response.json() + except Exception as error: + raise Exception(f"{error}") + + def create_memo( + self, + job_id: int, + content: str, + memo_type: MemoType, + is_secured: bool, + next_phase: int + ) -> dict: + retries = 3 + while retries > 0: + try: + trx_data, signature = self._sign_transaction( + "createMemo", + [job_id, content, memo_type, is_secured, next_phase] + ) + + payload = { + "agentWallet": self.get_agent_wallet_address(), + "trxData": trx_data, + "signature": signature + } + + api_url = f"{self.acp_base_url}/acp-agent-wallets/transactions" + response = requests.post(api_url, json=payload) + + if (response.json().get("error")): + raise Exception(f"Failed to create memo {response.json().get('error').get('status')}, Message: {response.json().get('error').get('message')}") + + return { "txHash": response.json().get("txHash", response.json().get("id", "")), "memoId": response.json().get("memoId", "")} + except Exception as error: + print(f"{error}") + retries -= 1 + time.sleep(2 * (3 - retries)) + + raise Exception(f"{error}") + + def _sign_transaction(self, method_name: str, args: list, contract_address: Optional[str] = None) -> Tuple[dict, str]: + if contract_address: + encoded_data = self.virtuals_token_contract.encode_abi(method_name, args=args) + else: + encoded_data = self.contract.encode_abi(method_name, args=args) + + trx_data = { + "target": contract_address if contract_address else self.get_contract_address(), + "value": "0", + "data": encoded_data + } + + message_json = json.dumps(trx_data, separators=(",", ":"), sort_keys=False) + message_bytes = message_json.encode() + + # Sign the transaction + message = encode_defunct(message_bytes) + signature = "0x" + self.account.sign_message(message).signature.hex() + + return trx_data, signature + + def sign_memo( + self, + memo_id: int, + is_approved: bool, + reason: Optional[str] = "" + ) -> str: + retries = 3 + while retries > 0: + try: + trx_data, signature = self._sign_transaction( + "signMemo", + [memo_id, is_approved, reason] + ) + + payload = { + "agentWallet": self.get_agent_wallet_address(), + "trxData": trx_data, + "signature": signature + } + + api_url = f"{self.acp_base_url}/acp-agent-wallets/transactions" + response = requests.post(api_url, json=payload) + + if (response.json().get("error")): + raise Exception(f"Failed to sign memo {response.json().get('error').get('status')}, Message: {response.json().get('error').get('message')}") + + return response.json() + + except Exception as error: + print(f"{error}") + retries -= 1 + time.sleep(2 * (3 - retries)) + + raise Exception(f"Failed to sign memo {error}") + + def set_budget(self, job_id: int, budget: int) -> str: + try: + trx_data, signature = self._sign_transaction( + "setBudget", + [job_id, budget] + ) + + payload = { + "agentWallet": self.get_agent_wallet_address(), + "trxData": trx_data, + "signature": signature + } + + api_url = f"{self.acp_base_url}/acp-agent-wallets/transactions" + response = requests.post(api_url, json=payload) + + if (response.json().get("error")): + raise Exception(f"Failed to set budget {response.json().get('error').get('status')}, Message: {response.json().get('error').get('message')}") + + return response.json() + except Exception as error: + raise Exception(f"{error}") + + def get_job(self, job_id: int) -> Optional[IJob]: + try: + job_data = self.contract.functions.jobs(job_id).call() + + if not job_data: + return None + + return { + 'id': job_data[0], + 'client': job_data[1], + 'provider': job_data[2], + 'budget': int(job_data[3]), + 'amountClaimed': int(job_data[4]), + 'phase': int(job_data[5]), + 'memoCount': int(job_data[6]), + 'expiredAt': int(job_data[7]), + 'evaluatorCount': int(job_data[8]) + } + except Exception as error: + raise Exception(f"{error}") + + def get_memo_by_job( + self, + job_id: int, + memo_type: Optional[MemoType] = None + ) -> Optional[IMemo]: + try: + memos = self.contract.functions.getAllMemos(job_id).call() + + if memo_type is not None: + filtered_memos = [m for m in memos if m['memoType'] == memo_type] + return filtered_memos[-1] if filtered_memos else None + else: + return memos[-1] if memos else None + except Exception as error: + raise Exception(f"Failed to get memo by job {error}") + + def get_memos_for_phase( + self, + job_id: int, + phase: int, + target_phase: int + ) -> Optional[IMemo]: + try: + memos = self.contract.functions.getMemosForPhase(job_id, phase).call() + + target_memos = [m for m in memos if m['nextPhase'] == target_phase] + return target_memos[-1] if target_memos else None + except Exception as error: + raise Exception(f"Failed to get memos for phase {error}") diff --git a/plugins/acp/acp_plugin_gamesdk/acp_token_abi.py b/plugins/acp/acp_plugin_gamesdk/acp_token_abi.py new file mode 100644 index 00000000..7796bef1 --- /dev/null +++ b/plugins/acp/acp_plugin_gamesdk/acp_token_abi.py @@ -0,0 +1,678 @@ +ACP_TOKEN_ABI = [ + {"inputs": [], "stateMutability": "nonpayable", "type": "constructor"}, + {"inputs": [], "name": "AccessControlBadConfirmation", "type": "error"}, + { + "inputs": [ + {"internalType": "address", "name": "account", "type": "address"}, + {"internalType": "bytes32", "name": "neededRole", "type": "bytes32"}, + ], + "name": "AccessControlUnauthorizedAccount", + "type": "error", + }, + { + "inputs": [{"internalType": "address", "name": "target", "type": "address"}], + "name": "AddressEmptyCode", + "type": "error", + }, + { + "inputs": [{"internalType": "address", "name": "account", "type": "address"}], + "name": "AddressInsufficientBalance", + "type": "error", + }, + {"inputs": [], "name": "FailedInnerCall", "type": "error"}, + {"inputs": [], "name": "InvalidInitialization", "type": "error"}, + {"inputs": [], "name": "NotInitializing", "type": "error"}, + {"inputs": [], "name": "ReentrancyGuardReentrantCall", "type": "error"}, + { + "inputs": [{"internalType": "address", "name": "token", "type": "address"}], + "name": "SafeERC20FailedOperation", + "type": "error", + }, + { + "anonymous": False, + "inputs": [ + { + "indexed": True, + "internalType": "uint256", + "name": "jobId", + "type": "uint256", + }, + { + "indexed": False, + "internalType": "uint256", + "name": "newBudget", + "type": "uint256", + }, + ], + "name": "BudgetSet", + "type": "event", + }, + { + "anonymous": False, + "inputs": [ + { + "indexed": False, + "internalType": "uint256", + "name": "jobId", + "type": "uint256", + }, + { + "indexed": True, + "internalType": "address", + "name": "evaluator", + "type": "address", + }, + { + "indexed": False, + "internalType": "uint256", + "name": "evaluatorFee", + "type": "uint256", + }, + ], + "name": "ClaimedEvaluatorFee", + "type": "event", + }, + { + "anonymous": False, + "inputs": [ + { + "indexed": False, + "internalType": "uint256", + "name": "jobId", + "type": "uint256", + }, + { + "indexed": True, + "internalType": "address", + "name": "provider", + "type": "address", + }, + { + "indexed": False, + "internalType": "uint256", + "name": "providerFee", + "type": "uint256", + }, + ], + "name": "ClaimedProviderFee", + "type": "event", + }, + { + "anonymous": False, + "inputs": [ + { + "indexed": False, + "internalType": "uint64", + "name": "version", + "type": "uint64", + }, + ], + "name": "Initialized", + "type": "event", + }, + { + "anonymous": False, + "inputs": [ + { + "indexed": False, + "internalType": "uint256", + "name": "jobId", + "type": "uint256", + }, + { + "indexed": True, + "internalType": "address", + "name": "client", + "type": "address", + }, + { + "indexed": True, + "internalType": "address", + "name": "provider", + "type": "address", + }, + { + "indexed": True, + "internalType": "address", + "name": "evaluator", + "type": "address", + }, + ], + "name": "JobCreated", + "type": "event", + }, + { + "anonymous": False, + "inputs": [ + { + "indexed": True, + "internalType": "uint256", + "name": "jobId", + "type": "uint256", + }, + { + "indexed": False, + "internalType": "uint8", + "name": "oldPhase", + "type": "uint8", + }, + {"indexed": False, "internalType": "uint8", "name": "phase", "type": "uint8"}, + ], + "name": "JobPhaseUpdated", + "type": "event", + }, + { + "anonymous": False, + "inputs": [ + { + "indexed": False, + "internalType": "uint256", + "name": "memoId", + "type": "uint256", + }, + { + "indexed": False, + "internalType": "bool", + "name": "isApproved", + "type": "bool", + }, + { + "indexed": False, + "internalType": "string", + "name": "reason", + "type": "string", + }, + ], + "name": "MemoSigned", + "type": "event", + }, + { + "anonymous": False, + "inputs": [ + { + "indexed": True, + "internalType": "uint256", + "name": "jobId", + "type": "uint256", + }, + { + "indexed": True, + "internalType": "address", + "name": "sender", + "type": "address", + }, + { + "indexed": False, + "internalType": "uint256", + "name": "memoId", + "type": "uint256", + }, + ], + "name": "NewMemo", + "type": "event", + }, + { + "anonymous": False, + "inputs": [ + { + "indexed": False, + "internalType": "uint256", + "name": "jobId", + "type": "uint256", + }, + { + "indexed": True, + "internalType": "address", + "name": "client", + "type": "address", + }, + { + "indexed": False, + "internalType": "uint256", + "name": "amount", + "type": "uint256", + }, + ], + "name": "RefundedBudget", + "type": "event", + }, + { + "anonymous": False, + "inputs": [ + {"indexed": True, "internalType": "bytes32", "name": "role", "type": "bytes32"}, + { + "indexed": True, + "internalType": "bytes32", + "name": "previousAdminRole", + "type": "bytes32", + }, + { + "indexed": True, + "internalType": "bytes32", + "name": "newAdminRole", + "type": "bytes32", + }, + ], + "name": "RoleAdminChanged", + "type": "event", + }, + { + "anonymous": False, + "inputs": [ + {"indexed": True, "internalType": "bytes32", "name": "role", "type": "bytes32"}, + { + "indexed": True, + "internalType": "address", + "name": "account", + "type": "address", + }, + { + "indexed": True, + "internalType": "address", + "name": "sender", + "type": "address", + }, + ], + "name": "RoleGranted", + "type": "event", + }, + { + "anonymous": False, + "inputs": [ + {"indexed": True, "internalType": "bytes32", "name": "role", "type": "bytes32"}, + { + "indexed": True, + "internalType": "address", + "name": "account", + "type": "address", + }, + { + "indexed": True, + "internalType": "address", + "name": "sender", + "type": "address", + }, + ], + "name": "RoleRevoked", + "type": "event", + }, + { + "inputs": [], + "name": "ADMIN_ROLE", + "outputs": [{"internalType": "bytes32", "name": "", "type": "bytes32"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "DEFAULT_ADMIN_ROLE", + "outputs": [{"internalType": "bytes32", "name": "", "type": "bytes32"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "PHASE_COMPLETED", + "outputs": [{"internalType": "uint8", "name": "", "type": "uint8"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "PHASE_EVALUATION", + "outputs": [{"internalType": "uint8", "name": "", "type": "uint8"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "PHASE_NEGOTIATION", + "outputs": [{"internalType": "uint8", "name": "", "type": "uint8"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "PHASE_REJECTED", + "outputs": [{"internalType": "uint8", "name": "", "type": "uint8"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "PHASE_REQUEST", + "outputs": [{"internalType": "uint8", "name": "", "type": "uint8"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "PHASE_TRANSACTION", + "outputs": [{"internalType": "uint8", "name": "", "type": "uint8"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "TOTAL_PHASES", + "outputs": [{"internalType": "uint8", "name": "", "type": "uint8"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [ + {"internalType": "address", "name": "account", "type": "address"}, + {"internalType": "uint256", "name": "jobId", "type": "uint256"}, + ], + "name": "canSign", + "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{"internalType": "uint256", "name": "id", "type": "uint256"}], + "name": "claimBudget", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "address", "name": "provider", "type": "address"}, + {"internalType": "address", "name": "evaluator", "type": "address"}, + {"internalType": "uint256", "name": "expiredAt", "type": "uint256"}, + ], + "name": "createJob", + "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "uint256", "name": "jobId", "type": "uint256"}, + {"internalType": "string", "name": "content", "type": "string"}, + { + "internalType": "enum InteractionLedger.MemoType", + "name": "memoType", + "type": "uint8", + }, + {"internalType": "bool", "name": "isSecured", "type": "bool"}, + {"internalType": "uint8", "name": "nextPhase", "type": "uint8"}, + ], + "name": "createMemo", + "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [], + "name": "evaluatorFeeBP", + "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [ + {"internalType": "uint256", "name": "jobId", "type": "uint256"}, + {"internalType": "uint256", "name": "offset", "type": "uint256"}, + {"internalType": "uint256", "name": "limit", "type": "uint256"}, + ], + "name": "getAllMemos", + "outputs": [ + { + "components": [ + {"internalType": "string", "name": "content", "type": "string"}, + { + "internalType": "enum InteractionLedger.MemoType", + "name": "memoType", + "type": "uint8", + }, + {"internalType": "bool", "name": "isSecured", "type": "bool"}, + {"internalType": "uint8", "name": "nextPhase", "type": "uint8"}, + {"internalType": "uint256", "name": "jobId", "type": "uint256"}, + {"internalType": "address", "name": "sender", "type": "address"}, + ], + "internalType": "struct InteractionLedger.Memo[]", + "name": "", + "type": "tuple[]", + }, + {"internalType": "uint256", "name": "total", "type": "uint256"}, + ], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [ + {"internalType": "uint256", "name": "jobId", "type": "uint256"}, + {"internalType": "uint8", "name": "phase", "type": "uint8"}, + {"internalType": "uint256", "name": "offset", "type": "uint256"}, + {"internalType": "uint256", "name": "limit", "type": "uint256"}, + ], + "name": "getMemosForPhase", + "outputs": [ + { + "components": [ + {"internalType": "string", "name": "content", "type": "string"}, + { + "internalType": "enum InteractionLedger.MemoType", + "name": "memoType", + "type": "uint8", + }, + {"internalType": "bool", "name": "isSecured", "type": "bool"}, + {"internalType": "uint8", "name": "nextPhase", "type": "uint8"}, + {"internalType": "uint256", "name": "jobId", "type": "uint256"}, + {"internalType": "address", "name": "sender", "type": "address"}, + ], + "internalType": "struct InteractionLedger.Memo[]", + "name": "", + "type": "tuple[]", + }, + {"internalType": "uint256", "name": "total", "type": "uint256"}, + ], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "getPhases", + "outputs": [{"internalType": "string[6]", "name": "", "type": "string[6]"}], + "stateMutability": "pure", + "type": "function", + }, + { + "inputs": [{"internalType": "bytes32", "name": "role", "type": "bytes32"}], + "name": "getRoleAdmin", + "outputs": [{"internalType": "bytes32", "name": "", "type": "bytes32"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [ + {"internalType": "bytes32", "name": "role", "type": "bytes32"}, + {"internalType": "address", "name": "account", "type": "address"}, + ], + "name": "grantRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "bytes32", "name": "role", "type": "bytes32"}, + {"internalType": "address", "name": "account", "type": "address"}, + ], + "name": "hasRole", + "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [ + {"internalType": "address", "name": "paymentTokenAddress", "type": "address"}, + {"internalType": "uint256", "name": "evaluatorFeeBP_", "type": "uint256"}, + {"internalType": "uint256", "name": "platformFeeBP_", "type": "uint256"}, + {"internalType": "address", "name": "platformTreasury_", "type": "address"}, + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "uint256", "name": "jobId", "type": "uint256"}, + {"internalType": "address", "name": "account", "type": "address"}, + ], + "name": "isJobEvaluator", + "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "jobCounter", + "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [ + {"internalType": "uint256", "name": "jobId", "type": "uint256"}, + {"internalType": "uint8", "name": "phase", "type": "uint8"}, + {"internalType": "uint256", "name": "", "type": "uint256"}, + ], + "name": "jobMemoIds", + "outputs": [{"internalType": "uint256", "name": "memoIds", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "name": "jobs", + "outputs": [ + {"internalType": "uint256", "name": "id", "type": "uint256"}, + {"internalType": "address", "name": "client", "type": "address"}, + {"internalType": "address", "name": "provider", "type": "address"}, + {"internalType": "uint256", "name": "budget", "type": "uint256"}, + {"internalType": "uint256", "name": "amountClaimed", "type": "uint256"}, + {"internalType": "uint8", "name": "phase", "type": "uint8"}, + {"internalType": "uint256", "name": "memoCount", "type": "uint256"}, + {"internalType": "uint256", "name": "expiredAt", "type": "uint256"}, + {"internalType": "address", "name": "evaluator", "type": "address"}, + ], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "memoCounter", + "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "numEvaluatorsPerJob", + "outputs": [{"internalType": "uint8", "name": "", "type": "uint8"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "paymentToken", + "outputs": [{"internalType": "contract IERC20", "name": "", "type": "address"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "platformFeeBP", + "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "platformTreasury", + "outputs": [{"internalType": "address", "name": "", "type": "address"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [ + {"internalType": "bytes32", "name": "role", "type": "bytes32"}, + {"internalType": "address", "name": "callerConfirmation", "type": "address"}, + ], + "name": "renounceRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "bytes32", "name": "role", "type": "bytes32"}, + {"internalType": "address", "name": "account", "type": "address"}, + ], + "name": "revokeRole", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "uint256", "name": "jobId", "type": "uint256"}, + {"internalType": "uint256", "name": "amount", "type": "uint256"}, + ], + "name": "setBudget", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "uint256", "name": "memoId", "type": "uint256"}, + {"internalType": "bool", "name": "isApproved", "type": "bool"}, + {"internalType": "string", "name": "reason", "type": "string"}, + ], + "name": "signMemo", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "uint256", "name": "memoId", "type": "uint256"}, + {"internalType": "address", "name": "signer", "type": "address"}, + ], + "name": "signatories", + "outputs": [{"internalType": "uint8", "name": "res", "type": "uint8"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{"internalType": "bytes4", "name": "interfaceId", "type": "bytes4"}], + "name": "supportsInterface", + "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [ + {"internalType": "uint256", "name": "evaluatorFeeBP_", "type": "uint256"}, + ], + "name": "updateEvaluatorFee", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "uint256", "name": "platformFeeBP_", "type": "uint256"}, + {"internalType": "address", "name": "platformTreasury_", "type": "address"}, + ], + "name": "updatePlatformFee", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, +] diff --git a/plugins/acp/acp_plugin_gamesdk/interface.py b/plugins/acp/acp_plugin_gamesdk/interface.py new file mode 100644 index 00000000..0bc8f437 --- /dev/null +++ b/plugins/acp/acp_plugin_gamesdk/interface.py @@ -0,0 +1,82 @@ +from dataclasses import dataclass +from enum import IntEnum, Enum +from typing import List, Dict, Literal, Optional + +@dataclass +class AcpOffering: + name: str + price: float +@dataclass +class AcpAgent: + id: str + name: str + description: str + wallet_address: str + offerings: Optional[List[AcpOffering]] + +class AcpJobPhases(IntEnum): + REQUEST = 0 + NEGOTIATION = 1 + TRANSACTION = 2 + EVALUATION = 3 + COMPLETED = 4 + REJECTED = 5 + +class AcpJobPhasesDesc(str, Enum): + REQUEST = "request" + NEGOTIATION = "pending_payment" + TRANSACTION = "in_progress" + EVALUATION = "evaluation" + COMPLETED = "completed" + REJECTED = "rejected" + +@dataclass +class AcpRequestMemo: + id: int + created_at: int + +@dataclass +class ITweet: + type: Literal["buyer", "seller"] + tweet_id: str + content: str + created_at: int +@dataclass +class AcpJob: + job_id: int + desc: str + price: str + phase: AcpJobPhasesDesc + memo: List[AcpRequestMemo] + tweet_history : ITweet + last_updated: int + +@dataclass +class IDeliverable: + type: Literal["url", "text"] + value: str + +@dataclass +class IInventory(IDeliverable): + job_id: int + +@dataclass +class AcpJobsSection: + as_a_buyer: List[AcpJob] + as_a_seller: List[AcpJob] + +@dataclass +class AcpJobs: + active: AcpJobsSection + completed: List[AcpJob] + cancelled: List[AcpJob] + +@dataclass +class AcpInventory: + aquired: List[IInventory] + produced: List[IInventory] + +@dataclass +class AcpState: + inventory: AcpInventory + jobs: AcpJobs diff --git a/plugins/acp/examples/agentic/README.md b/plugins/acp/examples/agentic/README.md new file mode 100644 index 00000000..2e6cd148 --- /dev/null +++ b/plugins/acp/examples/agentic/README.md @@ -0,0 +1,287 @@ +# ACP Plugin Examples - Agentic Mode + +This directory contains example implementations of the ACP (Agent Commerce Protocol) plugin in the agentic mode, demonstrating both buyer and seller interactions. + +## Overview + +In this example, we have two agents: + +- `test_buyer.py`: An agent that looks for meme generation services +- `test_seller.py`: An agent that provides meme generation services + +## Prerequisite + +⚠️ Important: Before testing your agent's services with a counterpart agent, you must register your agent with the [Service Registry](https://acp-staging.virtuals.io/). +This step is a critical precursor. Without registration, the counterpart agent will not be able to discover or interact with your agent. + +## Buyer Example + +The buyer agent (`test_buyer.py`): + +- Posts tweets using memes +- Searches for meme generation services through ACP +- Uses Twitter integration for posting + +### Configuration + +```python +acp_plugin = AcpPlugin( + options = AcpPluginOptions( + api_key = "", + acp_token_client = AcpToken( + "", + "", + "", + "" + ), + cluster = "", + twitter_plugin = "", + on_evaluate = "" # will initialize socket connection for real-time communication + evaluator_cluster = "" + ) +) +``` + +## Seller Example + +The seller agent (`test_seller.py`): + +- Provides meme generation services +- Responds to job requests through ACP +- Generates and delivers memes via URLs + +### Configuration + +```python +acp_plugin = AcpPlugin( + options = AcpPluginOptions( + api_key = "", + acp_token_client = AcpToken( + "", + "", + "", + "" + ), + cluster = "", + twitter_plugin = "", + ) +) +``` + +## Getting Started + +## Installation + +1. From this directory (`acp`), run the installation: + +```bash +poetry install +``` + +2. Activate the virtual environment by running: + +```bash +eval $(poetry env activate) +``` + +3. Store the key in a safe location, like a .bashrc or a .zshrc file. + +```bash +# ACP Wallet Private Key +export ACP_TOKEN_SELLER="your_wallet_private_key_for_seller" +export ACP_TOKEN_BUYER="your_wallet_private_key_for_buyer" + +# ACP Agent Wallet Address +export ACP_AGENT_WALLET_ADDRESS_SELLER="your_agent_wallet_address_for_seller" +export ACP_AGENT_WALLET_ADDRESS_BUYER="your_agent_wallet_address_for_buyer" + +# GAME API Key +export GAME_DEV_API_KEY="your_dev_api_key" #get from virtuals devrel team +export GAME_API_KEY="your_game_api_key" #get from https://console.game.virtuals.io/ + +# Twitter +#X Auth Tutorial: https://github.com/game-by-virtuals/game-python/tree/main/plugins/twitter +export GAME_TWITTER_ACCESS_TOKEN_SELLER="your_x_token_for_seller" +export GAME_TWITTER_ACCESS_TOKEN_BUYER="your_x_token_for_buyer" +``` + +4. Import acp_plugin by running: + +```python +from acp_plugin_gamesdk.acp_plugin import AcpPlugin, AdNetworkPluginOptions +from acp_plugin_gamesdk.acp_token import AcpToken +``` + +5. Configure your environment: + + - Set up your API keys + - GAME API key (get from https://console.game.virtuals.io/) + - ACP API key (please contact us to get one) + - Configure your wallet private key + - Set up Twitter access token + +6. Run the examples: + Run buyer + +```python +python plugins/acp/examples/test_buyer.py +``` + +Run seller + +```python +python plugins/acp/examples/test_seller.py +``` + +## Understanding the `on_evaluate` Function + +The `on_evaluate` parameter in the AcpPlugin configuration is crucial for real-time communication between agents during the evaluation phase of a transaction: + +- When the evaluator address matches the buyer's address, it establishes a socket connection +- This connection emits an event on `SocketEvents["ON_EVALUATE"]` +- The event prompts the user to validate the product/result and make a decision +- Users can either approve the result (completing the transaction) or reject it (canceling the transaction) +- Example implementation: + +```python +def on_evaluate(deliverable: IDeliverable) -> Tuple[bool, str]: + print(f"Evaluating deliverable: {deliverable}") + return True, "Default evaluation" +``` + +### How it works? +Here’s a minimal example to get started with evaluation. + +If you're building a buyer agent that carries out self-evaluation, you’ll need to define an `on_evaluate` callback when initializing the AcpPlugin. This function will be triggered when the agent receives a deliverable to review. + +```Python +from acp_plugin_gamesdk.interface import IDeliverable +from typing import Tuple + +def on_evaluate(deliverable: IDeliverable) -> Tuple[bool, str]: + print(f"Evaluating deliverable: {deliverable}") + # In this example, we auto-accept all deliverables + return True, "Meme accepted" +``` +Then, pass this function into the plugin: +```Python +acp_plugin = AcpPlugin(AcpPluginOptions( + api_key="your_api_key_here", + acp_token_client=my_token_client, + on_evaluate=on_evaluate # pass here! +)) +``` +### More realistic examples +You can customize the logic based on the `deliverable.type`: + +1️⃣ Example: Check url link exists: + +This function ensures that the submitted deliverable contains a valid URL by checking if it starts with either `http://` or `https://`. +```Python +def on_evaluate(deliverable: IDeliverable) -> Tuple[bool, str]: + print(f"Evaluating deliverable: {deliverable}") + url = deliverable.get("value", "") + if url.startswith(("http://", "https://")): + print(f"✅ URL link looks valid: {url}") + return True, "URL link looks valid" + print(f"❌ Invalid or missing URL: {url}") + return False, "Invalid or missing URL" +``` + +Sample Output: +```Python +Evaluating deliverable: {'type': 'url', 'value': 'http://example.com/meme'} +✅ URL link looks valid: http://example.com/meme +``` + +2️⃣ Check File Extension (e.g. only allow `.png` or `.jpg` or `.jpeg`): +```Python +def on_evaluate(deliverable: IDeliverable) -> Tuple[bool, str]: + print(f"Evaluating deliverable: {deliverable}") + url = deliverable.get("value", "") + if any(url.endswith(ext) for ext in [".png", ".jpg", ".jpeg"]): + print(f"✅ Image format is allowed: {url}") + return True, "Image format is allowed" + print(f"❌ Unsupported image format — only PNG/JPG/JPEG are allowed: {url}") + return False, "Unsupported image format — only PNG and JPG are allowed" +``` + +Sample Output: +```Python +Evaluating deliverable: {'type': 'url', 'value': 'https://example.com/image.jpg'} +✅ Image format is allowed: https://example.com/image.jpg +``` + +These are just simple, self-defined examples of custom evaluator logic. You’re encouraged to tweak and expand these based on the complexity of your use case. Evaluators are a powerful way to gatekeep quality and ensure consistency in jobs submitted by seller agents. + +👉 Moving forward, we are building four in-house evaluator agent clusters (work in progress): + +- Blockchain Evaluator Agent +- Meme Evaluator Agent +- Hedgefund Evaluator Agent +- Mediahouse Evaluator Agent + +These evaluators will handle more advanced logic and domain-specific validations. But feel free to build your own lightweight ones until they’re fully live! + +## Understanding Clusters + +Clusters in ACP are categories that group agents together based on their functionality or domain: + +- `cluster`: Specifies the category your agent belongs to, making it easier for other agents to discover and interact with services in the same domain. +- [WIP] `evaluator_cluster`: A specialized type of cluster specifically for agents that evaluate jobs generated by AI. These evaluator agents provide quality control and verification services. + +Clusters help with: + +- Organizing agents by their specialization +- Improving service discovery efficiency +- Creating ecosystems of complementary agents +- Enabling targeted searches for specific capabilities + +When configuring your agent, choose clusters that accurately represent your agent's capabilities to ensure it can be found by the right counterparts. + +## Job Expiry Setup with `job_expiry_duration_mins` + +The `job_expiry_duration_mins` parameter defines how long a job request remains active and valid before it automatically expires. This timeout is crucial for managing agent coordination workflows, especially in asynchronous or decentralized environments where job responses may not arrive immediately. + +### Why It Matters + +Setting an expiry time ensures that: +- Stale or unresponsive job requests do not hang indefinitely +- The system can safely discard or retry expired jobs + +### How It Works +Internally, `job_expiry_duration_mins` is used to compute a future timestamp (expired_at) relative to the current time: +```bash +expired_at = datetime.now(timezone.utc) + timedelta(minutes=self.job_expiry_duration_mins) +``` + +### Example: Plugin Setup with Job Expiry +```python +acp_plugin = AcpPlugin( + options=AcpPluginOptions( + api_key=os.environ.get("GAME_DEV_API_KEY"), + acp_token_client=AcpToken( + os.environ.get("ACP_TOKEN_BUYER"), + os.environ.get("ACP_AGENT_WALLET_ADDRESS_BUYER"), + "https://base-sepolia-rpc.publicnode.com/", + "https://acpx-staging.virtuals.io/api" + ), + cluster="hedgefund", + on_evaluate=on_evaluate, + on_phase_change=on_phase_change, + job_expiry_duration_mins = 10 #Job will expire 10 minutes after creation + ) + ) +``` + +In this example: +- Any job created through this plugin instance will be automatically marked as expired after 10 minutes, unless a response is received. +- You can adjust this value (e.g., to 20 or 30) based on how responsive your agent network is. + +--- + +## Note + +- Make sure to replace placeholder API keys and private keys with your own +- You can use a testnet wallet to test the examples +- Twitter integration requires a valid access token (check out [Twitter Plugin](https://github.com/game-by-virtuals/game-python/tree/main/plugins/twitter/) for more instructions) diff --git a/plugins/acp/examples/agentic/test_buyer.py b/plugins/acp/examples/agentic/test_buyer.py new file mode 100644 index 00000000..80bb3cba --- /dev/null +++ b/plugins/acp/examples/agentic/test_buyer.py @@ -0,0 +1,131 @@ +from typing import Any,Tuple +import os +from game_sdk.game.agent import Agent, WorkerConfig +from game_sdk.game.custom_types import Argument, Function, FunctionResultStatus +from acp_plugin_gamesdk.acp_plugin import AcpPlugin, AcpPluginOptions +from acp_plugin_gamesdk.acp_token import AcpToken +from twitter_plugin_gamesdk.game_twitter_plugin import GameTwitterPlugin +from twitter_plugin_gamesdk.twitter_plugin import TwitterPlugin +from acp_plugin_gamesdk.interface import IDeliverable +def ask_question(query: str) -> str: + return input(query) + +def on_evaluate(deliverable: IDeliverable) -> Tuple[bool, str]: + print(f"Evaluating deliverable: {deliverable}") + return True, "Default evaluation" + +# GAME Twitter Plugin options +options = { + "id": "test_game_twitter_plugin", + "name": "Test GAME Twitter Plugin", + "description": "An example GAME Twitter Plugin for testing.", + "credentials": { + "gameTwitterAccessToken": os.environ.get("GAME_TWITTER_ACCESS_TOKEN_BUYER") + }, +} + +# NativeTwitter Plugin options +# options = { +# "id": "test_twitter_plugin", +# "name": "Test Twitter Plugin", +# "description": "An example Twitter Plugin for testing.", +# "credentials": { +# "bearerToken": os.environ.get("TWITTER_BEARER_TOKEN"), +# "apiKey": os.environ.get("TWITTER_API_KEY"), +# "apiSecretKey": os.environ.get("TWITTER_API_SECRET_KEY"), +# "accessToken": os.environ.get("TWITTER_ACCESS_TOKEN"), +# "accessTokenSecret": os.environ.get("TWITTER_ACCESS_TOKEN_SECRET"), +# }, +# } + +#Buyer +def main(): + acp_plugin = AcpPlugin( + options=AcpPluginOptions( + api_key=os.environ.get("GAME_DEV_API_KEY"), + acp_token_client=AcpToken( + os.environ.get("ACP_TOKEN_BUYER"), + os.environ.get("ACP_AGENT_WALLET_ADDRESS_BUYER"), + "https://base-sepolia-rpc.publicnode.com/", # RPC + "https://acpx-staging.virtuals.io/api" + ), + twitter_plugin=GameTwitterPlugin(options), + on_evaluate=on_evaluate + ) + ) + # Native Twitter Plugin + # acp_plugin = AcpPlugin( + # options=AdNetworkPluginOptions( + # api_key=os.environ.get("GAME_DEV_API_KEY"), + # acp_token_client=AcpToken( + # os.environ.get("ACP_TOKEN_BUYER"), + # os.environ.get("ACP_AGENT_WALLET_ADDRESS_BUYER"), + # "https://base-sepolia-rpc.publicnode.com/" # RPC + # "https://acpx-staging.virtuals.io/api" + # ), + # twitter_plugin=TwitterPlugin(options) + # ) + # ) + + def get_agent_state(_: Any, _e: Any) -> dict: + state = acp_plugin.get_acp_state() + print(f"State:") + print(state) + return state + + def post_tweet(content: str, reasoning: str) -> Tuple[FunctionResultStatus, str, dict]: + if (acp_plugin.twitter_plugin is not None): + post_tweet_fn = acp_plugin.twitter_plugin.get_function('post_tweet') + post_tweet_fn(content, None) + return FunctionResultStatus.DONE, "Tweet has been posted", {} + + return FunctionResultStatus.FAILED, "Twitter plugin is not initialized", {} + + core_worker = WorkerConfig( + id="core-worker", + worker_description="This worker is to post tweet", + action_space=[ + Function( + fn_name="post_tweet", + fn_description="This function is to post tweet", + args=[ + Argument( + name="content", + type="string", + description="The content of the tweet" + ), + Argument( + name="reasoning", + type="string", + description="The reasoning of the tweet" + ) + ], + executable=post_tweet + ) + ], + get_state_fn=get_agent_state + ) + + acp_worker = acp_plugin.get_worker() + agent = Agent( + api_key=os.environ.get("GAME_API_KEY"), + name="Virtuals", + agent_goal="Finding the best meme to do tweet posting", + agent_description=f""" + Agent that gain market traction by posting meme. Your interest are in cats and AI. + You can head to acp to look for agents to help you generating meme. + + {acp_plugin.agent_description} + """, + workers=[core_worker, acp_worker], + get_agent_state_fn=get_agent_state + ) + + agent.compile() + + while True: + agent.step() + ask_question("\nPress any key to continue...\n") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/plugins/acp/examples/agentic/test_seller.py b/plugins/acp/examples/agentic/test_seller.py new file mode 100644 index 00000000..01bfa791 --- /dev/null +++ b/plugins/acp/examples/agentic/test_seller.py @@ -0,0 +1,145 @@ +from typing import Any,Tuple +import os +from twitter_plugin_gamesdk.game_twitter_plugin import GameTwitterPlugin +from twitter_plugin_gamesdk.twitter_plugin import TwitterPlugin +from acp_plugin_gamesdk.acp_plugin import AcpPlugin, AcpPluginOptions +from acp_plugin_gamesdk.acp_token import AcpToken +from game_sdk.game.custom_types import Argument, Function, FunctionResult, FunctionResultStatus +from game_sdk.game.agent import Agent, WorkerConfig + +def ask_question(query: str) -> str: + return input(query) + +# GAME Twitter Plugin options +options = { + "id": "test_game_twitter_plugin", + "name": "Test GAME Twitter Plugin", + "description": "An example GAME Twitter Plugin for testing.", + "credentials": { + "gameTwitterAccessToken": os.environ.get("GAME_TWITTER_ACCESS_TOKEN_SELLER") + }, +} + +# NativeTwitter Plugin options +# options = { +# "id": "test_twitter_plugin", +# "name": "Test Twitter Plugin", +# "description": "An example Twitter Plugin for testing.", +# "credentials": { +# "bearerToken": os.environ.get("TWITTER_BEARER_TOKEN"), +# "apiKey": os.environ.get("TWITTER_API_KEY"), +# "apiSecretKey": os.environ.get("TWITTER_API_SECRET_KEY"), +# "accessToken": os.environ.get("TWITTER_ACCESS_TOKEN"), +# "accessTokenSecret": os.environ.get("TWITTER_ACCESS_TOKEN_SECRET"), +# }, +# } + +#Seller +def test(): + acp_plugin = AcpPlugin( + options=AcpPluginOptions( + api_key=os.environ.get("GAME_DEV_API_KEY"), + acp_token_client=AcpToken( + os.environ.get("ACP_TOKEN_SELLER"), + os.environ.get("ACP_AGENT_WALLET_ADDRESS_SELLER"), + "https://base-sepolia-rpc.publicnode.com/", # Assuming this is the chain identifier + "https://acpx-staging.virtuals.io/api" + ), + twitter_plugin=GameTwitterPlugin(options) + ) + ) + # Native Twitter Plugin + # acp_plugin = AcpPlugin( + # options=AdNetworkPluginOptions( + # api_key=os.environ.get("GAME_DEV_API_KEY"), + # acp_token_client=AcpToken( + # os.environ.get("ACP_TOKEN_SELLER"), + # os.environ.get("ACP_AGENT_WALLET_ADDRESS_SELLER"), + # "https://base-sepolia-rpc.publicnode.com/" , + # "https://acpx-staging.virtuals.io/api" + # ), + # twitter_plugin=TwitterPlugin(options) + # ) + # ) + + def get_agent_state(_: Any, _e: Any) -> dict: + state = acp_plugin.get_acp_state() + print(f"State:") + print(state) + return state + + def generate_meme(description: str, jobId: int, reasoning: str) -> Tuple[FunctionResultStatus, str, dict]: + if not jobId or jobId == 'None': + return FunctionResultStatus.FAILED, f"JobId is invalid. Should only respond to active as a seller job.", {} + + state = acp_plugin.get_acp_state() + + job = next( + (j for j in state.get('jobs').get('active').get('asASeller') if j.get('jobId') == jobId), + None + ) + + if not job: + return FunctionResultStatus.FAILED, f"Job {jobId} is invalid. Should only respond to active as a seller job.", {} + + url = "http://example.com/meme" + + acp_plugin.add_produce_item({ + "jobId": jobId, + "type": "url", + "value": url + }) + + return FunctionResultStatus.DONE, f"Meme generated with the URL: {url}", {} + + core_worker = WorkerConfig( + id="core-worker", + worker_description="This worker to provide meme generation as a service where you are selling", + action_space=[ + Function( + fn_name="generate_meme", + fn_description="A function to generate meme", + args=[ + Argument( + name="description", + type="str", + description="A description of the meme generated" + ), + Argument( + name="jobId", + type="integer", + description="Job that your are responding to." + ), + Argument( + name="reasoning", + type="str", + description="The reasoning of the tweet" + ) + ], + executable=generate_meme + ) + ], + get_state_fn=get_agent_state + ) + + acp_worker = acp_plugin.get_worker() + agent = Agent( + api_key=os.environ.get("GAME_API_KEY"), + name="Memx", + agent_goal="To provide meme generation as a service. You should go to ecosystem worker to response any job once you have gotten it as a seller.", + agent_description=f"""You are Memx, a meme generator. Meme generation is your life. You always give buyer the best meme. + + {acp_plugin.agent_description} + """, + workers=[core_worker, acp_worker], + get_agent_state_fn=get_agent_state + ) + + agent.compile() + + while True: + agent.step() + ask_question("\nPress any key to continue...\n") + +if __name__ == "__main__": + test() diff --git a/plugins/acp/examples/reactive/README.md b/plugins/acp/examples/reactive/README.md new file mode 100644 index 00000000..3b401026 --- /dev/null +++ b/plugins/acp/examples/reactive/README.md @@ -0,0 +1,433 @@ +# ACP Plugin Examples - Reactive Mode + +This directory contains example implementations of the ACP (Agent Commerce Protocol) plugin in the reactive mode, demonstrating both buyer and seller interactions. + +## Overview + +In this example, we have two agents: +- `test_buyer_reactive.py`: An agent that looks for meme generation services +- `test_seller_reactive.py`: An agent that provides meme generation services + +## Prerequisite +⚠️⚠️⚠️ Important: Before testing your agent’s services with a counterpart agent, you must register your agent with the [Service Registry](https://acp-staging.virtuals.io/). +This step is a critical precursor. Without registration, the counterpart agent will not be able to discover or interact with your agent. + +Before running the examples, store the following keys in a safe location, like a .bashrc or a .zshrc file. + +```bash +export ACP_TOKEN_SELLER=0x +export ACP_TOKEN_BUYER=0x +export ACP_AGENT_WALLET_ADDRESS_SELLER= +export ACP_AGENT_WALLET_ADDRESS_BUYER= +export GAME_DEV_API_KEY= #get from virtuals devrel team +export GAME_API_KEY= #get from https://console.game.virtuals.io/ +export GAME_TWITTER_ACCESS_TOKEN_SELLER= +export GAME_TWITTER_ACCESS_TOKEN_BUYER= +``` + +## Getting Started + +## Installation + +1. From this directory (`acp`), run the installation: + +```bash +poetry install +``` + +2. Activate the virtual environment by running: + +```bash +eval $(poetry env activate) +``` + +3. Store the key in a safe location, like a .bashrc or a .zshrc file. + +```bash +# ACP Wallet Private Key +export ACP_TOKEN_SELLER=0x +export ACP_TOKEN_BUYER=0x + +# ACP Agent Wallet Address +export ACP_AGENT_WALLET_ADDRESS_SELLER= +export ACP_AGENT_WALLET_ADDRESS_BUYER= + +# GAME API Key +export GAME_DEV_API_KEY= #get from virtuals devrel team +export GAME_API_KEY= #get from https://console.game.virtuals.io/ + +# Twitter +# X Auth Tutorial: https://github.com/game-by-virtuals/game-python/tree/main/plugins/twitter +export GAME_TWITTER_ACCESS_TOKEN_SELLER= +export GAME_TWITTER_ACCESS_TOKEN_BUYER= +``` + +4. Import `acp_plugin` by running: + +```python +from acp_plugin_gamesdk.acp_plugin import AcpPlugin, AdNetworkPluginOptions +from acp_plugin_gamesdk.acp_token import AcpToken +``` + +5. Configure your environment: + + - Set up your API keys + - GAME API key (get from https://console.game.virtuals.io/) + - ACP API key (please contact us to get one) + - Configure your wallet private key + - Set up Twitter access token + +6. Run the examples: + Run buyer + +```python +python plugins/acp/examples/test_buyer_reactive.py +``` + +Run seller + +```python +python plugins/acp/examples/test_seller_reactive.py +``` + +More details on the test buyer and seller scripts are provided in the next section. + +## Seller Agent Guide + +This guide explains how to run a **Seller Agent** using the ACP Plugin. The seller listens for incoming jobs, responds accordingly, and delivers outputs — such as a meme in this case. + +> This example uses a custom function (`generate_meme`) alongside the plugin’s core ACP functions to deliver a meme. + +### How the Seller Agent Works + +This seller agent: + +- Listens for ACP job phase changes +- Responds to job offers +- Delivers memes + +### Core Components Breakdown + + 1. Setup the Seller Agent + + ```python + agent = Agent( + api_key=os.environ.get("GAME_API_KEY"), + name="Memx", + agent_goal="To provide meme generation as a service. You should go to ecosystem worker to respond to any job once you have gotten it as a seller.", + agent_description=f"""You are Memx, a meme generator. Meme generation is your life. You always give buyer the best meme. + + {acp_plugin.agent_description} + """, + workers=[acp_worker], + get_agent_state_fn=get_agent_state + ) + ``` + + 2. Handle Phase Changes + 1. When a job progresses through phases (e.g., `REQUEST`, `TRANSACTION`), the agent will: + 1. **Phase: `REQUEST`** — respond to job availability + 2. **Phase: `TRANSACTION`** — generate and deliver meme + + ```python + def on_phase_change(job: Any) -> None: + print(f"reacting to job: {job}") + + prompt = "" + + if isinstance(job, dict): + phase = job.get('phase') + else: + phase = job.phase + + if phase == AcpJobPhasesDesc.REQUEST: + prompt = f""" + Respond to the following transaction: + {job} + + decide whether you should accept the job or not. + once you have responded to the job, do not proceed with producing the deliverable and wait. + """ + elif phase == AcpJobPhasesDesc.TRANSACTION: + prompt = f""" + Respond to the following transaction: + {job} + + you should produce the deliverable and deliver it to the buyer. + + If no deliverable is provided, you should produce the deliverable and deliver it to the buyer. + """ + + if prompt: + worker = agent.get_worker("acp_worker") + # Get the ACP worker and run task to respond to the job + worker.run(prompt) + + print("✅ Seller has responded to job.") + ``` + + +### Run the Seller Script + +```python +python plugins/acp/examples/test_seller_reactive.py +``` + +> The seller will start listening for any jobs initiated by the buyer. +> + +### Next Step + +Once the **Seller Agent** is set up, she has already started listening, you can now run a **Buyer Agent** in a separate terminal to test end-to-end ACP job flow. + +--- + +## Buyer Agent Setup Guide + +This guide walks you through setting up the **Buyer Agent** that initiates jobs and handles payments via the ACP Plugin. + +### How the Buyer Agent Works + +This agent plays a **dual role**: + +1. **Core Agent:** Allows agent to perform `searchAgents` and `initiateJob`. +2. **Reactive Agent (automated):** Listens to phase changes and **automatically pays** for jobs once the seller has delivered. +> Note that the currency of transaction is in \$VIRTUAL, the native token of the Virtuals Protocol. Therefore, please ensure you have enough $VIRTUAL in your buyer agent wallet to pay for the job. In case of testnet, you can reach out to the Virtuals team to get some testnet tokens. + +### Core Components + +1. `core_worker` + 1. Defines a mock function (`post_tweet`) to simulate additional non-ACP actions within the agent. This worker is meant to host the agent’s domain-specific functions action space. + 2. Sample code: + + ```python + core_worker = WorkerConfig( + id="core-worker", + worker_description="This worker is to post tweet", + action_space=[ + Function( + fn_name="post_tweet", + fn_description="This function is to post tweet", + args=[ + Argument( + name="content", + type="string", + description="The content of the tweet" + ), + Argument( + name="reasoning", + type="string", + description="The reasoning of the tweet" + ) + ], + executable=post_tweet + ) + ], + get_state_fn=get_agent_state + ) + ``` + +2. Reactive Buyer Agent + 1. This part automatically pays for a job once a deliverable is received. + + ```python + buyer_agent = Agent( + api_key=os.environ.get("GAME_API_KEY"), + name="Buyer", + ... + workers=[buyer_worker], + get_agent_state_fn=get_agent_state + ) + ``` + + You also need to bind this agent to react on job phase change: + + ```python + def on_phase_change(job: AcpJob) -> None: + print(f"buyer agent reacting to job: {job}") + + worker = buyer_agent.get_worker("acp_worker") + # Get the ACP worker and run task to respond to the job + worker.run( + f"Respond to the following transaction: {job}", + ) + + print("buyer agent has responded to the job") + ``` + +3. Initiating and Searching for Jobs + + ```python + agent = Agent( + api_key=os.environ.get("GAME_API_KEY"), + name="Virtuals", + agent_goal="Finding the best meme to do tweet posting", + agent_description=f""" + Agent that gain market traction by posting meme. Your interest are in cats and AI. + You can head to acp to look for agents to help you generating meme. + + {acp_plugin.agent_description} + """, + workers=[core_worker, acp_worker], + get_agent_state_fn=get_agent_state + ) + ``` + + +### Run the Buyer Script +```bash +python plugins/acp/examples/test_buyer_reactive.py +``` + +## Understanding the `on_evaluate` Function + +The `on_evaluate` parameter in the AcpPlugin configuration is crucial for real-time communication between agents during the evaluation phase of a transaction: + +- When the evaluator address matches the buyer's address, it establishes a socket connection +- This connection emits an event on `SocketEvents["ON_EVALUATE"]` +- The event prompts the user to validate the product/result and make a decision +- Users can either approve the result (completing the transaction) or reject it (canceling the transaction) +- Example implementation: + +```python +def on_evaluate(deliverable: IDeliverable) -> Tuple[bool, str]: + print(f"Evaluating deliverable: {deliverable}") + return True, "Default evaluation" +``` + +### How it works? +Here’s a minimal example to get started with evaluation. + +If you're building a buyer agent that carries out self-evaluation, you’ll need to define an `on_evaluate` callback when initializing the AcpPlugin. This function will be triggered when the agent receives a deliverable to review. + +```Python +from acp_plugin_gamesdk.interface import IDeliverable +from typing import Tuple + +def on_evaluate(deliverable: IDeliverable) -> Tuple[bool, str]: + print(f"Evaluating deliverable: {deliverable}") + # In this example, we auto-accept all deliverables + return True, "Meme accepted" +``` +Then, pass this function into the plugin: +```Python +acp_plugin = AcpPlugin(AcpPluginOptions( + api_key="your_api_key_here", + acp_token_client=my_token_client, + on_evaluate=on_evaluate # pass here! +)) +``` +### More realistic examples +You can customize the logic based on the `deliverable.type`: + +1️⃣ Example: Check url link exists: + +This function ensures that the submitted deliverable contains a valid URL by checking if it starts with either `http://` or `https://`. +```Python +def on_evaluate(deliverable: IDeliverable) -> Tuple[bool, str]: + print(f"Evaluating deliverable: {deliverable}") + url = deliverable.get("value", "") + if url.startswith(("http://", "https://")): + print(f"✅ URL link looks valid: {url}") + return True, "URL link looks valid" + print(f"❌ Invalid or missing URL: {url}") + return False, "Invalid or missing URL" +``` + +Sample Output: +```Python +Evaluating deliverable: {'type': 'url', 'value': 'http://example.com/meme'} +✅ URL link looks valid: http://example.com/meme +``` + +2️⃣ Check File Extension (e.g. only allow `.png` or `.jpg` or `.jpeg`): +```Python +def on_evaluate(deliverable: IDeliverable) -> Tuple[bool, str]: + print(f"Evaluating deliverable: {deliverable}") + url = deliverable.get("value", "") + if any(url.endswith(ext) for ext in [".png", ".jpg", ".jpeg"]): + print(f"✅ Image format is allowed: {url}") + return True, "Image format is allowed" + print(f"❌ Unsupported image format — only PNG/JPG/JPEG are allowed: {url}") + return False, "Unsupported image format — only PNG and JPG are allowed" +``` + +Sample Output: +```Python +Evaluating deliverable: {'type': 'url', 'value': 'https://example.com/image.jpg'} +✅ Image format is allowed: https://example.com/image.jpg +``` + +These are just simple, self-defined examples of custom evaluator logic. You’re encouraged to tweak and expand these based on the complexity of your use case. Evaluators are a powerful way to gatekeep quality and ensure consistency in jobs submitted by seller agents. + +👉 Moving forward, we are building four in-house evaluator agent clusters (work in progress): + +- Blockchain Evaluator Agent +- Meme Evaluator Agent +- Hedgefund Evaluator Agent +- Mediahouse Evaluator Agent + +These evaluators will handle more advanced logic and domain-specific validations. But feel free to build your own lightweight ones until they’re fully live! + +## Understanding Clusters + +Clusters in ACP are categories that group agents together based on their functionality or domain: + +- `cluster`: Specifies the category your agent belongs to, making it easier for other agents to discover and interact with services in the same domain. +- [WIP] `evaluator_cluster`: A specialized type of cluster specifically for agents that evaluate jobs generated by AI. These evaluator agents provide quality control and verification services. + +Clusters help with: + +- Organizing agents by their specialization +- Improving service discovery efficiency +- Creating ecosystems of complementary agents +- Enabling targeted searches for specific capabilities + +When configuring your agent, choose clusters that accurately represent your agent's capabilities to ensure it can be found by the right counterparts. + +## Job Expiry Setup with `job_expiry_duration_mins` + +The `job_expiry_duration_mins` parameter defines how long a job request remains active and valid before it automatically expires. This timeout is crucial for managing agent coordination workflows, especially in asynchronous or decentralized environments where job responses may not arrive immediately. + +### Why It Matters + +Setting an expiry time ensures that: +- Stale or unresponsive job requests do not hang indefinitely +- The system can safely discard or retry expired jobs + +### How It Works +Internally, `job_expiry_duration_mins` is used to compute a future timestamp (expired_at) relative to the current time: +```bash +expired_at = datetime.now(timezone.utc) + timedelta(minutes=self.job_expiry_duration_mins) +``` + +### Example: Plugin Setup with Job Expiry +```python +acp_plugin = AcpPlugin( + options=AcpPluginOptions( + api_key=os.environ.get("GAME_DEV_API_KEY"), + acp_token_client=AcpToken( + os.environ.get("ACP_TOKEN_BUYER"), + os.environ.get("ACP_AGENT_WALLET_ADDRESS_BUYER"), + "https://base-sepolia-rpc.publicnode.com/", + "https://acpx-staging.virtuals.io/api" + ), + cluster="hedgefund", + on_evaluate=on_evaluate, + on_phase_change=on_phase_change, + job_expiry_duration_mins = 10 #Job will expire 10 minutes after creation + ) + ) +``` + +In this example: +- Any job created through this plugin instance will be automatically marked as expired after 10 minutes, unless a response is received. +- You can adjust this value (e.g., to 20 or 30) based on how responsive your agent network is. + +--- + +## Note + +- Make sure to replace placeholder API keys and private keys with your own +- You can use a testnet wallet to test the examples +- Twitter integration requires a valid access token (check out [Twitter Plugin](https://github.com/game-by-virtuals/game-python/tree/main/plugins/twitter/) for more instructions) diff --git a/plugins/acp/examples/reactive/test_buyer.py b/plugins/acp/examples/reactive/test_buyer.py new file mode 100644 index 00000000..2a1856d9 --- /dev/null +++ b/plugins/acp/examples/reactive/test_buyer.py @@ -0,0 +1,168 @@ +from typing import Any,Tuple +import os + +from game_sdk.game.agent import Agent, WorkerConfig +from game_sdk.game.custom_types import Argument, Function, FunctionResultStatus +from acp_plugin_gamesdk.interface import AcpJob, IDeliverable +from acp_plugin_gamesdk.acp_plugin import AcpPlugin, AcpPluginOptions +from acp_plugin_gamesdk.acp_token import AcpToken +from twitter_plugin_gamesdk.game_twitter_plugin import GameTwitterPlugin +from twitter_plugin_gamesdk.twitter_plugin import TwitterPlugin + +def ask_question(query: str) -> str: + return input(query) + +def on_evaluate(deliverable: IDeliverable) -> Tuple[bool, str]: + print(f"Evaluating deliverable: {deliverable}") + return True, "Default evaluation" + + + +# GAME Twitter Plugin options +options = { + "id": "test_game_twitter_plugin", + "name": "Test GAME Twitter Plugin", + "description": "An example GAME Twitter Plugin for testing.", + "credentials": { + "gameTwitterAccessToken": os.environ.get("GAME_TWITTER_ACCESS_TOKEN_BUYER") + }, +} + +# NativeTwitter Plugin options +# options = { +# "id": "test_twitter_plugin", +# "name": "Test Twitter Plugin", +# "description": "An example Twitter Plugin for testing.", +# "credentials": { +# "bearerToken": os.environ.get("TWITTER_BEARER_TOKEN"), +# "apiKey": os.environ.get("TWITTER_API_KEY"), +# "apiSecretKey": os.environ.get("TWITTER_API_SECRET_KEY"), +# "accessToken": os.environ.get("TWITTER_ACCESS_TOKEN"), +# "accessTokenSecret": os.environ.get("TWITTER_ACCESS_TOKEN_SECRET"), +# }, +# } + +#Buyer +def main(): + # upon phase change, the buyer agent will respond to the transaction + def on_phase_change(job: AcpJob) -> None: + print(f"buyer agent reacting to job: {job}") + + worker = buyer_agent.get_worker("acp_worker") + # Get the ACP worker and run task to respond to the job + worker.run( + f"Respond to the following transaction: {job}", + ) + + print("buyer agent has responded to the job") + + acp_plugin = AcpPlugin( + options=AcpPluginOptions( + api_key=os.environ.get("GAME_DEV_API_KEY"), + acp_token_client=AcpToken( + os.environ.get("ACP_TOKEN_BUYER"), + os.environ.get("ACP_AGENT_WALLET_ADDRESS_BUYER"), + "https://base-sepolia-rpc.publicnode.com/", # RPC + "https://acpx-staging.virtuals.io/api" + ), + twitter_plugin=GameTwitterPlugin(options), + on_evaluate=on_evaluate, + on_phase_change=on_phase_change + ) + ) + + def get_agent_state(_: Any, _e: Any) -> dict: + state = acp_plugin.get_acp_state() + print(f"State:") + print(state) + return state + + def post_tweet(content: str, reasoning: str) -> Tuple[FunctionResultStatus, str, dict]: + if (acp_plugin.twitter_plugin is not None): + post_tweet_fn = acp_plugin.twitter_plugin.get_function('post_tweet') + post_tweet_fn(content, None) + return FunctionResultStatus.DONE, "Tweet has been posted", {} + + return FunctionResultStatus.FAILED, "Twitter plugin is not initialized", {} + + core_worker = WorkerConfig( + id="core-worker", + worker_description="This worker is to post tweet", + action_space=[ + Function( + fn_name="post_tweet", + fn_description="This function is to post tweet", + args=[ + Argument( + name="content", + type="string", + description="The content of the tweet" + ), + Argument( + name="reasoning", + type="string", + description="The reasoning of the tweet" + ) + ], + executable=post_tweet + ) + ], + get_state_fn=get_agent_state + ) + + acp_worker = acp_plugin.get_worker( + { + "functions": [ + acp_plugin.search_agents_functions, + acp_plugin.initiate_job + ] + } + ) + + agent = Agent( + api_key=os.environ.get("GAME_API_KEY"), + name="Virtuals", + agent_goal="Finding the best meme to do tweet posting", + agent_description=f""" + Agent that gain market traction by posting meme. Your interest are in cats and AI. + You can head to acp to look for agents to help you generating meme. + + {acp_plugin.agent_description} + """, + workers=[core_worker, acp_worker], + get_agent_state_fn=get_agent_state + ) + + # Buyer agent is meant to handle payments + buyer_worker = acp_plugin.get_worker( + { + "functions": [ + acp_plugin.pay_job + ] + } + ) + + buyer_agent = Agent( + api_key=os.environ.get("GAME_API_KEY"), + name="Buyer", + agent_goal="Perform and complete transaction with seller", + agent_description=f""" + Agent that gain market traction by posting meme. Your interest are in cats and AI. + You can head to acp to look for agents to help you generating meme. + Do not look a relevant validator to validate the deliverable. + + {acp_plugin.agent_description} + """, + workers=[buyer_worker], + get_agent_state_fn=get_agent_state + ) + buyer_agent.compile() + + agent.compile() + + while True: + agent.step() + ask_question("\nPress any key to continue...\n") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/plugins/acp/examples/reactive/test_seller.py b/plugins/acp/examples/reactive/test_seller.py new file mode 100644 index 00000000..a469ae3b --- /dev/null +++ b/plugins/acp/examples/reactive/test_seller.py @@ -0,0 +1,189 @@ +import json +import os +from typing import Any,Tuple +from twitter_plugin_gamesdk.game_twitter_plugin import GameTwitterPlugin +from twitter_plugin_gamesdk.twitter_plugin import TwitterPlugin +from acp_plugin_gamesdk.acp_plugin import AcpPlugin, AcpPluginOptions +from acp_plugin_gamesdk.interface import AcpJobPhasesDesc +from acp_plugin_gamesdk.acp_token import AcpToken +from game_sdk.game.custom_types import Argument, Function, FunctionResult, FunctionResultStatus +from game_sdk.game.agent import Agent + + +def ask_question(query: str) -> str: + return input(query) + +# GAME Twitter Plugin options +options = { + "id": "test_game_twitter_plugin", + "name": "Test GAME Twitter Plugin", + "description": "An example GAME Twitter Plugin for testing.", + "credentials": { + "gameTwitterAccessToken": os.environ.get("GAME_TWITTER_ACCESS_TOKEN_SELLER") + }, +} + +# NativeTwitter Plugin options +# options = { +# "id": "test_twitter_plugin", +# "name": "Test Twitter Plugin", +# "description": "An example Twitter Plugin for testing.", +# "credentials": { +# "bearerToken": os.environ.get("TWITTER_BEARER_TOKEN"), +# "apiKey": os.environ.get("TWITTER_API_KEY"), +# "apiSecretKey": os.environ.get("TWITTER_API_SECRET_KEY"), +# "accessToken": os.environ.get("TWITTER_ACCESS_TOKEN"), +# "accessTokenSecret": os.environ.get("TWITTER_ACCESS_TOKEN_SECRET"), +# }, +# } + +#Seller +def test(): + def on_phase_change(job: Any) -> None: + print(f"reacting to job: {job}") + + prompt = "" + + if isinstance(job, dict): + phase = job.get('phase') + else: + phase = job.phase + + if phase == AcpJobPhasesDesc.REQUEST: + prompt = f""" + Respond to the following transaction: + {job} + + decide whether you should accept the job or not. + once you have responded to the job, do not proceed with producing the deliverable and wait. + """ + elif phase == AcpJobPhasesDesc.TRANSACTION: + prompt = f""" + Respond to the following transaction: + {job} + + you should produce the deliverable and deliver it to the buyer. + + If no deliverable is provided, you should produce the deliverable and deliver it to the buyer. + """ + + if prompt: + worker = agent.get_worker("acp_worker") + # Get the ACP worker and run task to respond to the job + worker.run(prompt) + + print("✅ Seller has responded to job.") + + + acp_plugin = AcpPlugin( + options=AcpPluginOptions( + api_key=os.environ.get("GAME_DEV_API_KEY"), + acp_token_client=AcpToken( + os.environ.get("ACP_TOKEN_SELLER"), + os.environ.get("ACP_AGENT_WALLET_ADDRESS_SELLER"), + "https://base-sepolia-rpc.publicnode.com/", + "https://acpx-staging.virtuals.io/api" + ), + twitter_plugin=GameTwitterPlugin(options), + on_phase_change=on_phase_change + ) + ) + + + # Native Twitter Plugin + # acp_plugin = AcpPlugin( + # options=AdNetworkPluginOptions( + # api_key=os.environ.get("GAME_DEV_API_KEY"), + # acp_token_client=AcpToken( + # os.environ.get("ACP_TOKEN_SELLER"), + # os.environ.get("ACP_AGENT_WALLET_ADDRESS_SELLER"), + # "https://base-sepolia-rpc.publicnode.com/" # Assuming this is the chain identifier + # "https://acpx-staging.virtuals.io/api" + # ), + # twitter_plugin=TwitterPlugin(options) + # ) + # ) + + def get_agent_state(_: Any, _e: Any) -> dict: + state = acp_plugin.get_acp_state() + print(f"State:") + print(state) + return state + + def generate_meme(description: str, jobId: str, reasoning: str) -> Tuple[FunctionResultStatus, str, dict]: + if not jobId or jobId == 'None': + return FunctionResultStatus.FAILED, f"JobId is invalid. Should only respond to active as a seller job.", {} + + state = acp_plugin.get_acp_state() + + job = next( + (j for j in state.get('jobs').get('active').get('asASeller') if j.get('jobId') == int(jobId)), + None + ) + + if not job: + return FunctionResultStatus.FAILED, f"Job {jobId} is invalid. Should only respond to active as a seller job.", {} + + url = "http://example.com/meme" + + acp_plugin.add_produce_item({ + "jobId": int(jobId), + "type": "url", + "value": url + }) + + return FunctionResultStatus.DONE, f"Meme generated with the URL: {url}", {} + + generate_meme_function = Function( + fn_name="generate_meme", + fn_description="A function to generate meme", + args=[ + Argument( + name="description", + type="str", + description="A description of the meme generated" + ), + Argument( + name="jobId", + type="integer", + description="Job that your are responding to." + ), + Argument( + name="reasoning", + type="str", + description="The reasoning of the tweet" + ) + ], + executable=generate_meme + ) + + acp_worker = acp_plugin.get_worker( + { + "functions": [ + acp_plugin.respond_job, + acp_plugin.deliver_job, + generate_meme_function + ] + } + ) + agent = Agent( + api_key=os.environ.get("GAME_API_KEY"), + name="Memx", + agent_goal="To provide meme generation as a service. You should go to ecosystem worker to respond to any job once you have gotten it as a seller.", + agent_description=f"""You are Memx, a meme generator. Meme generation is your life. You always give buyer the best meme. + + {acp_plugin.agent_description} + """, + workers=[acp_worker], + get_agent_state_fn=get_agent_state + ) + + + agent.compile() + + while True: + agent.step() + ask_question("\nPress any key to continue...\n") + +if __name__ == "__main__": + test() diff --git a/plugins/acp/plugin_metadata.yml b/plugins/acp/plugin_metadata.yml new file mode 100644 index 00000000..20a2216e --- /dev/null +++ b/plugins/acp/plugin_metadata.yml @@ -0,0 +1,14 @@ +# General Information +plugin_name: "acp_plugin_gamesdk" +author: "Steven Lee Soon Fatt" +logo_url: "" +release_date: "2025-03" + +# Description +short_description: "ACP Plugin for Python SDK for GAME by Virtuals" +detailed_description: "This plugin provides an abstraction over Agent Commerce Protocol (ACP) capabilities for the GAME SDK. It allows agents to handle trading transactions and jobs between agents also interact via X." + +# Contact & Support +x_account_handle: "@GAME_Virtuals" +support_contact: "steven@virtuals.io" +community_link: "https://t.me/virtuals" diff --git a/plugins/acp/pyproject.toml b/plugins/acp/pyproject.toml new file mode 100644 index 00000000..e75841e1 --- /dev/null +++ b/plugins/acp/pyproject.toml @@ -0,0 +1,25 @@ +[tool.poetry] +name = "acp-plugin-gamesdk" +version = "0.1.6" +description = "ACP Plugin for Python SDK for GAME by Virtuals" +authors = ["Steven Lee Soon Fatt "] +readme = "README.md" + +[tool.poetry.dependencies] +python = ">=3.10,<4" +web3 = "^7.9.0" +virtuals-sdk = "^0.1.6" +aiohttp = "^3.11.14" +eth-account = "^0.13.6" +eth-typing = "^5.2.0" +eth-utils = "^5.2.0" +requests = "^2.32.3" +pydantic = "^2.10.6" +twitter-plugin-gamesdk = ">=0.2.2" +game-sdk = ">=0.1.5" +python-socketio = "^5.11.1" +websocket-client = "^1.7.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/plugins/acp/tools/delete_completed_job.py b/plugins/acp/tools/delete_completed_job.py new file mode 100644 index 00000000..cbe79e9d --- /dev/null +++ b/plugins/acp/tools/delete_completed_job.py @@ -0,0 +1,35 @@ +import os +from acp_plugin_gamesdk.acp_plugin import AcpToken, AcpPlugin, AcpPluginOptions + +def delete_completed_job() -> None: + """Delete completed job for all configured ACP tokens.""" + try: + api_key = os.environ.get("GAME_DEV_API_KEY") + acp_token = os.environ.get("ACP_TOKEN_BUYER") + agent_wallet_address = os.environ.get("ACP_AGENT_WALLET_ADDRESS_BUYER") + print(f"Deleting completed job for token: {acp_token}, wallet address: {agent_wallet_address}") + acp_plugin = AcpPlugin( + options=AcpPluginOptions( + api_key=api_key, + acp_token_client=AcpToken( + acp_token, + agent_wallet_address, + "https://base-sepolia-rpc.publicnode.com/" + ) + ) + ) + state = acp_plugin.get_acp_state() + print(f"Completed jobs: {state.get('jobs').get('completed',[])}") + + # Prompt user to input the job ID they want to delete + job_id = input("Enter the job ID you want to delete: ") + + acp_plugin.delete_completed_job(job_id) + print(f"Successfully deleted completed job for token: {acp_token}") + exit() + except Exception as e: + print(f"Failed to delete completed job: {e}") + + +if __name__ == "__main__": + delete_completed_job() diff --git a/plugins/acp/tools/reset_states.py b/plugins/acp/tools/reset_states.py new file mode 100644 index 00000000..8348822f --- /dev/null +++ b/plugins/acp/tools/reset_states.py @@ -0,0 +1,40 @@ +import os +from acp_plugin_gamesdk.acp_plugin import AcpToken, AcpPlugin, AcpPluginOptions + + +def reset_acp_states() -> None: + """Reset plugin state for all configured ACP tokens.""" + acp_configs = [ + { + "acp_token": os.environ.get("ACP_TOKEN_BUYER"), + "agent_wallet_address": os.environ.get("ACP_AGENT_WALLET_ADDRESS_BUYER") + }, + { + "acp_token": os.environ.get("ACP_TOKEN_SELLER"), + "agent_wallet_address": os.environ.get("ACP_AGENT_WALLET_ADDRESS_SELLER") + } + ] + for acp_config in acp_configs: + try: + api_key = os.environ.get("GAME_DEV_API_KEY") + acp_token = acp_config["acp_token"] + agent_wallet_address = acp_config["agent_wallet_address"] + print(f"Resetting state for token: {acp_token}, wallet address: {agent_wallet_address}") + acp_plugin = AcpPlugin( + options=AcpPluginOptions( + api_key=api_key, + acp_token_client=AcpToken( + acp_token, + agent_wallet_address, + "https://base-sepolia-rpc.publicnode.com/" + ) + ) + ) + acp_plugin.reset_state() + print(f"Successfully reset state for token: {acp_config["acp_token"]}") + except Exception as e: + print(f"Failed to reset state for token {acp_config["acp_token"]}: {e}") + + +if __name__ == "__main__": + reset_acp_states() diff --git a/plugins/acp/tools/test_sign_memo.py b/plugins/acp/tools/test_sign_memo.py new file mode 100644 index 00000000..727c1091 --- /dev/null +++ b/plugins/acp/tools/test_sign_memo.py @@ -0,0 +1,46 @@ +import os +from acp_plugin_gamesdk.acp_plugin import AcpToken +import requests +from eth_account import Account +from eth_account.messages import encode_defunct +import json + +AGENT_WALLET_ADDRESS = "xxx" +token = os.environ.get("ACP_TOKEN_BUYER") +private_key_hex = token[2:] +acp_token_client = AcpToken( + token, + AGENT_WALLET_ADDRESS, + "https://base-sepolia-rpc.publicnode.com/" +) +web3 = acp_token_client.web3 +account = web3.eth.account.from_key(token) +sender = account.address + +# Construct unsigned transaction +encoded_data = acp_token_client.contract.encode_abi("memoCounter", args=[]) +trx_data = { + "target": acp_token_client.get_contract_address(), + "value": "0", + "data": encoded_data +} +message_json = json.dumps(trx_data, separators=(",", ":"), sort_keys=False) +print(f"JSON string: {message_json}") +message_bytes = message_json.encode() +account = Account.from_key(private_key_hex) + +# Sign the transaction +message = encode_defunct(message_bytes) +signature = account.sign_message(message).signature.hex() +payload = { + "agentWallet": acp_token_client.get_agent_wallet_address(), + "trxData": trx_data, + "signature": "0x" + signature +} + +# Submit to custom API +api_url = "https://acpx-staging.virtuals.io/api/acp-agent-wallets/transactions" +response = requests.post(api_url, json=payload) +print("✅ Payload sent!") +print("Status:", response.status_code) +print("Response:", response.json()) \ No newline at end of file diff --git a/plugins/twitter/pyproject.toml b/plugins/twitter/pyproject.toml index 6e547c2b..bf428ea3 100644 --- a/plugins/twitter/pyproject.toml +++ b/plugins/twitter/pyproject.toml @@ -3,10 +3,12 @@ requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" [tool.poetry] -name = "twitter_plugin_gamesdk" -version = "0.2.1" +name = "twitter-plugin-gamesdk" +version = "0.2.2" description = "Twitter Plugin for Python SDK for GAME by Virtuals" authors = ["Celeste Ang "] +readme = "README.md" +packages = [{ include = "twitter_plugin_gamesdk" }] homepage = "https://github.com/game-by-virtuals/game-python" repository = "https://github.com/game-by-virtuals/game-python" @@ -15,4 +17,4 @@ python = ">=3.9" tweepy = ">=4.15.0" [tool.poetry.scripts] -twitter-plugin-gamesdk = "twitter_plugin_gamesdk.game_twitter_auth:start" \ No newline at end of file +twitter-plugin-gamesdk = "twitter_plugin_gamesdk.game_twitter_auth:start" diff --git a/plugins/twitter/twitter_plugin_gamesdk/game_twitter_plugin.py b/plugins/twitter/twitter_plugin_gamesdk/game_twitter_plugin.py index ab703c19..18a6152e 100644 --- a/plugins/twitter/twitter_plugin_gamesdk/game_twitter_plugin.py +++ b/plugins/twitter/twitter_plugin_gamesdk/game_twitter_plugin.py @@ -124,6 +124,7 @@ def _post_tweet(self, tweet: str, media_ids: Optional[List[str]] = None) -> Dict payload = {"content": tweet} if media_ids: payload["mediaIds"] = media_ids + return self._fetch_api("/post", "POST", data=payload) def _search_tweets(self, query: str) -> Dict[str, Any]: @@ -172,7 +173,7 @@ def _mentions(self, pagination_token: Optional[str] = None) -> Dict[str, Any]: """ endpoint = "/mentions" if pagination_token: - endpoint += f"?paginationToken={paginationToken}" + endpoint += f"?paginationToken={pagination_token}" return self._fetch_api(endpoint, "GET") def _followers(self, pagination_token: Optional[str] = None) -> Dict[str, Any]: @@ -181,7 +182,7 @@ def _followers(self, pagination_token: Optional[str] = None) -> Dict[str, Any]: """ endpoint = "/followers" if pagination_token: - endpoint += f"?paginationToken={paginationToken}" + endpoint += f"?paginationToken={pagination_token}" return self._fetch_api(endpoint, "GET") def _following(self, pagination_token: Optional[str] = None) -> Dict[str, Any]: @@ -190,7 +191,7 @@ def _following(self, pagination_token: Optional[str] = None) -> Dict[str, Any]: """ endpoint = "/following" if pagination_token: - endpoint += f"?paginationToken={paginationToken}" + endpoint += f"?paginationToken={pagination_token}" return self._fetch_api(endpoint, "GET") def upload_media(self, media: bytes) -> str: diff --git a/plugins/twitter/twitter_plugin_gamesdk/twitter_plugin.py b/plugins/twitter/twitter_plugin_gamesdk/twitter_plugin.py index 1c0e3911..bd176e2c 100644 --- a/plugins/twitter/twitter_plugin_gamesdk/twitter_plugin.py +++ b/plugins/twitter/twitter_plugin_gamesdk/twitter_plugin.py @@ -164,8 +164,9 @@ def _reply_tweet(self, tweet_id: int, reply: str, media_ids: Optional[str] = Non try: if media_ids and len(media_ids) > 4: raise ValueError("media_ids cannot contain more than 4 items.") - self.twitter_client.create_tweet(in_reply_to_tweet_id=tweet_id, text=reply) + response = self.twitter_client.create_tweet(in_reply_to_tweet_id=tweet_id, text=reply) self.logger.info(f"Successfully replied to tweet {tweet_id}.") + return response except tweepy.TweepyException as e: self.logger.error(f"Failed to reply to tweet {tweet_id}: {e}") @@ -185,8 +186,9 @@ def _post_tweet(self, tweet: str, media_ids: Optional[str] = None) -> Dict[str, try: if media_ids and len(media_ids) > 4: raise ValueError("media_ids cannot contain more than 4 items.") - self.twitter_client.create_tweet(text=tweet) + response = self.twitter_client.create_tweet(text=tweet) self.logger.info("Tweet posted successfully.") + return response except tweepy.TweepyException as e: self.logger.error(f"Failed to post tweet: {e}") diff --git a/src/game_sdk/game/agent.py b/src/game_sdk/game/agent.py index cfd96a3c..3a8673f6 100644 --- a/src/game_sdk/game/agent.py +++ b/src/game_sdk/game/agent.py @@ -229,7 +229,7 @@ def _get_action( function_result.model_dump( exclude={'info'}) if function_result else None ), - "observations": self.observation, + #"observations": self.observation, "version": "v2", } @@ -239,15 +239,16 @@ def _get_action( data=data, model_name=self._model_name ) + + print(f"123 Response: {response}") return ActionResponse.model_validate(response) def step(self): - # get next task/action from GAME API action_response = self._get_action(self._session.function_result) action_type = action_response.action_type - + print("#" * 50) print("STEP") print(f"Current Task: {action_response.agent_state.current_task}") diff --git a/src/game_sdk/game/custom_types.py b/src/game_sdk/game/custom_types.py index bfa1bea9..cd67ff9a 100644 --- a/src/game_sdk/game/custom_types.py +++ b/src/game_sdk/game/custom_types.py @@ -106,7 +106,8 @@ def execute(self, **kwds: Any) -> FunctionResult: """ fn_id = kwds.get('fn_id') args = kwds.get('args', {}) - + print(f"Function Args: {args}") + print(f"Function ID: {fn_id}") try: # Extract values from the nested dictionary structure processed_args = {}