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