agent0-sdk 0.31__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,355 @@
1
+ """
2
+ IPFS client for decentralized storage with support for multiple providers:
3
+ - Local IPFS nodes
4
+ - Pinata IPFS pinning service
5
+ - Filecoin Pin service
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import subprocess
13
+ import tempfile
14
+ import os
15
+ import time
16
+ from typing import Any, Dict, Optional
17
+
18
+ try:
19
+ import ipfshttpclient
20
+ except ImportError:
21
+ ipfshttpclient = None
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class IPFSClient:
27
+ """Client for IPFS operations supporting multiple providers (local IPFS, Pinata, Filecoin Pin)."""
28
+
29
+ def __init__(
30
+ self,
31
+ url: Optional[str] = None,
32
+ filecoin_pin_enabled: bool = False,
33
+ filecoin_private_key: Optional[str] = None,
34
+ pinata_enabled: bool = False,
35
+ pinata_jwt: Optional[str] = None
36
+ ):
37
+ """Initialize IPFS client.
38
+
39
+ Args:
40
+ url: IPFS node URL (e.g., "http://localhost:5001")
41
+ filecoin_pin_enabled: Enable Filecoin Pin integration
42
+ filecoin_private_key: Private key for Filecoin Pin operations
43
+ pinata_enabled: Enable Pinata integration
44
+ pinata_jwt: JWT token for Pinata authentication
45
+ """
46
+ self.url = url
47
+ self.filecoin_pin_enabled = filecoin_pin_enabled
48
+ self.filecoin_private_key = filecoin_private_key
49
+ self.pinata_enabled = pinata_enabled
50
+ self.pinata_jwt = pinata_jwt
51
+ self.client = None
52
+
53
+ if pinata_enabled:
54
+ self._verify_pinata_jwt()
55
+ elif filecoin_pin_enabled:
56
+ self._verify_filecoin_pin_installation()
57
+ elif url and ipfshttpclient:
58
+ self.client = ipfshttpclient.connect(url)
59
+ elif url and not ipfshttpclient:
60
+ raise ImportError(
61
+ "IPFS dependencies not installed. Install with: pip install ipfshttpclient"
62
+ )
63
+
64
+ def _verify_pinata_jwt(self):
65
+ """Verify Pinata JWT is provided."""
66
+ if not self.pinata_jwt:
67
+ raise ValueError("pinata_jwt is required when pinata_enabled=True")
68
+ logger.debug("Pinata JWT configured")
69
+
70
+ def _verify_filecoin_pin_installation(self):
71
+ """Verify filecoin-pin CLI is installed."""
72
+ try:
73
+ result = subprocess.run(
74
+ ['filecoin-pin', '--version'],
75
+ capture_output=True,
76
+ text=True,
77
+ check=True
78
+ )
79
+ logger.debug(f"Filecoin Pin CLI found: {result.stdout.strip()}")
80
+ except (subprocess.CalledProcessError, FileNotFoundError):
81
+ raise RuntimeError(
82
+ "filecoin-pin CLI not found. "
83
+ "Install it from: https://github.com/filecoin-project/filecoin-pin?tab=readme-ov-file#cli"
84
+ )
85
+
86
+ def _pin_to_filecoin(self, file_path: str) -> str:
87
+ """Pin file to Filecoin using filecoin-pin CLI following the official guide."""
88
+ # Check if environment file exists (as per guide)
89
+ env_file = os.path.expanduser("~/.filecoin-pin-env")
90
+ if not os.path.exists(env_file):
91
+ raise RuntimeError(
92
+ "Filecoin Pin environment file not found. Please run:\n"
93
+ " 1. cast wallet new\n"
94
+ " 2. Create ~/.filecoin-pin-env with PRIVATE_KEY and WALLET_ADDRESS\n"
95
+ " 3. Get testnet tokens from https://faucet.calibnet.chainsafe-fil.io/\n"
96
+ " 4. filecoin-pin payments setup --auto (one-time setup)"
97
+ )
98
+
99
+ # Load environment from file (as per guide)
100
+ env = os.environ.copy()
101
+ try:
102
+ with open(env_file, 'r') as f:
103
+ for line in f:
104
+ if line.startswith('export '):
105
+ key, value = line[7:].strip().split('=', 1)
106
+ env[key] = value.strip('"\'')
107
+ except Exception as e:
108
+ raise RuntimeError(f"Error loading Filecoin Pin environment: {e}")
109
+
110
+ if 'PRIVATE_KEY' not in env:
111
+ raise RuntimeError("PRIVATE_KEY not found in environment file")
112
+
113
+ try:
114
+ import time
115
+ cmd = ['filecoin-pin', 'add', '--bare', file_path]
116
+ logger.debug(f"Running Filecoin CLI command: {' '.join(cmd)}")
117
+
118
+ start_time = time.time()
119
+ result = subprocess.run(
120
+ cmd,
121
+ capture_output=True,
122
+ text=True,
123
+ check=True,
124
+ env=env
125
+ )
126
+ elapsed_time = time.time() - start_time
127
+ logger.debug(f"Filecoin CLI completed in {elapsed_time:.2f} seconds")
128
+
129
+ # Parse the output to extract Root CID
130
+ lines = result.stdout.strip().split('\n')
131
+ for line in lines:
132
+ if 'Root CID:' in line:
133
+ return line.split('Root CID:')[1].strip()
134
+
135
+ # Fallback: return the first line if parsing fails
136
+ return lines[0] if lines else "unknown"
137
+
138
+ except subprocess.CalledProcessError as e:
139
+ # Handle specific error cases from the guide
140
+ stderr_lower = e.stderr.lower()
141
+ if "insufficient fil" in stderr_lower or "balance" in stderr_lower:
142
+ raise RuntimeError(
143
+ f"Insufficient FIL for gas fees: {e.stderr}\n"
144
+ "Get test FIL from: https://faucet.calibnet.chainsafe-fil.io/\n"
145
+ "Then run: filecoin-pin payments setup --auto (one-time setup)"
146
+ )
147
+ elif "payment" in stderr_lower or "setup" in stderr_lower:
148
+ raise RuntimeError(
149
+ f"Payment setup required: {e.stderr}\n"
150
+ "Run: filecoin-pin payments setup --auto (one-time setup)"
151
+ )
152
+ else:
153
+ raise RuntimeError(f"Filecoin Pin 'add' command failed: {e.stderr}")
154
+
155
+ def _pin_to_local_ipfs(self, data: str, **kwargs) -> str:
156
+ """Pin data to local IPFS node."""
157
+ if not self.client:
158
+ raise RuntimeError("No IPFS client available")
159
+ result = self.client.add_str(data, **kwargs)
160
+ # add_str returns the CID directly as a string
161
+ return result if isinstance(result, str) else result['Hash']
162
+
163
+ def _pin_to_pinata(self, data: str) -> str:
164
+ """Pin data to Pinata using JWT authentication with v3 API."""
165
+ import requests
166
+ import tempfile
167
+ import os
168
+
169
+ # Pinata v3 API endpoint for uploading files
170
+ url = "https://uploads.pinata.cloud/v3/files"
171
+
172
+ # Pinata authentication using JWT
173
+ headers = {
174
+ "Authorization": f"Bearer {self.pinata_jwt}"
175
+ }
176
+
177
+ # Create a temporary file with the data
178
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
179
+ f.write(data)
180
+ temp_path = f.name
181
+
182
+ try:
183
+ logger.debug("Pinning to Pinata v3 (public)")
184
+
185
+ # Prepare the file for upload with public network setting
186
+ with open(temp_path, 'rb') as file:
187
+ files = {
188
+ 'file': ('registration.json', file, 'application/json')
189
+ }
190
+
191
+ # Add network parameter to make file public
192
+ data = {
193
+ 'network': 'public'
194
+ }
195
+
196
+ response = requests.post(url, headers=headers, files=files, data=data)
197
+
198
+ response.raise_for_status()
199
+ result = response.json()
200
+
201
+ # v3 API returns different structure - CID is nested in data
202
+ cid = None
203
+ if 'data' in result and 'cid' in result['data']:
204
+ cid = result['data']['cid']
205
+ elif 'cid' in result:
206
+ cid = result['cid']
207
+ elif 'IpfsHash' in result:
208
+ cid = result['IpfsHash']
209
+
210
+ if not cid:
211
+ error_msg = f"No CID returned from Pinata. Response: {result}"
212
+ logger.error(error_msg)
213
+ raise ValueError(error_msg)
214
+ logger.debug(f"Pinned to Pinata v3: {cid}")
215
+ return cid
216
+ except requests.exceptions.HTTPError as e:
217
+ error_details = ""
218
+ if hasattr(e, 'response') and e.response is not None:
219
+ error_details = f" Response: {e.response.text}"
220
+ error_msg = f"Failed to pin to Pinata: HTTP {e}{error_details}"
221
+ logger.error(error_msg)
222
+ raise RuntimeError(error_msg)
223
+ except Exception as e:
224
+ error_msg = f"Failed to pin to Pinata: {e}"
225
+ logger.error(error_msg)
226
+ raise RuntimeError(error_msg)
227
+ finally:
228
+ # Clean up temporary file
229
+ try:
230
+ os.unlink(temp_path)
231
+ except:
232
+ pass
233
+
234
+ def add(self, data: str, **kwargs) -> str:
235
+ """Add data to IPFS and return CID."""
236
+ if self.pinata_enabled:
237
+ return self._pin_to_pinata(data)
238
+ elif self.filecoin_pin_enabled:
239
+ # Create temporary file for Filecoin Pin
240
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
241
+ f.write(data)
242
+ temp_path = f.name
243
+
244
+ try:
245
+ cid = self._pin_to_filecoin(temp_path)
246
+ return cid
247
+ finally:
248
+ os.unlink(temp_path)
249
+ else:
250
+ return self._pin_to_local_ipfs(data, **kwargs)
251
+
252
+ def add_file(self, filepath: str, **kwargs) -> str:
253
+ """Add file to IPFS and return CID."""
254
+ if self.pinata_enabled:
255
+ # Read file and send to Pinata
256
+ with open(filepath, 'r') as f:
257
+ data = f.read()
258
+ return self._pin_to_pinata(data)
259
+ elif self.filecoin_pin_enabled:
260
+ return self._pin_to_filecoin(filepath)
261
+ else:
262
+ if not self.client:
263
+ raise RuntimeError("No IPFS client available")
264
+ result = self.client.add(filepath, **kwargs)
265
+ return result['Hash']
266
+
267
+ def get(self, cid: str) -> str:
268
+ """Get data from IPFS by CID."""
269
+ # Extract CID from IPFS URL if needed
270
+ if cid.startswith("ipfs://"):
271
+ cid = cid[7:] # Remove "ipfs://" prefix
272
+
273
+ # Pinata and Filecoin Pin both use IPFS gateways for retrieval
274
+ if self.pinata_enabled or self.filecoin_pin_enabled:
275
+ # Use IPFS gateways for retrieval
276
+ import requests
277
+ try:
278
+ # Try multiple gateways for reliability, prioritizing Pinata v3 gateway
279
+ gateways = [
280
+ f"https://gateway.pinata.cloud/ipfs/{cid}",
281
+ f"https://ipfs.io/ipfs/{cid}",
282
+ f"https://dweb.link/ipfs/{cid}"
283
+ ]
284
+
285
+ for gateway in gateways:
286
+ try:
287
+ response = requests.get(gateway, timeout=10)
288
+ response.raise_for_status()
289
+ return response.text
290
+ except Exception:
291
+ continue
292
+
293
+ raise RuntimeError(f"Failed to retrieve data from all IPFS gateways")
294
+ except Exception as e:
295
+ raise RuntimeError(f"Failed to retrieve data from IPFS gateway: {e}")
296
+ else:
297
+ if not self.client:
298
+ raise RuntimeError("No IPFS client available")
299
+ return self.client.cat(cid).decode('utf-8')
300
+
301
+ def get_json(self, cid: str) -> Dict[str, Any]:
302
+ """Get JSON data from IPFS by CID."""
303
+ data = self.get(cid)
304
+ return json.loads(data)
305
+
306
+ def pin(self, cid: str) -> Dict[str, Any]:
307
+ """Pin a CID to local node."""
308
+ if self.filecoin_pin_enabled:
309
+ # Filecoin Pin automatically pins data, so this is a no-op
310
+ return {"pinned": [cid]}
311
+ else:
312
+ if not self.client:
313
+ raise RuntimeError("No IPFS client available")
314
+ return self.client.pin.add(cid)
315
+
316
+ def unpin(self, cid: str) -> Dict[str, Any]:
317
+ """Unpin a CID from local node."""
318
+ if self.filecoin_pin_enabled:
319
+ # Filecoin Pin doesn't support unpinning in the same way
320
+ # This is a no-op for Filecoin Pin
321
+ return {"unpinned": [cid]}
322
+ else:
323
+ if not self.client:
324
+ raise RuntimeError("No IPFS client available")
325
+ return self.client.pin.rm(cid)
326
+
327
+ def add_json(self, data: Dict[str, Any], **kwargs) -> str:
328
+ """Add JSON data to IPFS and return CID."""
329
+ json_str = json.dumps(data, indent=2)
330
+ return self.add(json_str, **kwargs)
331
+
332
+ def addRegistrationFile(self, registrationFile: "RegistrationFile", chainId: Optional[int] = None, identityRegistryAddress: Optional[str] = None, **kwargs) -> str:
333
+ """Add registration file to IPFS and return CID."""
334
+ data = registrationFile.to_dict(chain_id=chainId, identity_registry_address=identityRegistryAddress)
335
+ return self.add_json(data, **kwargs)
336
+
337
+ def getRegistrationFile(self, cid: str) -> "RegistrationFile":
338
+ """Get registration file from IPFS by CID."""
339
+ from .models import RegistrationFile
340
+ data = self.get_json(cid)
341
+ return RegistrationFile.from_dict(data)
342
+
343
+ def addFeedbackFile(self, feedbackData: Dict[str, Any], **kwargs) -> str:
344
+ """Add feedback file to IPFS and return CID."""
345
+ return self.add_json(feedbackData, **kwargs)
346
+
347
+ def getFeedbackFile(self, cid: str) -> Dict[str, Any]:
348
+ """Get feedback file from IPFS by CID."""
349
+ return self.get_json(cid)
350
+
351
+ def close(self):
352
+ """Close IPFS client connection."""
353
+ if hasattr(self.client, 'close'):
354
+ self.client.close()
355
+
@@ -0,0 +1,313 @@
1
+ """
2
+ Core data models for the Agent0 SDK.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from dataclasses import dataclass, field
9
+ from enum import Enum
10
+ from typing import Any, Dict, List, Optional, Union, Literal
11
+ from datetime import datetime
12
+
13
+
14
+ # Type aliases
15
+ AgentId = str # "chainId:tokenId" (e.g., "8453:1234") or just tokenId when chain is implicit
16
+ ChainId = int
17
+ Address = str # 0x-hex
18
+ URI = str # https://... or ipfs://...
19
+ CID = str # IPFS CID (if used)
20
+ Timestamp = int # unix seconds
21
+ IdemKey = str # idempotency key for write ops
22
+
23
+
24
+ class EndpointType(Enum):
25
+ """Types of endpoints that agents can advertise."""
26
+ MCP = "MCP"
27
+ A2A = "A2A"
28
+ ENS = "ENS"
29
+ DID = "DID"
30
+ WALLET = "wallet"
31
+ OASF = "OASF"
32
+
33
+
34
+ class TrustModel(Enum):
35
+ """Trust models supported by the SDK."""
36
+ REPUTATION = "reputation"
37
+ CRYPTO_ECONOMIC = "crypto-economic"
38
+ TEE_ATTESTATION = "tee-attestation"
39
+
40
+
41
+ @dataclass
42
+ class Endpoint:
43
+ """Represents an agent endpoint."""
44
+ type: EndpointType
45
+ value: str # endpoint value (URL, name, DID, ENS)
46
+ meta: Dict[str, Any] = field(default_factory=dict) # optional metadata
47
+
48
+
49
+ @dataclass
50
+ class RegistrationFile:
51
+ """Agent registration file structure."""
52
+ agentId: Optional[AgentId] = None # None until minted
53
+ agentURI: Optional[URI] = None # where this file is (or will be) published
54
+ name: str = ""
55
+ description: str = ""
56
+ image: Optional[URI] = None
57
+ walletAddress: Optional[Address] = None
58
+ walletChainId: Optional[int] = None # Chain ID for the wallet address
59
+ endpoints: List[Endpoint] = field(default_factory=list)
60
+ trustModels: List[Union[TrustModel, str]] = field(default_factory=list)
61
+ owners: List[Address] = field(default_factory=list) # from chain (read-only, hydrated)
62
+ operators: List[Address] = field(default_factory=list) # from chain (read-only, hydrated)
63
+ active: bool = False # SDK extension flag
64
+ x402support: bool = False # Binary flag for x402 payment support
65
+ metadata: Dict[str, Any] = field(default_factory=dict) # arbitrary, SDK-managed
66
+ updatedAt: Timestamp = field(default_factory=lambda: int(datetime.now().timestamp()))
67
+
68
+ def __str__(self) -> str:
69
+ """String representation as JSON."""
70
+ # Use stored registry info if available
71
+ chain_id = getattr(self, '_chain_id', None)
72
+ registry_address = getattr(self, '_registry_address', None)
73
+ return json.dumps(self.to_dict(chain_id, registry_address), indent=2, default=str)
74
+
75
+ def __repr__(self) -> str:
76
+ """Developer representation."""
77
+ return f"RegistrationFile(agentId={self.agentId}, agentURI={self.agentURI}, name={self.name})"
78
+
79
+ def to_dict(self, chain_id: Optional[int] = None, identity_registry_address: Optional[str] = None) -> Dict[str, Any]:
80
+ """Convert to dictionary for JSON serialization."""
81
+ # Build endpoints array
82
+ endpoints = []
83
+ for endpoint in self.endpoints:
84
+ endpoint_dict = {
85
+ "name": endpoint.type.value,
86
+ "endpoint": endpoint.value,
87
+ **endpoint.meta
88
+ }
89
+ endpoints.append(endpoint_dict)
90
+
91
+ # Add walletAddress as an endpoint if present
92
+ if self.walletAddress:
93
+ # Use stored walletChainId if available, otherwise extract from agentId
94
+ chain_id_for_wallet = self.walletChainId
95
+ if chain_id_for_wallet is None:
96
+ # Extract chain ID from agentId if available, otherwise use 1 as default
97
+ chain_id_for_wallet = 1 # Default to mainnet
98
+ if self.agentId and ":" in self.agentId:
99
+ try:
100
+ chain_id_for_wallet = int(self.agentId.split(":")[1])
101
+ except (ValueError, IndexError):
102
+ chain_id_for_wallet = 1
103
+
104
+ endpoints.append({
105
+ "name": "agentWallet",
106
+ "endpoint": f"eip155:{chain_id_for_wallet}:{self.walletAddress}"
107
+ })
108
+
109
+ # Build registrations array
110
+ registrations = []
111
+ if self.agentId:
112
+ agent_id_int = int(self.agentId.split(":")[-1]) if ":" in self.agentId else int(self.agentId)
113
+ agent_registry = f"eip155:{chain_id}:{identity_registry_address}" if chain_id and identity_registry_address else f"eip155:1:{{identityRegistry}}"
114
+ registrations.append({
115
+ "agentId": agent_id_int,
116
+ "agentRegistry": agent_registry
117
+ })
118
+
119
+ return {
120
+ "type": "https://eips.ethereum.org/EIPS/eip-8004#registration-v1",
121
+ "name": self.name,
122
+ "description": self.description,
123
+ "image": self.image,
124
+ "endpoints": endpoints,
125
+ "registrations": registrations,
126
+ "supportedTrust": [tm.value if isinstance(tm, TrustModel) else tm for tm in self.trustModels],
127
+ "active": self.active,
128
+ "x402support": self.x402support,
129
+ "updatedAt": self.updatedAt,
130
+ }
131
+
132
+ @classmethod
133
+ def from_dict(cls, data: Dict[str, Any]) -> RegistrationFile:
134
+ """Create from dictionary."""
135
+ endpoints = []
136
+ for ep_data in data.get("endpoints", []):
137
+ name = ep_data["name"]
138
+ # Special handling for agentWallet - it's not a standard endpoint type
139
+ if name == "agentWallet":
140
+ # Skip agentWallet endpoints as they're handled separately via walletAddress field
141
+ continue
142
+
143
+ ep_type = EndpointType(name)
144
+ ep_value = ep_data["endpoint"]
145
+ ep_meta = {k: v for k, v in ep_data.items() if k not in ["name", "endpoint"]}
146
+ endpoints.append(Endpoint(type=ep_type, value=ep_value, meta=ep_meta))
147
+
148
+ trust_models = []
149
+ for tm in data.get("supportedTrust", []):
150
+ try:
151
+ trust_models.append(TrustModel(tm))
152
+ except ValueError:
153
+ trust_models.append(tm) # custom string
154
+
155
+ return cls(
156
+ agentId=data.get("agentId"),
157
+ agentURI=data.get("agentURI"),
158
+ name=data.get("name", ""),
159
+ description=data.get("description", ""),
160
+ image=data.get("image"),
161
+ walletAddress=data.get("walletAddress"),
162
+ walletChainId=data.get("walletChainId"),
163
+ endpoints=endpoints,
164
+ trustModels=trust_models,
165
+ active=data.get("active", False),
166
+ x402support=data.get("x402support", False),
167
+ metadata=data.get("metadata", {}),
168
+ updatedAt=data.get("updatedAt", int(datetime.now().timestamp())),
169
+ )
170
+
171
+
172
+ @dataclass
173
+ class AgentSummary:
174
+ """Summary information for agent discovery and search."""
175
+ chainId: ChainId
176
+ agentId: AgentId
177
+ name: str
178
+ image: Optional[URI]
179
+ description: str
180
+ owners: List[Address]
181
+ operators: List[Address]
182
+ mcp: bool
183
+ a2a: bool
184
+ ens: Optional[str]
185
+ did: Optional[str]
186
+ walletAddress: Optional[Address]
187
+ supportedTrusts: List[str] # normalized string keys
188
+ a2aSkills: List[str]
189
+ mcpTools: List[str]
190
+ mcpPrompts: List[str]
191
+ mcpResources: List[str]
192
+ active: bool
193
+ x402support: bool = False
194
+ extras: Dict[str, Any] = field(default_factory=dict)
195
+
196
+
197
+ @dataclass
198
+ class Feedback:
199
+ """Feedback data structure."""
200
+ id: tuple # (agentId, clientAddress, feedbackIndex) - tuple for efficiency
201
+ agentId: AgentId
202
+ reviewer: Address
203
+ score: Optional[int] # 0-100
204
+ tags: List[str] = field(default_factory=list)
205
+ text: Optional[str] = None
206
+ context: Optional[Dict[str, Any]] = None
207
+ proofOfPayment: Optional[Dict[str, Any]] = None
208
+ fileURI: Optional[URI] = None
209
+ createdAt: Timestamp = field(default_factory=lambda: int(datetime.now().timestamp()))
210
+ answers: List[Dict[str, Any]] = field(default_factory=list)
211
+ isRevoked: bool = False
212
+
213
+ # Off-chain only fields (not stored on blockchain)
214
+ capability: Optional[str] = None # MCP capability: "prompts", "resources", "tools", "completions"
215
+ name: Optional[str] = None # MCP tool/resource name
216
+ skill: Optional[str] = None # A2A skill
217
+ task: Optional[str] = None # A2A task
218
+
219
+ def __post_init__(self):
220
+ """Validate and set ID after initialization."""
221
+ if isinstance(self.id, str):
222
+ # Convert string ID to tuple
223
+ parsed_id = self.from_id_string(self.id)
224
+ self.id = parsed_id
225
+ elif not isinstance(self.id, tuple) or len(self.id) != 3:
226
+ raise ValueError(f"Feedback ID must be tuple of (agentId, clientAddress, feedbackIndex), got: {self.id}")
227
+
228
+ @property
229
+ def id_string(self) -> str:
230
+ """Get string representation of ID for external APIs."""
231
+ return f"{self.id[0]}:{self.id[1]}:{self.id[2]}"
232
+
233
+ @classmethod
234
+ def create_id(cls, agentId: AgentId, clientAddress: Address, feedbackIndex: int) -> tuple:
235
+ """Create feedback ID tuple with normalized address."""
236
+ # Normalize address to lowercase for consistency
237
+ # Ethereum addresses are case-insensitive, but we store them in lowercase
238
+ if isinstance(clientAddress, str) and (clientAddress.startswith("0x") or clientAddress.startswith("0X")):
239
+ normalized_address = "0x" + clientAddress[2:].lower()
240
+ else:
241
+ normalized_address = clientAddress.lower() if isinstance(clientAddress, str) else str(clientAddress).lower()
242
+ return (agentId, normalized_address, feedbackIndex)
243
+
244
+ @classmethod
245
+ def from_id_string(cls, id_string: str) -> tuple:
246
+ """Parse feedback ID from string.
247
+
248
+ Format: agentId:clientAddress:feedbackIndex
249
+ Note: agentId may contain colons (e.g., "11155111:123"), so we need to split from the right.
250
+ """
251
+ parts = id_string.rsplit(":", 2) # Split from right, max 2 splits
252
+ if len(parts) != 3:
253
+ raise ValueError(f"Invalid feedback ID format: {id_string}")
254
+
255
+ try:
256
+ feedback_index = int(parts[2])
257
+ except ValueError:
258
+ raise ValueError(f"Invalid feedback index: {parts[2]}")
259
+
260
+ # Normalize address to lowercase for consistency
261
+ client_address = parts[1]
262
+ if isinstance(client_address, str) and (client_address.startswith("0x") or client_address.startswith("0X")):
263
+ normalized_address = "0x" + client_address[2:].lower()
264
+ else:
265
+ normalized_address = client_address.lower() if isinstance(client_address, str) else str(client_address).lower()
266
+
267
+ return (parts[0], normalized_address, feedback_index)
268
+
269
+
270
+ @dataclass
271
+ class SearchParams:
272
+ """Parameters for agent search."""
273
+ chains: Optional[Union[List[ChainId], Literal["all"]]] = None
274
+ name: Optional[str] = None # case-insensitive substring
275
+ description: Optional[str] = None # semantic; vector distance < threshold
276
+ owners: Optional[List[Address]] = None
277
+ operators: Optional[List[Address]] = None
278
+ mcp: Optional[bool] = None
279
+ a2a: Optional[bool] = None
280
+ ens: Optional[str] = None # exact, case-insensitive
281
+ did: Optional[str] = None # exact
282
+ walletAddress: Optional[Address] = None
283
+ supportedTrust: Optional[List[str]] = None
284
+ a2aSkills: Optional[List[str]] = None
285
+ mcpTools: Optional[List[str]] = None
286
+ mcpPrompts: Optional[List[str]] = None
287
+ mcpResources: Optional[List[str]] = None
288
+ active: Optional[bool] = True
289
+ x402support: Optional[bool] = None
290
+ deduplicate_cross_chain: bool = False # Deduplicate same agent across chains
291
+
292
+ def to_dict(self) -> Dict[str, Any]:
293
+ """Convert to dictionary, filtering out None values."""
294
+ return {k: v for k, v in self.__dict__.items() if v is not None}
295
+
296
+
297
+ @dataclass
298
+ class SearchFeedbackParams:
299
+ """Parameters for feedback search."""
300
+ agents: Optional[List[AgentId]] = None
301
+ tags: Optional[List[str]] = None
302
+ reviewers: Optional[List[Address]] = None
303
+ capabilities: Optional[List[str]] = None
304
+ skills: Optional[List[str]] = None
305
+ tasks: Optional[List[str]] = None
306
+ names: Optional[List[str]] = None # MCP tool/resource/prompt names
307
+ minScore: Optional[int] = None # 0-100
308
+ maxScore: Optional[int] = None # 0-100
309
+ includeRevoked: bool = False
310
+
311
+ def to_dict(self) -> Dict[str, Any]:
312
+ """Convert to dictionary, filtering out None values."""
313
+ return {k: v for k, v in self.__dict__.items() if v is not None}