agent0-sdk 0.2.0__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-shipyard/filecoin-pin"
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,311 @@
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
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
+
32
+
33
+ class TrustModel(Enum):
34
+ """Trust models supported by the SDK."""
35
+ REPUTATION = "reputation"
36
+ CRYPTO_ECONOMIC = "crypto-economic"
37
+ TEE_ATTESTATION = "tee-attestation"
38
+
39
+
40
+ @dataclass
41
+ class Endpoint:
42
+ """Represents an agent endpoint."""
43
+ type: EndpointType
44
+ value: str # endpoint value (URL, name, DID, ENS)
45
+ meta: Dict[str, Any] = field(default_factory=dict) # optional metadata
46
+
47
+
48
+ @dataclass
49
+ class RegistrationFile:
50
+ """Agent registration file structure."""
51
+ agentId: Optional[AgentId] = None # None until minted
52
+ agentURI: Optional[URI] = None # where this file is (or will be) published
53
+ name: str = ""
54
+ description: str = ""
55
+ image: Optional[URI] = None
56
+ walletAddress: Optional[Address] = None
57
+ walletChainId: Optional[int] = None # Chain ID for the wallet address
58
+ endpoints: List[Endpoint] = field(default_factory=list)
59
+ trustModels: List[Union[TrustModel, str]] = field(default_factory=list)
60
+ owners: List[Address] = field(default_factory=list) # from chain (read-only, hydrated)
61
+ operators: List[Address] = field(default_factory=list) # from chain (read-only, hydrated)
62
+ active: bool = False # SDK extension flag
63
+ x402support: bool = False # Binary flag for x402 payment support
64
+ metadata: Dict[str, Any] = field(default_factory=dict) # arbitrary, SDK-managed
65
+ updatedAt: Timestamp = field(default_factory=lambda: int(datetime.now().timestamp()))
66
+
67
+ def __str__(self) -> str:
68
+ """String representation as JSON."""
69
+ # Use stored registry info if available
70
+ chain_id = getattr(self, '_chain_id', None)
71
+ registry_address = getattr(self, '_registry_address', None)
72
+ return json.dumps(self.to_dict(chain_id, registry_address), indent=2, default=str)
73
+
74
+ def __repr__(self) -> str:
75
+ """Developer representation."""
76
+ return f"RegistrationFile(agentId={self.agentId}, agentURI={self.agentURI}, name={self.name})"
77
+
78
+ def to_dict(self, chain_id: Optional[int] = None, identity_registry_address: Optional[str] = None) -> Dict[str, Any]:
79
+ """Convert to dictionary for JSON serialization."""
80
+ # Build endpoints array
81
+ endpoints = []
82
+ for endpoint in self.endpoints:
83
+ endpoint_dict = {
84
+ "name": endpoint.type.value,
85
+ "endpoint": endpoint.value,
86
+ **endpoint.meta
87
+ }
88
+ endpoints.append(endpoint_dict)
89
+
90
+ # Add walletAddress as an endpoint if present
91
+ if self.walletAddress:
92
+ # Use stored walletChainId if available, otherwise extract from agentId
93
+ chain_id_for_wallet = self.walletChainId
94
+ if chain_id_for_wallet is None:
95
+ # Extract chain ID from agentId if available, otherwise use 1 as default
96
+ chain_id_for_wallet = 1 # Default to mainnet
97
+ if self.agentId and ":" in self.agentId:
98
+ try:
99
+ chain_id_for_wallet = int(self.agentId.split(":")[1])
100
+ except (ValueError, IndexError):
101
+ chain_id_for_wallet = 1
102
+
103
+ endpoints.append({
104
+ "name": "agentWallet",
105
+ "endpoint": f"eip155:{chain_id_for_wallet}:{self.walletAddress}"
106
+ })
107
+
108
+ # Build registrations array
109
+ registrations = []
110
+ if self.agentId:
111
+ agent_id_int = int(self.agentId.split(":")[-1]) if ":" in self.agentId else int(self.agentId)
112
+ agent_registry = f"eip155:{chain_id}:{identity_registry_address}" if chain_id and identity_registry_address else f"eip155:1:{{identityRegistry}}"
113
+ registrations.append({
114
+ "agentId": agent_id_int,
115
+ "agentRegistry": agent_registry
116
+ })
117
+
118
+ return {
119
+ "type": "https://eips.ethereum.org/EIPS/eip-8004#registration-v1",
120
+ "name": self.name,
121
+ "description": self.description,
122
+ "image": self.image,
123
+ "endpoints": endpoints,
124
+ "registrations": registrations,
125
+ "supportedTrust": [tm.value if isinstance(tm, TrustModel) else tm for tm in self.trustModels],
126
+ "active": self.active,
127
+ "x402support": self.x402support,
128
+ "updatedAt": self.updatedAt,
129
+ }
130
+
131
+ @classmethod
132
+ def from_dict(cls, data: Dict[str, Any]) -> RegistrationFile:
133
+ """Create from dictionary."""
134
+ endpoints = []
135
+ for ep_data in data.get("endpoints", []):
136
+ name = ep_data["name"]
137
+ # Special handling for agentWallet - it's not a standard endpoint type
138
+ if name == "agentWallet":
139
+ # Skip agentWallet endpoints as they're handled separately via walletAddress field
140
+ continue
141
+
142
+ ep_type = EndpointType(name)
143
+ ep_value = ep_data["endpoint"]
144
+ ep_meta = {k: v for k, v in ep_data.items() if k not in ["name", "endpoint"]}
145
+ endpoints.append(Endpoint(type=ep_type, value=ep_value, meta=ep_meta))
146
+
147
+ trust_models = []
148
+ for tm in data.get("supportedTrust", []):
149
+ try:
150
+ trust_models.append(TrustModel(tm))
151
+ except ValueError:
152
+ trust_models.append(tm) # custom string
153
+
154
+ return cls(
155
+ agentId=data.get("agentId"),
156
+ agentURI=data.get("agentURI"),
157
+ name=data.get("name", ""),
158
+ description=data.get("description", ""),
159
+ image=data.get("image"),
160
+ walletAddress=data.get("walletAddress"),
161
+ walletChainId=data.get("walletChainId"),
162
+ endpoints=endpoints,
163
+ trustModels=trust_models,
164
+ active=data.get("active", False),
165
+ x402support=data.get("x402support", False),
166
+ metadata=data.get("metadata", {}),
167
+ updatedAt=data.get("updatedAt", int(datetime.now().timestamp())),
168
+ )
169
+
170
+
171
+ @dataclass
172
+ class AgentSummary:
173
+ """Summary information for agent discovery and search."""
174
+ chainId: ChainId
175
+ agentId: AgentId
176
+ name: str
177
+ image: Optional[URI]
178
+ description: str
179
+ owners: List[Address]
180
+ operators: List[Address]
181
+ mcp: bool
182
+ a2a: bool
183
+ ens: Optional[str]
184
+ did: Optional[str]
185
+ walletAddress: Optional[Address]
186
+ supportedTrusts: List[str] # normalized string keys
187
+ a2aSkills: List[str]
188
+ mcpTools: List[str]
189
+ mcpPrompts: List[str]
190
+ mcpResources: List[str]
191
+ active: bool
192
+ x402support: bool = False
193
+ extras: Dict[str, Any] = field(default_factory=dict)
194
+
195
+
196
+ @dataclass
197
+ class Feedback:
198
+ """Feedback data structure."""
199
+ id: tuple # (agentId, clientAddress, feedbackIndex) - tuple for efficiency
200
+ agentId: AgentId
201
+ reviewer: Address
202
+ score: Optional[int] # 0-100
203
+ tags: List[str] = field(default_factory=list)
204
+ text: Optional[str] = None
205
+ context: Optional[Dict[str, Any]] = None
206
+ proof_of_payment: Optional[Dict[str, Any]] = None
207
+ fileURI: Optional[URI] = None
208
+ createdAt: Timestamp = field(default_factory=lambda: int(datetime.now().timestamp()))
209
+ answers: List[Dict[str, Any]] = field(default_factory=list)
210
+ isRevoked: bool = False
211
+
212
+ # Off-chain only fields (not stored on blockchain)
213
+ capability: Optional[str] = None # MCP capability: "prompts", "resources", "tools", "completions"
214
+ name: Optional[str] = None # MCP tool/resource name
215
+ skill: Optional[str] = None # A2A skill
216
+ task: Optional[str] = None # A2A task
217
+
218
+ def __post_init__(self):
219
+ """Validate and set ID after initialization."""
220
+ if isinstance(self.id, str):
221
+ # Convert string ID to tuple
222
+ parsed_id = self.from_id_string(self.id)
223
+ self.id = parsed_id
224
+ elif not isinstance(self.id, tuple) or len(self.id) != 3:
225
+ raise ValueError(f"Feedback ID must be tuple of (agentId, clientAddress, feedbackIndex), got: {self.id}")
226
+
227
+ @property
228
+ def id_string(self) -> str:
229
+ """Get string representation of ID for external APIs."""
230
+ return f"{self.id[0]}:{self.id[1]}:{self.id[2]}"
231
+
232
+ @classmethod
233
+ def create_id(cls, agentId: AgentId, clientAddress: Address, feedbackIndex: int) -> tuple:
234
+ """Create feedback ID tuple with normalized address."""
235
+ # Normalize address to lowercase for consistency
236
+ # Ethereum addresses are case-insensitive, but we store them in lowercase
237
+ if isinstance(clientAddress, str) and (clientAddress.startswith("0x") or clientAddress.startswith("0X")):
238
+ normalized_address = "0x" + clientAddress[2:].lower()
239
+ else:
240
+ normalized_address = clientAddress.lower() if isinstance(clientAddress, str) else str(clientAddress).lower()
241
+ return (agentId, normalized_address, feedbackIndex)
242
+
243
+ @classmethod
244
+ def from_id_string(cls, id_string: str) -> tuple:
245
+ """Parse feedback ID from string.
246
+
247
+ Format: agentId:clientAddress:feedbackIndex
248
+ Note: agentId may contain colons (e.g., "11155111:123"), so we need to split from the right.
249
+ """
250
+ parts = id_string.rsplit(":", 2) # Split from right, max 2 splits
251
+ if len(parts) != 3:
252
+ raise ValueError(f"Invalid feedback ID format: {id_string}")
253
+
254
+ try:
255
+ feedback_index = int(parts[2])
256
+ except ValueError:
257
+ raise ValueError(f"Invalid feedback index: {parts[2]}")
258
+
259
+ # Normalize address to lowercase for consistency
260
+ client_address = parts[1]
261
+ if isinstance(client_address, str) and (client_address.startswith("0x") or client_address.startswith("0X")):
262
+ normalized_address = "0x" + client_address[2:].lower()
263
+ else:
264
+ normalized_address = client_address.lower() if isinstance(client_address, str) else str(client_address).lower()
265
+
266
+ return (parts[0], normalized_address, feedback_index)
267
+
268
+
269
+ @dataclass
270
+ class SearchParams:
271
+ """Parameters for agent search."""
272
+ chains: Optional[List[ChainId]] = None
273
+ name: Optional[str] = None # case-insensitive substring
274
+ description: Optional[str] = None # semantic; vector distance < threshold
275
+ owners: Optional[List[Address]] = None
276
+ operators: Optional[List[Address]] = None
277
+ mcp: Optional[bool] = None
278
+ a2a: Optional[bool] = None
279
+ ens: Optional[str] = None # exact, case-insensitive
280
+ did: Optional[str] = None # exact
281
+ walletAddress: Optional[Address] = None
282
+ supportedTrust: Optional[List[str]] = None
283
+ a2aSkills: Optional[List[str]] = None
284
+ mcpTools: Optional[List[str]] = None
285
+ mcpPrompts: Optional[List[str]] = None
286
+ mcpResources: Optional[List[str]] = None
287
+ active: Optional[bool] = True
288
+ x402support: Optional[bool] = None
289
+
290
+ def to_dict(self) -> Dict[str, Any]:
291
+ """Convert to dictionary, filtering out None values."""
292
+ return {k: v for k, v in self.__dict__.items() if v is not None}
293
+
294
+
295
+ @dataclass
296
+ class SearchFeedbackParams:
297
+ """Parameters for feedback search."""
298
+ agents: Optional[List[AgentId]] = None
299
+ tags: Optional[List[str]] = None
300
+ reviewers: Optional[List[Address]] = None
301
+ capabilities: Optional[List[str]] = None
302
+ skills: Optional[List[str]] = None
303
+ tasks: Optional[List[str]] = None
304
+ names: Optional[List[str]] = None # MCP tool/resource/prompt names
305
+ minScore: Optional[int] = None # 0-100
306
+ maxScore: Optional[int] = None # 0-100
307
+ includeRevoked: bool = False
308
+
309
+ def to_dict(self) -> Dict[str, Any]:
310
+ """Convert to dictionary, filtering out None values."""
311
+ return {k: v for k, v in self.__dict__.items() if v is not None}