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.
agent0_sdk/core/sdk.py ADDED
@@ -0,0 +1,1005 @@
1
+ """
2
+ Main SDK class for Agent0.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import json
9
+ import logging
10
+ import time
11
+ from typing import Any, Dict, List, Optional, Union, Literal
12
+ from datetime import datetime
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ from .models import (
17
+ AgentId, ChainId, Address, URI, Timestamp, IdemKey,
18
+ EndpointType, TrustModel, Endpoint, RegistrationFile,
19
+ AgentSummary, Feedback, SearchParams
20
+ )
21
+ from .web3_client import Web3Client
22
+ from .contracts import (
23
+ IDENTITY_REGISTRY_ABI, REPUTATION_REGISTRY_ABI, VALIDATION_REGISTRY_ABI,
24
+ DEFAULT_REGISTRIES, DEFAULT_SUBGRAPH_URLS
25
+ )
26
+ from .agent import Agent
27
+ from .indexer import AgentIndexer
28
+ from .ipfs_client import IPFSClient
29
+ from .feedback_manager import FeedbackManager
30
+ from .transaction_handle import TransactionHandle
31
+ from .subgraph_client import SubgraphClient
32
+
33
+
34
+ class SDK:
35
+ """Main SDK class for Agent0."""
36
+
37
+ def __init__(
38
+ self,
39
+ chainId: ChainId,
40
+ rpcUrl: str,
41
+ signer: Optional[Any] = None, # Optional for read-only operations
42
+ registryOverrides: Optional[Dict[ChainId, Dict[str, Address]]] = None,
43
+ indexingStore: Optional[Any] = None, # optional (e.g., sqlite/postgres/duckdb)
44
+ embeddings: Optional[Any] = None, # optional vector backend
45
+ # IPFS configuration
46
+ ipfs: Optional[str] = None, # "node", "filecoinPin", or "pinata"
47
+ # Direct IPFS node config
48
+ ipfsNodeUrl: Optional[str] = None,
49
+ # Filecoin Pin config
50
+ filecoinPrivateKey: Optional[str] = None,
51
+ # Pinata config
52
+ pinataJwt: Optional[str] = None,
53
+ # Subgraph configuration
54
+ subgraphOverrides: Optional[Dict[ChainId, str]] = None, # Override subgraph URLs per chain
55
+ ):
56
+ """Initialize the SDK."""
57
+ self.chainId = chainId
58
+ self.rpcUrl = rpcUrl
59
+ self.signer = signer
60
+
61
+ # Initialize Web3 client (with or without signer for read-only operations)
62
+ if signer:
63
+ if isinstance(signer, str):
64
+ self.web3_client = Web3Client(rpcUrl, private_key=signer)
65
+ else:
66
+ self.web3_client = Web3Client(rpcUrl, account=signer)
67
+ else:
68
+ # Read-only mode - no signer
69
+ self.web3_client = Web3Client(rpcUrl)
70
+
71
+ # Registry addresses
72
+ self.registry_overrides = registryOverrides or {}
73
+ self._registries = self._resolve_registries()
74
+
75
+ # Initialize contract instances
76
+ self._identity_registry = None
77
+ self._reputation_registry = None
78
+ self._validation_registry = None
79
+
80
+ # Resolve subgraph URL (with fallback chain)
81
+ self._subgraph_urls = {}
82
+ if subgraphOverrides:
83
+ self._subgraph_urls.update(subgraphOverrides)
84
+
85
+ # Get subgraph URL for current chain
86
+ resolved_subgraph_url = None
87
+
88
+ # Priority 1: Chain-specific override
89
+ if chainId in self._subgraph_urls:
90
+ resolved_subgraph_url = self._subgraph_urls[chainId]
91
+ # Priority 2: Default for chain
92
+ elif chainId in DEFAULT_SUBGRAPH_URLS:
93
+ resolved_subgraph_url = DEFAULT_SUBGRAPH_URLS[chainId]
94
+ else:
95
+ # No subgraph available - subgraph_client will be None
96
+ resolved_subgraph_url = None
97
+
98
+ # Initialize subgraph client if URL available
99
+ if resolved_subgraph_url:
100
+ self.subgraph_client = SubgraphClient(resolved_subgraph_url)
101
+ else:
102
+ self.subgraph_client = None
103
+
104
+ # Initialize services
105
+ self.indexer = AgentIndexer(
106
+ web3_client=self.web3_client,
107
+ store=indexingStore,
108
+ embeddings=embeddings,
109
+ subgraph_client=self.subgraph_client,
110
+ subgraph_url_overrides=self._subgraph_urls
111
+ )
112
+
113
+ # Initialize IPFS client based on configuration
114
+ self.ipfs_client = self._initialize_ipfs_client(
115
+ ipfs, ipfsNodeUrl, filecoinPrivateKey, pinataJwt
116
+ )
117
+
118
+ # Load registries before passing to FeedbackManager
119
+ identity_registry = self.identity_registry
120
+ reputation_registry = self.reputation_registry
121
+
122
+ self.feedback_manager = FeedbackManager(
123
+ subgraph_client=self.subgraph_client,
124
+ web3_client=self.web3_client,
125
+ ipfs_client=self.ipfs_client,
126
+ reputation_registry=reputation_registry,
127
+ identity_registry=identity_registry,
128
+ indexer=self.indexer # Pass indexer for unified search interface
129
+ )
130
+
131
+ def _resolve_registries(self) -> Dict[str, Address]:
132
+ """Resolve registry addresses for current chain."""
133
+ # Start with defaults
134
+ registries = DEFAULT_REGISTRIES.get(self.chainId, {}).copy()
135
+
136
+ # Apply overrides
137
+ if self.chainId in self.registry_overrides:
138
+ registries.update(self.registry_overrides[self.chainId])
139
+
140
+ return registries
141
+
142
+ def _initialize_ipfs_client(
143
+ self,
144
+ ipfs: Optional[str],
145
+ ipfsNodeUrl: Optional[str],
146
+ filecoinPrivateKey: Optional[str],
147
+ pinataJwt: Optional[str]
148
+ ) -> Optional[IPFSClient]:
149
+ """Initialize IPFS client based on configuration."""
150
+ if not ipfs:
151
+ return None
152
+
153
+ if ipfs == "node":
154
+ if not ipfsNodeUrl:
155
+ raise ValueError("ipfsNodeUrl is required when ipfs='node'")
156
+ return IPFSClient(url=ipfsNodeUrl, filecoin_pin_enabled=False)
157
+
158
+ elif ipfs == "filecoinPin":
159
+ if not filecoinPrivateKey:
160
+ raise ValueError("filecoinPrivateKey is required when ipfs='filecoinPin'")
161
+ return IPFSClient(
162
+ url=None,
163
+ filecoin_pin_enabled=True,
164
+ filecoin_private_key=filecoinPrivateKey
165
+ )
166
+
167
+ elif ipfs == "pinata":
168
+ if not pinataJwt:
169
+ raise ValueError("pinataJwt is required when ipfs='pinata'")
170
+ return IPFSClient(
171
+ url=None,
172
+ filecoin_pin_enabled=False,
173
+ pinata_enabled=True,
174
+ pinata_jwt=pinataJwt
175
+ )
176
+
177
+ else:
178
+ raise ValueError(f"Invalid ipfs value: {ipfs}. Must be 'node', 'filecoinPin', or 'pinata'")
179
+
180
+ @property
181
+ def isReadOnly(self) -> bool:
182
+ """Check if SDK is in read-only mode (no signer)."""
183
+ return self.signer is None
184
+
185
+ @property
186
+ def identity_registry(self):
187
+ """Get identity registry contract."""
188
+ if self._identity_registry is None:
189
+ address = self._registries.get("IDENTITY")
190
+ if not address:
191
+ raise ValueError(f"No identity registry address for chain {self.chainId}")
192
+ self._identity_registry = self.web3_client.get_contract(
193
+ address, IDENTITY_REGISTRY_ABI
194
+ )
195
+ return self._identity_registry
196
+
197
+ @property
198
+ def reputation_registry(self):
199
+ """Get reputation registry contract."""
200
+ if self._reputation_registry is None:
201
+ address = self._registries.get("REPUTATION")
202
+ if not address:
203
+ raise ValueError(f"No reputation registry address for chain {self.chainId}")
204
+ self._reputation_registry = self.web3_client.get_contract(
205
+ address, REPUTATION_REGISTRY_ABI
206
+ )
207
+ return self._reputation_registry
208
+
209
+ @property
210
+ def validation_registry(self):
211
+ """Get validation registry contract."""
212
+ if self._validation_registry is None:
213
+ address = self._registries.get("VALIDATION")
214
+ if not address:
215
+ raise ValueError(f"No validation registry address for chain {self.chainId}")
216
+ self._validation_registry = self.web3_client.get_contract(
217
+ address, VALIDATION_REGISTRY_ABI
218
+ )
219
+ return self._validation_registry
220
+
221
+ def chain_id(self) -> ChainId:
222
+ """Get current chain ID."""
223
+ return self.chainId
224
+
225
+ def registries(self) -> Dict[str, Address]:
226
+ """Get resolved addresses for current chain."""
227
+ return self._registries.copy()
228
+
229
+ def get_subgraph_client(self, chain_id: Optional[ChainId] = None) -> Optional[SubgraphClient]:
230
+ """
231
+ Get subgraph client for a specific chain.
232
+
233
+ Args:
234
+ chain_id: Chain ID (defaults to current chain)
235
+
236
+ Returns:
237
+ SubgraphClient instance or None if no subgraph available
238
+ """
239
+ target_chain = chain_id if chain_id is not None else self.chainId
240
+
241
+ # Check if we already have a client for this chain
242
+ if target_chain == self.chainId and self.subgraph_client:
243
+ return self.subgraph_client
244
+
245
+ # Resolve URL for target chain
246
+ url = None
247
+ if target_chain in self._subgraph_urls:
248
+ url = self._subgraph_urls[target_chain]
249
+ elif target_chain in DEFAULT_SUBGRAPH_URLS:
250
+ url = DEFAULT_SUBGRAPH_URLS[target_chain]
251
+
252
+ if url:
253
+ return SubgraphClient(url)
254
+ return None
255
+
256
+ def set_chain(self, chain_id: ChainId) -> None:
257
+ """Switch chains (advanced)."""
258
+ self.chainId = chain_id
259
+ self._registries = self._resolve_registries()
260
+ # Reset contract instances
261
+ self._identity_registry = None
262
+ self._reputation_registry = None
263
+ self._validation_registry = None
264
+
265
+ # Agent lifecycle methods
266
+ def createAgent(
267
+ self,
268
+ name: str,
269
+ description: str,
270
+ image: Optional[URI] = None,
271
+ ) -> Agent:
272
+ """Create a new agent (off-chain object in memory)."""
273
+ registration_file = RegistrationFile(
274
+ name=name,
275
+ description=description,
276
+ image=image,
277
+ # Default trust model: reputation (if caller doesn't set one explicitly).
278
+ trustModels=[TrustModel.REPUTATION],
279
+ updatedAt=int(time.time())
280
+ )
281
+ return Agent(sdk=self, registration_file=registration_file)
282
+
283
+ def loadAgent(self, agentId: AgentId) -> Agent:
284
+ """Load an existing agent (hydrates from registration file if registered).
285
+
286
+ Note: Agents can be minted with an empty token URI (e.g. IPFS flow where publish fails).
287
+ In that case we return a partially-hydrated Agent with an empty registration file so the
288
+ caller can resume publishing and set the URI later.
289
+ """
290
+ # Convert agentId to string if it's an integer
291
+ agentId = str(agentId)
292
+
293
+ # Parse agent ID
294
+ if ":" in agentId:
295
+ chain_id, token_id = agentId.split(":", 1)
296
+ if int(chain_id) != self.chainId:
297
+ raise ValueError(f"Agent {agentId} is not on current chain {self.chainId}")
298
+ else:
299
+ token_id = agentId
300
+
301
+ # Get token URI from contract
302
+ try:
303
+ agent_uri = self.web3_client.call_contract(
304
+ self.identity_registry, "tokenURI", int(token_id) # tokenURI is ERC-721 standard, but represents agentURI
305
+ )
306
+ except Exception as e:
307
+ raise ValueError(f"Failed to load agent {agentId}: {e}")
308
+
309
+ # Load registration file (or fall back to a minimal file if agent URI is missing)
310
+ registration_file = self._load_registration_file(agent_uri)
311
+ registration_file.agentId = agentId
312
+ registration_file.agentURI = agent_uri if agent_uri else None
313
+
314
+ if not agent_uri or not str(agent_uri).strip():
315
+ logger.warning(
316
+ f"Agent {agentId} has no agentURI set on-chain yet. "
317
+ "Returning a partial agent; update info and call registerIPFS() to publish and set URI."
318
+ )
319
+
320
+ # Store registry address for proper JSON generation
321
+ registry_address = self._registries.get("IDENTITY")
322
+ if registry_address:
323
+ registration_file._registry_address = registry_address
324
+ registration_file._chain_id = self.chainId
325
+
326
+ # Hydrate on-chain data
327
+ self._hydrate_agent_data(registration_file, int(token_id))
328
+
329
+ return Agent(sdk=self, registration_file=registration_file)
330
+
331
+ def _load_registration_file(self, uri: str) -> RegistrationFile:
332
+ """Load registration file from URI.
333
+
334
+ If uri is empty/None/whitespace, returns an empty RegistrationFile to allow resume flows.
335
+ """
336
+ if not uri or not str(uri).strip():
337
+ return RegistrationFile()
338
+
339
+ if uri.startswith("ipfs://"):
340
+ if not self.ipfs_client:
341
+ raise ValueError("IPFS client not configured")
342
+ content = self.ipfs_client.get(uri)
343
+ elif uri.startswith("http"):
344
+ try:
345
+ import requests
346
+ response = requests.get(uri)
347
+ response.raise_for_status()
348
+ content = response.text
349
+ except ImportError:
350
+ raise ImportError("requests not installed. Install with: pip install requests")
351
+ else:
352
+ raise ValueError(f"Unsupported URI scheme: {uri}")
353
+
354
+ data = json.loads(content)
355
+ return RegistrationFile.from_dict(data)
356
+
357
+ def _hydrate_agent_data(self, registration_file: RegistrationFile, token_id: int):
358
+ """Hydrate agent data from on-chain sources."""
359
+ # Get owner
360
+ owner = self.web3_client.call_contract(
361
+ self.identity_registry, "ownerOf", token_id
362
+ )
363
+ registration_file.owners = [owner]
364
+
365
+ # Get operators (this would require additional contract calls)
366
+ # For now, we'll leave it empty
367
+ registration_file.operators = []
368
+
369
+ # Hydrate agentWallet from on-chain (now uses getAgentWallet() instead of metadata)
370
+ agent_id = token_id
371
+ try:
372
+ # Get agentWallet using the new dedicated function
373
+ wallet_address = self.web3_client.call_contract(
374
+ self.identity_registry, "getAgentWallet", agent_id
375
+ )
376
+ if wallet_address and wallet_address != "0x0000000000000000000000000000000000000000":
377
+ registration_file.walletAddress = wallet_address
378
+ # If wallet is read from on-chain, use current chain ID
379
+ # (the chain ID from the registration file might be outdated)
380
+ registration_file.walletChainId = self.chainId
381
+ except Exception as e:
382
+ # No on-chain wallet set, will fall back to registration file
383
+ pass
384
+
385
+ try:
386
+ # Try to get agentName (ENS) from on-chain metadata
387
+ name_bytes = self.web3_client.call_contract(
388
+ self.identity_registry, "getMetadata", agent_id, "agentName"
389
+ )
390
+ if name_bytes and len(name_bytes) > 0:
391
+ ens_name = name_bytes.decode('utf-8')
392
+ # Add ENS endpoint to registration file
393
+ from .models import EndpointType, Endpoint
394
+ # Remove existing ENS endpoints
395
+ registration_file.endpoints = [
396
+ ep for ep in registration_file.endpoints
397
+ if ep.type != EndpointType.ENS
398
+ ]
399
+ # Add new ENS endpoint
400
+ ens_endpoint = Endpoint(
401
+ type=EndpointType.ENS,
402
+ value=ens_name,
403
+ meta={"version": "1.0"}
404
+ )
405
+ registration_file.endpoints.append(ens_endpoint)
406
+ except Exception as e:
407
+ # No on-chain ENS name, will fall back to registration file
408
+ pass
409
+
410
+ # Try to get custom metadata keys from registration file and check on-chain
411
+ # Note: We can't enumerate on-chain metadata keys, so we check each key from the registration file
412
+ # Also check for common custom metadata keys that might exist on-chain
413
+ keys_to_check = list(registration_file.metadata.keys())
414
+ # Also check for known metadata keys that might have been set on-chain
415
+ known_keys = ["testKey", "version", "timestamp", "customField", "anotherField", "numericField"]
416
+ for key in known_keys:
417
+ if key not in keys_to_check:
418
+ keys_to_check.append(key)
419
+
420
+ for key in keys_to_check:
421
+ try:
422
+ value_bytes = self.web3_client.call_contract(
423
+ self.identity_registry, "getMetadata", agent_id, key
424
+ )
425
+ if value_bytes and len(value_bytes) > 0:
426
+ value_str = value_bytes.decode('utf-8')
427
+ # Try to convert back to original type if possible
428
+ try:
429
+ # Try integer
430
+ value_int = int(value_str)
431
+ # Check if it's actually stored as integer in metadata or if it was originally a string
432
+ registration_file.metadata[key] = value_str # Keep as string for now
433
+ except ValueError:
434
+ # Try float
435
+ try:
436
+ value_float = float(value_str)
437
+ registration_file.metadata[key] = value_str # Keep as string for now
438
+ except ValueError:
439
+ registration_file.metadata[key] = value_str
440
+ except Exception as e:
441
+ # Keep registration file value if on-chain not found
442
+ pass
443
+
444
+ # Discovery and indexing
445
+ def refreshAgentIndex(self, agentId: AgentId, deep: bool = False) -> AgentSummary:
446
+ """Refresh index for a single agent."""
447
+ return asyncio.run(self.indexer.refresh_agent(agentId, deep=deep))
448
+
449
+ def refreshIndex(
450
+ self,
451
+ agentIds: Optional[List[AgentId]] = None,
452
+ concurrency: int = 8,
453
+ ) -> List[AgentSummary]:
454
+ """Refresh index for multiple agents."""
455
+ return asyncio.run(self.indexer.refresh_agents(agentIds, concurrency))
456
+
457
+ def getAgent(self, agentId: AgentId) -> AgentSummary:
458
+ """Get agent summary from index."""
459
+ return self.indexer.get_agent(agentId)
460
+
461
+ def searchAgents(
462
+ self,
463
+ params: Union[SearchParams, Dict[str, Any], None] = None,
464
+ sort: Union[str, List[str], None] = None,
465
+ page_size: int = 50,
466
+ cursor: Optional[str] = None,
467
+ **kwargs # Accept search criteria as kwargs for better DX
468
+ ) -> Dict[str, Any]:
469
+ """Search for agents.
470
+
471
+ Examples:
472
+ # Simple kwargs for better developer experience
473
+ sdk.searchAgents(name="Test")
474
+ sdk.searchAgents(mcpTools=["code_generation"], active=True)
475
+
476
+ # Explicit SearchParams (for complex queries or IDE autocomplete)
477
+ sdk.searchAgents(SearchParams(name="Test", mcpTools=["code_generation"]))
478
+
479
+ # With pagination
480
+ sdk.searchAgents(name="Test", page_size=10)
481
+ """
482
+ # If kwargs provided, use them instead of params
483
+ if kwargs and params is None:
484
+ params = SearchParams(**kwargs)
485
+ elif params is None:
486
+ params = SearchParams()
487
+ elif isinstance(params, dict):
488
+ params = SearchParams(**params)
489
+
490
+ if sort is None:
491
+ sort = ["updatedAt:desc"]
492
+ elif isinstance(sort, str):
493
+ sort = [sort]
494
+
495
+ return self.indexer.search_agents(params, sort, page_size, cursor)
496
+
497
+ # Feedback methods are defined later in this class (single authoritative API).
498
+
499
+ def searchAgentsByReputation(
500
+ self,
501
+ agents: Optional[List[AgentId]] = None,
502
+ tags: Optional[List[str]] = None,
503
+ reviewers: Optional[List[Address]] = None,
504
+ capabilities: Optional[List[str]] = None,
505
+ skills: Optional[List[str]] = None,
506
+ tasks: Optional[List[str]] = None,
507
+ names: Optional[List[str]] = None,
508
+ minAverageValue: Optional[float] = None,
509
+ includeRevoked: bool = False,
510
+ page_size: int = 50,
511
+ cursor: Optional[str] = None,
512
+ sort: Optional[List[str]] = None,
513
+ chains: Optional[Union[List[ChainId], Literal["all"]]] = None,
514
+ ) -> Dict[str, Any]:
515
+ """Search agents filtered by reputation criteria."""
516
+ # Handle multi-chain search
517
+ if chains:
518
+ # Expand "all" if needed
519
+ if chains == "all":
520
+ chains = self.indexer._get_all_configured_chains()
521
+
522
+ # If multiple chains or single chain different from default
523
+ if isinstance(chains, list) and len(chains) > 0:
524
+ if len(chains) > 1 or (len(chains) == 1 and chains[0] != self.chainId):
525
+ return asyncio.run(
526
+ self._search_agents_by_reputation_across_chains(
527
+ agents, tags, reviewers, capabilities, skills, tasks, names,
528
+ minAverageValue, includeRevoked, page_size, cursor, sort, chains
529
+ )
530
+ )
531
+
532
+ # Single chain search (existing behavior)
533
+ if not self.subgraph_client:
534
+ raise ValueError("Subgraph client required for searchAgentsByReputation")
535
+
536
+ if sort is None:
537
+ sort = ["createdAt:desc"]
538
+
539
+ skip = 0
540
+ if cursor:
541
+ try:
542
+ skip = int(cursor)
543
+ except ValueError:
544
+ skip = 0
545
+
546
+ order_by = "createdAt"
547
+ order_direction = "desc"
548
+ if sort and len(sort) > 0:
549
+ sort_field = sort[0].split(":")
550
+ order_by = sort_field[0] if len(sort_field) >= 1 else order_by
551
+ order_direction = sort_field[1] if len(sort_field) >= 2 else order_direction
552
+
553
+ try:
554
+ agents_data = self.subgraph_client.search_agents_by_reputation(
555
+ agents=agents,
556
+ tags=tags,
557
+ reviewers=reviewers,
558
+ capabilities=capabilities,
559
+ skills=skills,
560
+ tasks=tasks,
561
+ names=names,
562
+ minAverageValue=minAverageValue,
563
+ includeRevoked=includeRevoked,
564
+ first=page_size,
565
+ skip=skip,
566
+ order_by=order_by,
567
+ order_direction=order_direction
568
+ )
569
+
570
+ from .models import AgentSummary
571
+ results = []
572
+ for agent_data in agents_data:
573
+ reg_file = agent_data.get('registrationFile') or {}
574
+ if not isinstance(reg_file, dict):
575
+ reg_file = {}
576
+
577
+ agent_summary = AgentSummary(
578
+ chainId=int(agent_data.get('chainId', 0)),
579
+ agentId=agent_data.get('id'),
580
+ name=reg_file.get('name', f"Agent {agent_data.get('id')}"),
581
+ image=reg_file.get('image'),
582
+ description=reg_file.get('description', ''),
583
+ owners=[agent_data.get('owner', '')],
584
+ operators=agent_data.get('operators', []),
585
+ mcp=reg_file.get('mcpEndpoint') is not None,
586
+ a2a=reg_file.get('a2aEndpoint') is not None,
587
+ ens=reg_file.get('ens'),
588
+ did=reg_file.get('did'),
589
+ walletAddress=reg_file.get('agentWallet'),
590
+ supportedTrusts=reg_file.get('supportedTrusts', []),
591
+ a2aSkills=reg_file.get('a2aSkills', []),
592
+ mcpTools=reg_file.get('mcpTools', []),
593
+ mcpPrompts=reg_file.get('mcpPrompts', []),
594
+ mcpResources=reg_file.get('mcpResources', []),
595
+ active=reg_file.get('active', True),
596
+ x402support=reg_file.get('x402Support', reg_file.get('x402support', False)),
597
+ extras={'averageValue': agent_data.get('averageValue')}
598
+ )
599
+ results.append(agent_summary)
600
+
601
+ next_cursor = str(skip + len(results)) if len(results) == page_size else None
602
+ return {"items": results, "nextCursor": next_cursor}
603
+
604
+ except Exception as e:
605
+ raise ValueError(f"Failed to search agents by reputation: {e}")
606
+
607
+ async def _search_agents_by_reputation_across_chains(
608
+ self,
609
+ agents: Optional[List[AgentId]],
610
+ tags: Optional[List[str]],
611
+ reviewers: Optional[List[Address]],
612
+ capabilities: Optional[List[str]],
613
+ skills: Optional[List[str]],
614
+ tasks: Optional[List[str]],
615
+ names: Optional[List[str]],
616
+ minAverageValue: Optional[float],
617
+ includeRevoked: bool,
618
+ page_size: int,
619
+ cursor: Optional[str],
620
+ sort: Optional[List[str]],
621
+ chains: List[ChainId],
622
+ ) -> Dict[str, Any]:
623
+ """
624
+ Search agents by reputation across multiple chains in parallel.
625
+
626
+ Similar to indexer._search_agents_across_chains() but for reputation-based search.
627
+ """
628
+ import time
629
+ start_time = time.time()
630
+
631
+ if sort is None:
632
+ sort = ["createdAt:desc"]
633
+
634
+ order_by = "createdAt"
635
+ order_direction = "desc"
636
+ if sort and len(sort) > 0:
637
+ sort_field = sort[0].split(":")
638
+ order_by = sort_field[0] if len(sort_field) >= 1 else order_by
639
+ order_direction = sort_field[1] if len(sort_field) >= 2 else order_direction
640
+
641
+ skip = 0
642
+ if cursor:
643
+ try:
644
+ skip = int(cursor)
645
+ except ValueError:
646
+ skip = 0
647
+
648
+ # Define async function for querying a single chain
649
+ async def query_single_chain(chain_id: int) -> Dict[str, Any]:
650
+ """Query one chain and return its results with metadata."""
651
+ try:
652
+ # Get subgraph client for this chain
653
+ subgraph_client = self.indexer._get_subgraph_client_for_chain(chain_id)
654
+
655
+ if subgraph_client is None:
656
+ logger.warning(f"No subgraph client available for chain {chain_id}")
657
+ return {
658
+ "chainId": chain_id,
659
+ "status": "unavailable",
660
+ "agents": [],
661
+ "error": f"No subgraph configured for chain {chain_id}"
662
+ }
663
+
664
+ # Execute reputation search query
665
+ try:
666
+ agents_data = subgraph_client.search_agents_by_reputation(
667
+ agents=agents,
668
+ tags=tags,
669
+ reviewers=reviewers,
670
+ capabilities=capabilities,
671
+ skills=skills,
672
+ tasks=tasks,
673
+ names=names,
674
+ minAverageValue=minAverageValue,
675
+ includeRevoked=includeRevoked,
676
+ first=page_size * 3, # Fetch extra to allow for filtering/sorting
677
+ skip=0, # We'll handle pagination after aggregation
678
+ order_by=order_by,
679
+ order_direction=order_direction
680
+ )
681
+
682
+ logger.info(f"Chain {chain_id}: fetched {len(agents_data)} agents by reputation")
683
+ except Exception as e:
684
+ logger.error(f"Error in search_agents_by_reputation for chain {chain_id}: {e}", exc_info=True)
685
+ agents_data = []
686
+
687
+ return {
688
+ "chainId": chain_id,
689
+ "status": "success",
690
+ "agents": agents_data,
691
+ "count": len(agents_data),
692
+ }
693
+
694
+ except Exception as e:
695
+ logger.error(f"Error querying chain {chain_id} for reputation search: {e}", exc_info=True)
696
+ return {
697
+ "chainId": chain_id,
698
+ "status": "error",
699
+ "agents": [],
700
+ "error": str(e),
701
+ "count": 0
702
+ }
703
+
704
+ # Execute queries in parallel
705
+ chain_tasks = [query_single_chain(chain_id) for chain_id in chains]
706
+ chain_results = await asyncio.gather(*chain_tasks)
707
+
708
+ # Aggregate results from all chains
709
+ all_agents = []
710
+ successful_chains = []
711
+ failed_chains = []
712
+
713
+ for result in chain_results:
714
+ chain_id = result["chainId"]
715
+ if result["status"] == "success":
716
+ successful_chains.append(chain_id)
717
+ agents_count = len(result.get("agents", []))
718
+ logger.debug(f"Chain {chain_id}: aggregating {agents_count} agents")
719
+ all_agents.extend(result["agents"])
720
+ else:
721
+ failed_chains.append(chain_id)
722
+ logger.warning(f"Chain {chain_id}: status={result.get('status')}, error={result.get('error', 'N/A')}")
723
+
724
+ logger.debug(f"Total agents aggregated: {len(all_agents)} from {len(successful_chains)} chains")
725
+
726
+ # Transform to AgentSummary objects
727
+ from .models import AgentSummary
728
+ results = []
729
+ for agent_data in all_agents:
730
+ reg_file = agent_data.get('registrationFile') or {}
731
+ if not isinstance(reg_file, dict):
732
+ reg_file = {}
733
+
734
+ agent_summary = AgentSummary(
735
+ chainId=int(agent_data.get('chainId', 0)),
736
+ agentId=agent_data.get('id'),
737
+ name=reg_file.get('name', f"Agent {agent_data.get('id')}"),
738
+ image=reg_file.get('image'),
739
+ description=reg_file.get('description', ''),
740
+ owners=[agent_data.get('owner', '')],
741
+ operators=agent_data.get('operators', []),
742
+ mcp=reg_file.get('mcpEndpoint') is not None,
743
+ a2a=reg_file.get('a2aEndpoint') is not None,
744
+ ens=reg_file.get('ens'),
745
+ did=reg_file.get('did'),
746
+ walletAddress=reg_file.get('agentWallet'),
747
+ supportedTrusts=reg_file.get('supportedTrusts', []),
748
+ a2aSkills=reg_file.get('a2aSkills', []),
749
+ mcpTools=reg_file.get('mcpTools', []),
750
+ mcpPrompts=reg_file.get('mcpPrompts', []),
751
+ mcpResources=reg_file.get('mcpResources', []),
752
+ active=reg_file.get('active', True),
753
+ x402support=reg_file.get('x402Support', reg_file.get('x402support', False)),
754
+ extras={'averageValue': agent_data.get('averageValue')}
755
+ )
756
+ results.append(agent_summary)
757
+
758
+ # Sort by averageValue (descending) if available, otherwise by createdAt
759
+ results.sort(
760
+ key=lambda x: (
761
+ x.extras.get('averageValue') if x.extras.get('averageValue') is not None else 0,
762
+ x.chainId,
763
+ x.agentId
764
+ ),
765
+ reverse=True
766
+ )
767
+
768
+ # Apply pagination
769
+ paginated_results = results[skip:skip + page_size]
770
+ next_cursor = str(skip + len(paginated_results)) if len(paginated_results) == page_size and skip + len(paginated_results) < len(results) else None
771
+
772
+ elapsed_ms = int((time.time() - start_time) * 1000)
773
+
774
+ return {
775
+ "items": paginated_results,
776
+ "nextCursor": next_cursor,
777
+ "meta": {
778
+ "chains": chains,
779
+ "successfulChains": successful_chains,
780
+ "failedChains": failed_chains,
781
+ "totalResults": len(results),
782
+ "timing": {"totalMs": elapsed_ms}
783
+ }
784
+ }
785
+
786
+ # Feedback methods - delegate to feedback_manager
787
+ def prepareFeedbackFile(self, input: Dict[str, Any]) -> Dict[str, Any]:
788
+ """Prepare an off-chain feedback file payload.
789
+
790
+ This is intentionally off-chain-only; it does not attempt to represent
791
+ the on-chain fields (value/tag1/tag2/endpoint-on-chain).
792
+ """
793
+ return self.feedback_manager.prepareFeedbackFile(input)
794
+
795
+ def giveFeedback(
796
+ self,
797
+ agentId: "AgentId",
798
+ value: Union[int, float, str],
799
+ tag1: Optional[str] = None,
800
+ tag2: Optional[str] = None,
801
+ endpoint: Optional[str] = None,
802
+ feedbackFile: Optional[Dict[str, Any]] = None,
803
+ ) -> "TransactionHandle[Feedback]":
804
+ """Give feedback (on-chain first; optional off-chain file upload).
805
+
806
+ - If feedbackFile is None: submit on-chain only (no upload even if IPFS is configured).
807
+ - If feedbackFile is provided: requires IPFS configured; uploads and commits URI/hash on-chain.
808
+ """
809
+ return self.feedback_manager.giveFeedback(
810
+ agentId=agentId,
811
+ value=value,
812
+ tag1=tag1,
813
+ tag2=tag2,
814
+ endpoint=endpoint,
815
+ feedbackFile=feedbackFile,
816
+ )
817
+
818
+ def getFeedback(
819
+ self,
820
+ agentId: "AgentId",
821
+ clientAddress: "Address",
822
+ feedbackIndex: int,
823
+ ) -> "Feedback":
824
+ """Get feedback (maps 8004 endpoint)."""
825
+ return self.feedback_manager.getFeedback(
826
+ agentId, clientAddress, feedbackIndex
827
+ )
828
+
829
+ def searchFeedback(
830
+ self,
831
+ agentId: Optional["AgentId"] = None,
832
+ reviewers: Optional[List["Address"]] = None,
833
+ tags: Optional[List[str]] = None,
834
+ capabilities: Optional[List[str]] = None,
835
+ skills: Optional[List[str]] = None,
836
+ tasks: Optional[List[str]] = None,
837
+ names: Optional[List[str]] = None,
838
+ minValue: Optional[float] = None,
839
+ maxValue: Optional[float] = None,
840
+ include_revoked: bool = False,
841
+ first: int = 100,
842
+ skip: int = 0,
843
+ agents: Optional[List["AgentId"]] = None,
844
+ ) -> List["Feedback"]:
845
+ """Search feedback.
846
+
847
+ Backwards compatible:
848
+ - Previously required `agentId`; it is now optional.
849
+
850
+ New:
851
+ - `agents` can be used to search feedback across multiple agents in one call.
852
+ - `reviewers` can now be used without specifying any agent, enabling "all feedback given by a wallet".
853
+ """
854
+ has_any_filter = any([
855
+ bool(agentId),
856
+ bool(agents),
857
+ bool(reviewers),
858
+ bool(tags),
859
+ bool(capabilities),
860
+ bool(skills),
861
+ bool(tasks),
862
+ bool(names),
863
+ minValue is not None,
864
+ maxValue is not None,
865
+ ])
866
+ if not has_any_filter:
867
+ raise ValueError(
868
+ "searchFeedback requires at least one filter "
869
+ "(agentId/agents/reviewers/tags/capabilities/skills/tasks/names/minValue/maxValue)."
870
+ )
871
+
872
+ return self.feedback_manager.searchFeedback(
873
+ agentId=agentId,
874
+ agents=agents,
875
+ clientAddresses=reviewers,
876
+ tags=tags,
877
+ capabilities=capabilities,
878
+ skills=skills,
879
+ tasks=tasks,
880
+ names=names,
881
+ minValue=minValue,
882
+ maxValue=maxValue,
883
+ include_revoked=include_revoked,
884
+ first=first,
885
+ skip=skip,
886
+ )
887
+
888
+ def revokeFeedback(
889
+ self,
890
+ agentId: "AgentId",
891
+ feedbackIndex: int,
892
+ ) -> "TransactionHandle[Feedback]":
893
+ """Revoke feedback (submitted-by-default)."""
894
+ return self.feedback_manager.revokeFeedback(agentId, feedbackIndex)
895
+
896
+ def appendResponse(
897
+ self,
898
+ agentId: "AgentId",
899
+ clientAddress: "Address",
900
+ feedbackIndex: int,
901
+ response: Dict[str, Any],
902
+ ) -> "TransactionHandle[Feedback]":
903
+ """Append a response/follow-up to existing feedback (submitted-by-default)."""
904
+ return self.feedback_manager.appendResponse(
905
+ agentId, clientAddress, feedbackIndex, response
906
+ )
907
+
908
+ def getReputationSummary(
909
+ self,
910
+ agentId: "AgentId",
911
+ ) -> Dict[str, Any]:
912
+ """Get reputation summary for an agent."""
913
+ return self.feedback_manager.getReputationSummary(
914
+ agentId
915
+ )
916
+
917
+ def transferAgent(
918
+ self,
919
+ agentId: "AgentId",
920
+ newOwnerAddress: str,
921
+ ) -> "TransactionHandle[Dict[str, Any]]":
922
+ """Transfer agent ownership to a new address.
923
+
924
+ Convenience method that loads the agent and calls transfer().
925
+
926
+ Args:
927
+ agentId: The agent ID to transfer
928
+ newOwnerAddress: Ethereum address of the new owner
929
+
930
+ Returns:
931
+ Transaction receipt
932
+
933
+ Raises:
934
+ ValueError: If agent not found or transfer not allowed
935
+ """
936
+ # Load the agent
937
+ agent = self.loadAgent(agentId)
938
+
939
+ # Call the transfer method
940
+ return agent.transfer(newOwnerAddress)
941
+
942
+ # Utility methods for owner operations
943
+ def getAgentOwner(self, agentId: AgentId) -> str:
944
+ """Get the current owner of an agent.
945
+
946
+ Args:
947
+ agentId: The agent ID to check (can be "chainId:tokenId" or just tokenId)
948
+
949
+ Returns:
950
+ The current owner's Ethereum address
951
+
952
+ Raises:
953
+ ValueError: If agent ID is invalid or agent doesn't exist
954
+ """
955
+ try:
956
+ # Parse agentId to extract tokenId
957
+ if ":" in str(agentId):
958
+ tokenId = int(str(agentId).split(":")[-1])
959
+ else:
960
+ tokenId = int(agentId)
961
+
962
+ owner = self.web3_client.call_contract(
963
+ self.identity_registry,
964
+ "ownerOf",
965
+ tokenId
966
+ )
967
+ return owner
968
+ except Exception as e:
969
+ raise ValueError(f"Failed to get owner for agent {agentId}: {e}")
970
+
971
+ def isAgentOwner(self, agentId: AgentId, address: Optional[str] = None) -> bool:
972
+ """Check if an address is the owner of an agent.
973
+
974
+ Args:
975
+ agentId: The agent ID to check
976
+ address: Address to check (defaults to SDK's signer address)
977
+
978
+ Returns:
979
+ True if the address is the owner, False otherwise
980
+
981
+ Raises:
982
+ ValueError: If agent ID is invalid or agent doesn't exist
983
+ """
984
+ if address is None:
985
+ if not self.signer:
986
+ raise ValueError("No signer available and no address provided")
987
+ address = self.web3_client.account.address
988
+
989
+ try:
990
+ owner = self.getAgentOwner(agentId)
991
+ return owner.lower() == address.lower()
992
+ except ValueError:
993
+ return False
994
+
995
+ def canTransferAgent(self, agentId: AgentId, address: Optional[str] = None) -> bool:
996
+ """Check if an address can transfer an agent (i.e., is the owner).
997
+
998
+ Args:
999
+ agentId: The agent ID to check
1000
+ address: Address to check (defaults to SDK's signer address)
1001
+
1002
+ Returns:
1003
+ True if the address can transfer the agent, False otherwise
1004
+ """
1005
+ return self.isAgentOwner(agentId, address)