agent0-sdk 1.4.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,357 @@
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, file_name: str = "file.json") -> 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': (file_name, 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
+ file_name = kwargs.pop("file_name", None)
237
+ if self.pinata_enabled:
238
+ return self._pin_to_pinata(data, file_name=file_name or "file.json")
239
+ elif self.filecoin_pin_enabled:
240
+ # Create temporary file for Filecoin Pin
241
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
242
+ f.write(data)
243
+ temp_path = f.name
244
+
245
+ try:
246
+ cid = self._pin_to_filecoin(temp_path)
247
+ return cid
248
+ finally:
249
+ os.unlink(temp_path)
250
+ else:
251
+ return self._pin_to_local_ipfs(data, **kwargs)
252
+
253
+ def add_file(self, filepath: str, **kwargs) -> str:
254
+ """Add file to IPFS and return CID."""
255
+ file_name = kwargs.pop("file_name", None)
256
+ if self.pinata_enabled:
257
+ # Read file and send to Pinata
258
+ with open(filepath, 'r') as f:
259
+ data = f.read()
260
+ return self._pin_to_pinata(data, file_name=file_name or "file.json")
261
+ elif self.filecoin_pin_enabled:
262
+ return self._pin_to_filecoin(filepath)
263
+ else:
264
+ if not self.client:
265
+ raise RuntimeError("No IPFS client available")
266
+ result = self.client.add(filepath, **kwargs)
267
+ return result['Hash']
268
+
269
+ def get(self, cid: str) -> str:
270
+ """Get data from IPFS by CID."""
271
+ # Extract CID from IPFS URL if needed
272
+ if cid.startswith("ipfs://"):
273
+ cid = cid[7:] # Remove "ipfs://" prefix
274
+
275
+ # Pinata and Filecoin Pin both use IPFS gateways for retrieval
276
+ if self.pinata_enabled or self.filecoin_pin_enabled:
277
+ # Use IPFS gateways for retrieval
278
+ import requests
279
+ try:
280
+ # Try multiple gateways for reliability, prioritizing Pinata v3 gateway
281
+ gateways = [
282
+ f"https://gateway.pinata.cloud/ipfs/{cid}",
283
+ f"https://ipfs.io/ipfs/{cid}",
284
+ f"https://dweb.link/ipfs/{cid}"
285
+ ]
286
+
287
+ for gateway in gateways:
288
+ try:
289
+ response = requests.get(gateway, timeout=10)
290
+ response.raise_for_status()
291
+ return response.text
292
+ except Exception:
293
+ continue
294
+
295
+ raise RuntimeError(f"Failed to retrieve data from all IPFS gateways")
296
+ except Exception as e:
297
+ raise RuntimeError(f"Failed to retrieve data from IPFS gateway: {e}")
298
+ else:
299
+ if not self.client:
300
+ raise RuntimeError("No IPFS client available")
301
+ return self.client.cat(cid).decode('utf-8')
302
+
303
+ def get_json(self, cid: str) -> Dict[str, Any]:
304
+ """Get JSON data from IPFS by CID."""
305
+ data = self.get(cid)
306
+ return json.loads(data)
307
+
308
+ def pin(self, cid: str) -> Dict[str, Any]:
309
+ """Pin a CID to local node."""
310
+ if self.filecoin_pin_enabled:
311
+ # Filecoin Pin automatically pins data, so this is a no-op
312
+ return {"pinned": [cid]}
313
+ else:
314
+ if not self.client:
315
+ raise RuntimeError("No IPFS client available")
316
+ return self.client.pin.add(cid)
317
+
318
+ def unpin(self, cid: str) -> Dict[str, Any]:
319
+ """Unpin a CID from local node."""
320
+ if self.filecoin_pin_enabled:
321
+ # Filecoin Pin doesn't support unpinning in the same way
322
+ # This is a no-op for Filecoin Pin
323
+ return {"unpinned": [cid]}
324
+ else:
325
+ if not self.client:
326
+ raise RuntimeError("No IPFS client available")
327
+ return self.client.pin.rm(cid)
328
+
329
+ def add_json(self, data: Dict[str, Any], **kwargs) -> str:
330
+ """Add JSON data to IPFS and return CID."""
331
+ json_str = json.dumps(data, indent=2)
332
+ return self.add(json_str, **kwargs)
333
+
334
+ def addRegistrationFile(self, registrationFile: "RegistrationFile", chainId: Optional[int] = None, identityRegistryAddress: Optional[str] = None, **kwargs) -> str:
335
+ """Add registration file to IPFS and return CID."""
336
+ data = registrationFile.to_dict(chain_id=chainId, identity_registry_address=identityRegistryAddress)
337
+ return self.add_json(data, file_name="agent-registration.json", **kwargs)
338
+
339
+ def getRegistrationFile(self, cid: str) -> "RegistrationFile":
340
+ """Get registration file from IPFS by CID."""
341
+ from .models import RegistrationFile
342
+ data = self.get_json(cid)
343
+ return RegistrationFile.from_dict(data)
344
+
345
+ def addFeedbackFile(self, feedbackData: Dict[str, Any], **kwargs) -> str:
346
+ """Add feedback file to IPFS and return CID."""
347
+ return self.add_json(feedbackData, file_name="feedback.json", **kwargs)
348
+
349
+ def getFeedbackFile(self, cid: str) -> Dict[str, Any]:
350
+ """Get feedback file from IPFS by CID."""
351
+ return self.get_json(cid)
352
+
353
+ def close(self):
354
+ """Close IPFS client connection."""
355
+ if hasattr(self.client, 'close'):
356
+ self.client.close()
357
+
@@ -0,0 +1,303 @@
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
+ # Note: agentWallet is no longer included in endpoints array.
92
+ # It's now a reserved on-chain metadata key managed via Agent.setWallet().
93
+
94
+ # Build registrations array
95
+ registrations = []
96
+ if self.agentId:
97
+ agent_id_int = int(self.agentId.split(":")[-1]) if ":" in self.agentId else int(self.agentId)
98
+ agent_registry = f"eip155:{chain_id}:{identity_registry_address}" if chain_id and identity_registry_address else f"eip155:1:{{identityRegistry}}"
99
+ registrations.append({
100
+ "agentId": agent_id_int,
101
+ "agentRegistry": agent_registry
102
+ })
103
+
104
+ return {
105
+ "type": "https://eips.ethereum.org/EIPS/eip-8004#registration-v1",
106
+ "name": self.name,
107
+ "description": self.description,
108
+ "image": self.image,
109
+ "services": endpoints,
110
+ "registrations": registrations,
111
+ "supportedTrust": [tm.value if isinstance(tm, TrustModel) else tm for tm in self.trustModels],
112
+ "active": self.active,
113
+ "x402Support": self.x402support, # Use camelCase in JSON output per spec
114
+ "updatedAt": self.updatedAt,
115
+ }
116
+
117
+ @classmethod
118
+ def from_dict(cls, data: Dict[str, Any]) -> RegistrationFile:
119
+ """Create from dictionary."""
120
+ endpoints = []
121
+ raw_services = data.get("services", data.get("endpoints", []))
122
+ for ep_data in raw_services:
123
+ name = ep_data["name"]
124
+ # Special handling for agentWallet - it's not a standard endpoint type
125
+ if name == "agentWallet":
126
+ # Skip agentWallet endpoints as they're handled separately via walletAddress field
127
+ continue
128
+
129
+ ep_type = EndpointType(name)
130
+ ep_value = ep_data["endpoint"]
131
+ ep_meta = {k: v for k, v in ep_data.items() if k not in ["name", "endpoint"]}
132
+ endpoints.append(Endpoint(type=ep_type, value=ep_value, meta=ep_meta))
133
+
134
+ trust_models = []
135
+ for tm in data.get("supportedTrust", []):
136
+ try:
137
+ trust_models.append(TrustModel(tm))
138
+ except ValueError:
139
+ trust_models.append(tm) # custom string
140
+
141
+ return cls(
142
+ agentId=data.get("agentId"),
143
+ agentURI=data.get("agentURI"),
144
+ name=data.get("name", ""),
145
+ description=data.get("description", ""),
146
+ image=data.get("image"),
147
+ walletAddress=data.get("walletAddress"),
148
+ walletChainId=data.get("walletChainId"),
149
+ endpoints=endpoints,
150
+ trustModels=trust_models,
151
+ active=data.get("active", False),
152
+ x402support=data.get("x402Support", data.get("x402support", False)), # Handle both camelCase and lowercase
153
+ metadata=data.get("metadata", {}),
154
+ updatedAt=data.get("updatedAt", int(datetime.now().timestamp())),
155
+ )
156
+
157
+
158
+ @dataclass
159
+ class AgentSummary:
160
+ """Summary information for agent discovery and search."""
161
+ chainId: ChainId
162
+ agentId: AgentId
163
+ name: str
164
+ image: Optional[URI]
165
+ description: str
166
+ owners: List[Address]
167
+ operators: List[Address]
168
+ mcp: bool
169
+ a2a: bool
170
+ ens: Optional[str]
171
+ did: Optional[str]
172
+ walletAddress: Optional[Address]
173
+ supportedTrusts: List[str] # normalized string keys
174
+ a2aSkills: List[str]
175
+ mcpTools: List[str]
176
+ mcpPrompts: List[str]
177
+ mcpResources: List[str]
178
+ active: bool
179
+ x402support: bool = False
180
+ extras: Dict[str, Any] = field(default_factory=dict)
181
+
182
+
183
+ @dataclass
184
+ class Feedback:
185
+ """Feedback data structure."""
186
+ id: tuple # (agentId, clientAddress, feedbackIndex) - tuple for efficiency
187
+ agentId: AgentId
188
+ reviewer: Address
189
+ # ReputationRegistry Jan 2026: decimal value computed as (value:int256 / 10^valueDecimals).
190
+ # SDK exposes ONLY the computed value.
191
+ value: Optional[float]
192
+ tags: List[str] = field(default_factory=list)
193
+ text: Optional[str] = None
194
+ context: Optional[Dict[str, Any]] = None
195
+ proofOfPayment: Optional[Dict[str, Any]] = None
196
+ fileURI: Optional[URI] = None
197
+ endpoint: Optional[str] = None # Endpoint URI associated with feedback
198
+ createdAt: Timestamp = field(default_factory=lambda: int(datetime.now().timestamp()))
199
+ answers: List[Dict[str, Any]] = field(default_factory=list)
200
+ isRevoked: bool = False
201
+
202
+ # Off-chain only fields (not stored on blockchain)
203
+ capability: Optional[str] = None # MCP capability: "prompts", "resources", "tools", "completions"
204
+ name: Optional[str] = None # MCP tool/resource name
205
+ skill: Optional[str] = None # A2A skill
206
+ task: Optional[str] = None # A2A task
207
+
208
+ def __post_init__(self):
209
+ """Validate and set ID after initialization."""
210
+ if isinstance(self.id, str):
211
+ # Convert string ID to tuple
212
+ parsed_id = self.from_id_string(self.id)
213
+ self.id = parsed_id
214
+ elif not isinstance(self.id, tuple) or len(self.id) != 3:
215
+ raise ValueError(f"Feedback ID must be tuple of (agentId, clientAddress, feedbackIndex), got: {self.id}")
216
+
217
+ @property
218
+ def id_string(self) -> str:
219
+ """Get string representation of ID for external APIs."""
220
+ return f"{self.id[0]}:{self.id[1]}:{self.id[2]}"
221
+
222
+ @classmethod
223
+ def create_id(cls, agentId: AgentId, clientAddress: Address, feedbackIndex: int) -> tuple:
224
+ """Create feedback ID tuple with normalized address."""
225
+ # Normalize address to lowercase for consistency
226
+ # Ethereum addresses are case-insensitive, but we store them in lowercase
227
+ if isinstance(clientAddress, str) and (clientAddress.startswith("0x") or clientAddress.startswith("0X")):
228
+ normalized_address = "0x" + clientAddress[2:].lower()
229
+ else:
230
+ normalized_address = clientAddress.lower() if isinstance(clientAddress, str) else str(clientAddress).lower()
231
+ return (agentId, normalized_address, feedbackIndex)
232
+
233
+ @classmethod
234
+ def from_id_string(cls, id_string: str) -> tuple:
235
+ """Parse feedback ID from string.
236
+
237
+ Format: agentId:clientAddress:feedbackIndex
238
+ Note: agentId may contain colons (e.g., "11155111:123"), so we need to split from the right.
239
+ """
240
+ parts = id_string.rsplit(":", 2) # Split from right, max 2 splits
241
+ if len(parts) != 3:
242
+ raise ValueError(f"Invalid feedback ID format: {id_string}")
243
+
244
+ try:
245
+ feedback_index = int(parts[2])
246
+ except ValueError:
247
+ raise ValueError(f"Invalid feedback index: {parts[2]}")
248
+
249
+ # Normalize address to lowercase for consistency
250
+ client_address = parts[1]
251
+ if isinstance(client_address, str) and (client_address.startswith("0x") or client_address.startswith("0X")):
252
+ normalized_address = "0x" + client_address[2:].lower()
253
+ else:
254
+ normalized_address = client_address.lower() if isinstance(client_address, str) else str(client_address).lower()
255
+
256
+ return (parts[0], normalized_address, feedback_index)
257
+
258
+
259
+ @dataclass
260
+ class SearchParams:
261
+ """Parameters for agent search."""
262
+ chains: Optional[Union[List[ChainId], Literal["all"]]] = None
263
+ name: Optional[str] = None # case-insensitive substring
264
+ description: Optional[str] = None # semantic; vector distance < threshold
265
+ owners: Optional[List[Address]] = None
266
+ operators: Optional[List[Address]] = None
267
+ mcp: Optional[bool] = None
268
+ a2a: Optional[bool] = None
269
+ ens: Optional[str] = None # exact, case-insensitive
270
+ did: Optional[str] = None # exact
271
+ walletAddress: Optional[Address] = None
272
+ supportedTrust: Optional[List[str]] = None
273
+ a2aSkills: Optional[List[str]] = None
274
+ mcpTools: Optional[List[str]] = None
275
+ mcpPrompts: Optional[List[str]] = None
276
+ mcpResources: Optional[List[str]] = None
277
+ active: Optional[bool] = True
278
+ x402support: Optional[bool] = None
279
+ deduplicate_cross_chain: bool = False # Deduplicate same agent across chains
280
+
281
+ def to_dict(self) -> Dict[str, Any]:
282
+ """Convert to dictionary, filtering out None values."""
283
+ return {k: v for k, v in self.__dict__.items() if v is not None}
284
+
285
+
286
+ @dataclass
287
+ class SearchFeedbackParams:
288
+ """Parameters for feedback search."""
289
+ agents: Optional[List[AgentId]] = None
290
+ tags: Optional[List[str]] = None
291
+ reviewers: Optional[List[Address]] = None
292
+ capabilities: Optional[List[str]] = None
293
+ skills: Optional[List[str]] = None
294
+ tasks: Optional[List[str]] = None
295
+ names: Optional[List[str]] = None # MCP tool/resource/prompt names
296
+ endpoint: Optional[str] = None # Filter by endpoint URI
297
+ minValue: Optional[float] = None
298
+ maxValue: Optional[float] = None
299
+ includeRevoked: bool = False
300
+
301
+ def to_dict(self) -> Dict[str, Any]:
302
+ """Convert to dictionary, filtering out None values."""
303
+ return {k: v for k, v in self.__dict__.items() if v is not None}