agent0-sdk 0.2.2__py3-none-any.whl → 0.3rc1__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.
@@ -54,19 +54,28 @@ class AgentIndexer:
54
54
  embeddings: Optional[Any] = None,
55
55
  subgraph_client: Optional[Any] = None,
56
56
  identity_registry: Optional[Any] = None,
57
+ subgraph_url_overrides: Optional[Dict[int, str]] = None,
57
58
  ):
58
- """Initialize indexer."""
59
+ """Initialize indexer with optional subgraph URL overrides for multiple chains."""
59
60
  self.web3_client = web3_client
60
61
  self.store = store or self._create_default_store()
61
62
  self.embeddings = embeddings or self._create_default_embeddings()
62
63
  self.subgraph_client = subgraph_client
63
64
  self.identity_registry = identity_registry
65
+ self.subgraph_url_overrides = subgraph_url_overrides or {}
64
66
  self._agent_cache = {} # Cache for agent data
65
67
  self._cache_timestamp = 0
66
68
  self._cache_ttl = 7 * 24 * 60 * 60 # 1 week cache TTL (604800 seconds)
67
69
  self._http_cache = {} # Cache for HTTP content
68
70
  self._http_cache_ttl = 60 * 60 # 1 hour cache TTL for HTTP content
69
71
 
72
+ # Cache for subgraph clients (one per chain)
73
+ self._subgraph_client_cache: Dict[int, Any] = {}
74
+
75
+ # If default subgraph_client provided, cache it for current chain
76
+ if self.subgraph_client:
77
+ self._subgraph_client_cache[self.web3_client.chain_id] = self.subgraph_client
78
+
70
79
  def _create_default_store(self) -> Dict[str, Any]:
71
80
  """Create default in-memory store."""
72
81
  return {
@@ -363,19 +372,40 @@ class AgentIndexer:
363
372
 
364
373
  def get_agent(self, agent_id: AgentId) -> AgentSummary:
365
374
  """Get agent summary from index."""
375
+ # Parse chainId from agentId
376
+ chain_id, token_id = self._parse_agent_id(agent_id)
377
+
378
+ # Get subgraph client for the chain
379
+ subgraph_client = None
380
+ full_agent_id = agent_id
381
+
382
+ if chain_id is not None:
383
+ subgraph_client = self._get_subgraph_client_for_chain(chain_id)
384
+ else:
385
+ # No chainId in agentId, use SDK's default
386
+ # Construct full agentId format for subgraph query
387
+ default_chain_id = self.web3_client.chain_id
388
+ full_agent_id = f"{default_chain_id}:{token_id}"
389
+ subgraph_client = self.subgraph_client
390
+
366
391
  # Use subgraph if available (preferred)
367
- if self.subgraph_client:
368
- return self._get_agent_from_subgraph(agent_id)
392
+ if subgraph_client:
393
+ return self._get_agent_from_subgraph(full_agent_id, subgraph_client)
369
394
 
370
395
  # Fallback to local cache
371
396
  if agent_id not in self.store["agents"]:
372
397
  raise ValueError(f"Agent {agent_id} not found in index")
373
398
  return self.store["agents"][agent_id]
374
399
 
375
- def _get_agent_from_subgraph(self, agent_id: AgentId) -> AgentSummary:
400
+ def _get_agent_from_subgraph(self, agent_id: AgentId, subgraph_client: Optional[Any] = None) -> AgentSummary:
376
401
  """Get agent summary from subgraph."""
402
+ # Use provided client or default
403
+ client = subgraph_client or self.subgraph_client
404
+ if not client:
405
+ raise ValueError("No subgraph client available")
406
+
377
407
  try:
378
- agent_data = self.subgraph_client.get_agent_by_id(agent_id)
408
+ agent_data = client.get_agent_by_id(agent_id)
379
409
 
380
410
  if agent_data is None:
381
411
  raise ValueError(f"Agent {agent_id} not found in subgraph")
@@ -418,13 +448,322 @@ class AgentIndexer:
418
448
  cursor: Optional[str] = None,
419
449
  ) -> Dict[str, Any]:
420
450
  """Search for agents by querying the subgraph or blockchain."""
451
+ # Handle "all" chains shorthand
452
+ if params.chains == "all":
453
+ params.chains = self._get_all_configured_chains()
454
+ logger.info(f"Expanding 'all' to configured chains: {params.chains}")
455
+
456
+ # If chains are explicitly specified (even a single chain), use multi-chain path
457
+ # This ensures the correct subgraph client is used for the requested chain(s)
458
+ if params.chains and len(params.chains) > 0:
459
+ # Validate chains are configured
460
+ available_chains = set(self._get_all_configured_chains())
461
+ requested_chains = set(params.chains)
462
+ invalid_chains = requested_chains - available_chains
463
+
464
+ if invalid_chains:
465
+ logger.warning(
466
+ f"Requested chains not configured: {invalid_chains}. "
467
+ f"Available chains: {available_chains}"
468
+ )
469
+ # Filter to valid chains only
470
+ valid_chains = list(requested_chains & available_chains)
471
+ if not valid_chains:
472
+ return {
473
+ "items": [],
474
+ "nextCursor": None,
475
+ "meta": {
476
+ "chains": list(requested_chains),
477
+ "successfulChains": [],
478
+ "failedChains": list(requested_chains),
479
+ "error": f"No valid chains configured. Available: {list(available_chains)}"
480
+ }
481
+ }
482
+ params.chains = valid_chains
483
+
484
+ return asyncio.run(
485
+ self._search_agents_across_chains(params, sort, page_size, cursor)
486
+ )
487
+
421
488
  # Use subgraph if available (preferred)
422
489
  if self.subgraph_client:
423
490
  return self._search_agents_via_subgraph(params, sort, page_size, cursor)
424
-
491
+
425
492
  # Fallback to blockchain queries
426
493
  return self._search_agents_via_blockchain(params, sort, page_size, cursor)
427
-
494
+
495
+ async def _search_agents_across_chains(
496
+ self,
497
+ params: SearchParams,
498
+ sort: List[str],
499
+ page_size: int,
500
+ cursor: Optional[str] = None,
501
+ timeout: float = 30.0,
502
+ ) -> Dict[str, Any]:
503
+ """
504
+ Search agents across multiple chains in parallel.
505
+
506
+ This method is called when params.chains contains 2+ chain IDs.
507
+ It executes one subgraph query per chain, all in parallel using asyncio.
508
+
509
+ Args:
510
+ params: Search parameters
511
+ sort: Sort specification
512
+ page_size: Number of results per page
513
+ cursor: Pagination cursor
514
+ timeout: Maximum time in seconds for all chain queries (default: 30.0)
515
+
516
+ Returns:
517
+ {
518
+ "items": [agent_dict, ...],
519
+ "nextCursor": str or None,
520
+ "meta": {
521
+ "chains": [chainId, ...],
522
+ "successfulChains": [chainId, ...],
523
+ "failedChains": [chainId, ...],
524
+ "totalResults": int,
525
+ "timing": {"totalMs": int}
526
+ }
527
+ }
528
+ """
529
+ import time
530
+ start_time = time.time()
531
+ # Step 1: Determine which chains to query
532
+ chains_to_query = params.chains if params.chains else self._get_all_configured_chains()
533
+
534
+ if not chains_to_query or len(chains_to_query) == 0:
535
+ logger.warning("No chains specified or configured for multi-chain query")
536
+ return {"items": [], "nextCursor": None, "meta": {"chains": [], "successfulChains": [], "failedChains": []}}
537
+
538
+ # Step 2: Parse pagination cursor (if any)
539
+ chain_cursors = self._parse_multi_chain_cursor(cursor)
540
+ global_offset = chain_cursors.get("_global_offset", 0)
541
+
542
+ # Step 3: Define async function for querying a single chain
543
+ async def query_single_chain(chain_id: int) -> Dict[str, Any]:
544
+ """Query one chain and return its results with metadata."""
545
+ try:
546
+ # Get subgraph client for this chain
547
+ subgraph_client = self._get_subgraph_client_for_chain(chain_id)
548
+
549
+ if subgraph_client is None:
550
+ logger.warning(f"No subgraph client available for chain {chain_id}")
551
+ return {
552
+ "chainId": chain_id,
553
+ "status": "unavailable",
554
+ "agents": [],
555
+ "error": f"No subgraph configured for chain {chain_id}"
556
+ }
557
+
558
+ # Build WHERE clause for this chain's query
559
+ # (reuse existing logic from _search_agents_via_subgraph)
560
+ where_clause = {}
561
+ reg_file_where = {}
562
+
563
+ if params.name is not None:
564
+ reg_file_where["name_contains"] = params.name
565
+ if params.active is not None:
566
+ reg_file_where["active"] = params.active
567
+ if params.x402support is not None:
568
+ reg_file_where["x402support"] = params.x402support
569
+ if params.mcp is not None:
570
+ if params.mcp:
571
+ reg_file_where["mcpEndpoint_not"] = None
572
+ else:
573
+ reg_file_where["mcpEndpoint"] = None
574
+ if params.a2a is not None:
575
+ if params.a2a:
576
+ reg_file_where["a2aEndpoint_not"] = None
577
+ else:
578
+ reg_file_where["a2aEndpoint"] = None
579
+ if params.ens is not None:
580
+ reg_file_where["ens"] = params.ens
581
+ if params.did is not None:
582
+ reg_file_where["did"] = params.did
583
+ if params.walletAddress is not None:
584
+ reg_file_where["agentWallet"] = params.walletAddress
585
+
586
+ if reg_file_where:
587
+ where_clause["registrationFile_"] = reg_file_where
588
+
589
+ # Owner filtering
590
+ if params.owners is not None and len(params.owners) > 0:
591
+ normalized_owners = [owner.lower() for owner in params.owners]
592
+ if len(normalized_owners) == 1:
593
+ where_clause["owner"] = normalized_owners[0]
594
+ else:
595
+ where_clause["owner_in"] = normalized_owners
596
+
597
+ # Operator filtering
598
+ if params.operators is not None and len(params.operators) > 0:
599
+ normalized_operators = [op.lower() for op in params.operators]
600
+ where_clause["operators_contains"] = normalized_operators
601
+
602
+ # Get pagination offset for this chain (not used in multi-chain, fetch all)
603
+ skip = 0
604
+
605
+ # Execute subgraph query
606
+ agents = subgraph_client.get_agents(
607
+ where=where_clause if where_clause else None,
608
+ first=page_size * 3, # Fetch extra to allow for filtering/sorting
609
+ skip=skip,
610
+ order_by=self._extract_order_by(sort),
611
+ order_direction=self._extract_order_direction(sort)
612
+ )
613
+
614
+ logger.info(f"Chain {chain_id}: fetched {len(agents)} agents")
615
+
616
+ return {
617
+ "chainId": chain_id,
618
+ "status": "success",
619
+ "agents": agents,
620
+ "count": len(agents),
621
+ }
622
+
623
+ except Exception as e:
624
+ logger.error(f"Error querying chain {chain_id}: {e}", exc_info=True)
625
+ return {
626
+ "chainId": chain_id,
627
+ "status": "error",
628
+ "agents": [],
629
+ "error": str(e)
630
+ }
631
+
632
+ # Step 4: Execute all chain queries in parallel with timeout
633
+ logger.info(f"Querying {len(chains_to_query)} chains in parallel: {chains_to_query}")
634
+ tasks = [query_single_chain(chain_id) for chain_id in chains_to_query]
635
+
636
+ try:
637
+ chain_results = await asyncio.wait_for(
638
+ asyncio.gather(*tasks),
639
+ timeout=timeout
640
+ )
641
+ except asyncio.TimeoutError:
642
+ logger.error(f"Multi-chain query timed out after {timeout}s")
643
+ # Collect results from completed tasks
644
+ chain_results = []
645
+ for task in tasks:
646
+ if task.done():
647
+ try:
648
+ chain_results.append(task.result())
649
+ except Exception as e:
650
+ logger.warning(f"Task failed: {e}")
651
+ else:
652
+ # Task didn't complete - mark as timeout
653
+ chain_results.append({
654
+ "chainId": None,
655
+ "status": "timeout",
656
+ "agents": [],
657
+ "error": f"Query timed out after {timeout}s"
658
+ })
659
+
660
+ # Step 5: Extract successful results and track failures
661
+ all_agents = []
662
+ successful_chains = []
663
+ failed_chains = []
664
+
665
+ for result in chain_results:
666
+ chain_id = result["chainId"]
667
+
668
+ if result["status"] == "success":
669
+ successful_chains.append(chain_id)
670
+ all_agents.extend(result["agents"])
671
+ else:
672
+ failed_chains.append(chain_id)
673
+ logger.warning(
674
+ f"Chain {chain_id} query failed: {result.get('error', 'Unknown error')}"
675
+ )
676
+
677
+ logger.info(f"Multi-chain query: {len(successful_chains)} successful, {len(failed_chains)} failed, {len(all_agents)} total agents")
678
+
679
+ # If ALL chains failed, raise error
680
+ if len(successful_chains) == 0:
681
+ raise ConnectionError(
682
+ f"All chains failed: {', '.join(str(c) for c in failed_chains)}"
683
+ )
684
+
685
+ # Step 6: Apply cross-chain filtering (for fields not supported by subgraph WHERE clause)
686
+ filtered_agents = self._apply_cross_chain_filters(all_agents, params)
687
+ logger.info(f"After cross-chain filters: {len(filtered_agents)} agents")
688
+
689
+ # Step 7: Deduplicate if requested
690
+ deduplicated_agents = self._deduplicate_agents_cross_chain(filtered_agents, params)
691
+ logger.info(f"After deduplication: {len(deduplicated_agents)} agents")
692
+
693
+ # Step 8: Sort across chains
694
+ sorted_agents = self._sort_agents_cross_chain(deduplicated_agents, sort)
695
+ logger.info(f"After sorting: {len(sorted_agents)} agents")
696
+
697
+ # Step 9: Apply pagination
698
+ start_idx = global_offset
699
+ paginated_agents = sorted_agents[start_idx:start_idx + page_size]
700
+
701
+ # Step 10: Convert to result format (keep as dicts, SDK will convert to AgentSummary)
702
+ results = []
703
+ for agent_data in paginated_agents:
704
+ reg_file = agent_data.get('registrationFile') or {}
705
+ if not isinstance(reg_file, dict):
706
+ reg_file = {}
707
+
708
+ result_agent = {
709
+ "agentId": agent_data.get('id'),
710
+ "chainId": agent_data.get('chainId'),
711
+ "name": reg_file.get('name', f"Agent {agent_data.get('agentId')}"),
712
+ "description": reg_file.get('description', ''),
713
+ "image": reg_file.get('image'),
714
+ "owner": agent_data.get('owner'),
715
+ "operators": agent_data.get('operators', []),
716
+ "mcp": reg_file.get('mcpEndpoint') is not None,
717
+ "a2a": reg_file.get('a2aEndpoint') is not None,
718
+ "ens": reg_file.get('ens'),
719
+ "did": reg_file.get('did'),
720
+ "walletAddress": reg_file.get('agentWallet'),
721
+ "supportedTrusts": reg_file.get('supportedTrusts', []),
722
+ "a2aSkills": reg_file.get('a2aSkills', []),
723
+ "mcpTools": reg_file.get('mcpTools', []),
724
+ "mcpPrompts": reg_file.get('mcpPrompts', []),
725
+ "mcpResources": reg_file.get('mcpResources', []),
726
+ "active": reg_file.get('active', True),
727
+ "x402support": reg_file.get('x402support', False),
728
+ "totalFeedback": agent_data.get('totalFeedback', 0),
729
+ "lastActivity": agent_data.get('lastActivity'),
730
+ "updatedAt": agent_data.get('updatedAt'),
731
+ "extras": {}
732
+ }
733
+
734
+ # Add deployedOn if deduplication was used
735
+ if 'deployedOn' in agent_data:
736
+ result_agent['extras']['deployedOn'] = agent_data['deployedOn']
737
+
738
+ results.append(result_agent)
739
+
740
+ # Step 11: Calculate next cursor
741
+ next_cursor = None
742
+ if len(sorted_agents) > start_idx + page_size:
743
+ # More results available
744
+ next_cursor = self._create_multi_chain_cursor(
745
+ global_offset=start_idx + page_size
746
+ )
747
+
748
+ # Step 12: Build response with metadata
749
+ query_time = time.time() - start_time
750
+
751
+ return {
752
+ "items": results,
753
+ "nextCursor": next_cursor,
754
+ "meta": {
755
+ "chains": chains_to_query,
756
+ "successfulChains": successful_chains,
757
+ "failedChains": failed_chains,
758
+ "totalResults": len(sorted_agents),
759
+ "pageResults": len(results),
760
+ "timing": {
761
+ "totalMs": int(query_time * 1000),
762
+ "averagePerChainMs": int(query_time * 1000 / len(chains_to_query)) if chains_to_query else 0,
763
+ }
764
+ }
765
+ }
766
+
428
767
  def _search_agents_via_subgraph(
429
768
  self,
430
769
  params: SearchParams,
@@ -766,11 +1105,27 @@ class AgentIndexer:
766
1105
  skip: int = 0,
767
1106
  ) -> List[Feedback]:
768
1107
  """Search feedback for an agent - uses subgraph if available."""
1108
+ # Parse chainId from agentId
1109
+ chain_id, token_id = self._parse_agent_id(agentId)
1110
+
1111
+ # Get subgraph client for the chain
1112
+ subgraph_client = None
1113
+ full_agent_id = agentId
1114
+
1115
+ if chain_id is not None:
1116
+ subgraph_client = self._get_subgraph_client_for_chain(chain_id)
1117
+ else:
1118
+ # No chainId in agentId, use SDK's default
1119
+ # Construct full agentId format for subgraph query
1120
+ default_chain_id = self.web3_client.chain_id
1121
+ full_agent_id = f"{default_chain_id}:{token_id}"
1122
+ subgraph_client = self.subgraph_client
1123
+
769
1124
  # Use subgraph if available (preferred)
770
- if self.subgraph_client:
1125
+ if subgraph_client:
771
1126
  return self._search_feedback_subgraph(
772
- agentId, clientAddresses, tags, capabilities, skills, tasks, names,
773
- minScore, maxScore, include_revoked, first, skip
1127
+ full_agent_id, clientAddresses, tags, capabilities, skills, tasks, names,
1128
+ minScore, maxScore, include_revoked, first, skip, subgraph_client
774
1129
  )
775
1130
 
776
1131
  # Fallback not implemented (would require blockchain queries)
@@ -791,8 +1146,14 @@ class AgentIndexer:
791
1146
  include_revoked: bool,
792
1147
  first: int,
793
1148
  skip: int,
1149
+ subgraph_client: Optional[Any] = None,
794
1150
  ) -> List[Feedback]:
795
1151
  """Search feedback using subgraph."""
1152
+ # Use provided client or default
1153
+ client = subgraph_client or self.subgraph_client
1154
+ if not client:
1155
+ return []
1156
+
796
1157
  # Create SearchFeedbackParams
797
1158
  params = SearchFeedbackParams(
798
1159
  agents=[agentId],
@@ -808,7 +1169,7 @@ class AgentIndexer:
808
1169
  )
809
1170
 
810
1171
  # Query subgraph
811
- feedbacks_data = self.subgraph_client.search_feedback(
1172
+ feedbacks_data = client.search_feedback(
812
1173
  params=params,
813
1174
  first=first,
814
1175
  skip=skip,
@@ -1030,3 +1391,364 @@ class AgentIndexer:
1030
1391
  except Exception as e:
1031
1392
  logger.warning(f"Could not parse token URI {token_uri}: {e}")
1032
1393
  return None
1394
+
1395
+ def _get_subgraph_client_for_chain(self, chain_id: int):
1396
+ """
1397
+ Get or create SubgraphClient for a specific chain.
1398
+
1399
+ Checks (in order):
1400
+ 1. Client cache (already created)
1401
+ 2. Subgraph URL overrides (from constructor)
1402
+ 3. DEFAULT_SUBGRAPH_URLS (from contracts.py)
1403
+ 4. Environment variables (SUBGRAPH_URL_<chainId>)
1404
+
1405
+ Returns None if no subgraph URL is available for this chain.
1406
+ """
1407
+ # Check cache first
1408
+ if chain_id in self._subgraph_client_cache:
1409
+ return self._subgraph_client_cache[chain_id]
1410
+
1411
+ # Get subgraph URL for this chain
1412
+ subgraph_url = self._get_subgraph_url_for_chain(chain_id)
1413
+
1414
+ if subgraph_url is None:
1415
+ logger.warning(f"No subgraph URL configured for chain {chain_id}")
1416
+ return None
1417
+
1418
+ # Create new SubgraphClient
1419
+ from .subgraph_client import SubgraphClient
1420
+ client = SubgraphClient(subgraph_url)
1421
+
1422
+ # Cache for future use
1423
+ self._subgraph_client_cache[chain_id] = client
1424
+
1425
+ logger.info(f"Created subgraph client for chain {chain_id}: {subgraph_url}")
1426
+
1427
+ return client
1428
+
1429
+ def _get_subgraph_url_for_chain(self, chain_id: int) -> Optional[str]:
1430
+ """
1431
+ Get subgraph URL for a specific chain.
1432
+
1433
+ Priority order:
1434
+ 1. Constructor-provided overrides (self.subgraph_url_overrides)
1435
+ 2. DEFAULT_SUBGRAPH_URLS from contracts.py
1436
+ 3. Environment variable SUBGRAPH_URL_<chainId>
1437
+ 4. None (not configured)
1438
+ """
1439
+ import os
1440
+
1441
+ # 1. Check constructor overrides
1442
+ if chain_id in self.subgraph_url_overrides:
1443
+ return self.subgraph_url_overrides[chain_id]
1444
+
1445
+ # 2. Check DEFAULT_SUBGRAPH_URLS
1446
+ from .contracts import DEFAULT_SUBGRAPH_URLS
1447
+ if chain_id in DEFAULT_SUBGRAPH_URLS:
1448
+ return DEFAULT_SUBGRAPH_URLS[chain_id]
1449
+
1450
+ # 3. Check environment variable
1451
+ env_key = f"SUBGRAPH_URL_{chain_id}"
1452
+ env_url = os.environ.get(env_key)
1453
+ if env_url:
1454
+ logger.info(f"Using subgraph URL from environment: {env_key}={env_url}")
1455
+ return env_url
1456
+
1457
+ # 4. Not found
1458
+ return None
1459
+
1460
+ def _parse_agent_id(self, agent_id: AgentId) -> tuple[Optional[int], str]:
1461
+ """
1462
+ Parse agentId to extract chainId and tokenId.
1463
+
1464
+ Returns:
1465
+ (chain_id, token_id_str) where:
1466
+ - chain_id: int if "chainId:tokenId" format, None if just "tokenId"
1467
+ - token_id_str: the tokenId part (always present)
1468
+ """
1469
+ if ":" in agent_id:
1470
+ parts = agent_id.split(":", 1)
1471
+ try:
1472
+ chain_id = int(parts[0])
1473
+ token_id = parts[1]
1474
+ return (chain_id, token_id)
1475
+ except ValueError:
1476
+ # Invalid chainId, treat as tokenId only
1477
+ return (None, agent_id)
1478
+ return (None, agent_id)
1479
+
1480
+ def _get_all_configured_chains(self) -> List[int]:
1481
+ """
1482
+ Get list of all chains that have subgraphs configured.
1483
+
1484
+ This is used when params.chains is None (query all available chains).
1485
+ """
1486
+ import os
1487
+ from .contracts import DEFAULT_SUBGRAPH_URLS
1488
+
1489
+ chains = set()
1490
+
1491
+ # Add chains from DEFAULT_SUBGRAPH_URLS
1492
+ chains.update(DEFAULT_SUBGRAPH_URLS.keys())
1493
+
1494
+ # Add chains from constructor overrides
1495
+ chains.update(self.subgraph_url_overrides.keys())
1496
+
1497
+ # Add chains from environment variables
1498
+ for key, value in os.environ.items():
1499
+ if key.startswith("SUBGRAPH_URL_") and value:
1500
+ try:
1501
+ chain_id = int(key.replace("SUBGRAPH_URL_", ""))
1502
+ chains.add(chain_id)
1503
+ except ValueError:
1504
+ pass
1505
+
1506
+ return sorted(list(chains))
1507
+
1508
+ def _apply_cross_chain_filters(
1509
+ self,
1510
+ agents: List[Dict[str, Any]],
1511
+ params: SearchParams
1512
+ ) -> List[Dict[str, Any]]:
1513
+ """
1514
+ Apply filters that couldn't be expressed in subgraph WHERE clause.
1515
+
1516
+ Most filters are already applied by the subgraph query, but some
1517
+ (like supportedTrust, mcpTools, etc.) need post-processing.
1518
+ """
1519
+ filtered = agents
1520
+
1521
+ # Filter by supportedTrust (if specified)
1522
+ if params.supportedTrust is not None:
1523
+ filtered = [
1524
+ agent for agent in filtered
1525
+ if any(
1526
+ trust in agent.get('registrationFile', {}).get('supportedTrusts', [])
1527
+ for trust in params.supportedTrust
1528
+ )
1529
+ ]
1530
+
1531
+ # Filter by mcpTools (if specified)
1532
+ if params.mcpTools is not None:
1533
+ filtered = [
1534
+ agent for agent in filtered
1535
+ if any(
1536
+ tool in agent.get('registrationFile', {}).get('mcpTools', [])
1537
+ for tool in params.mcpTools
1538
+ )
1539
+ ]
1540
+
1541
+ # Filter by a2aSkills (if specified)
1542
+ if params.a2aSkills is not None:
1543
+ filtered = [
1544
+ agent for agent in filtered
1545
+ if any(
1546
+ skill in agent.get('registrationFile', {}).get('a2aSkills', [])
1547
+ for skill in params.a2aSkills
1548
+ )
1549
+ ]
1550
+
1551
+ # Filter by mcpPrompts (if specified)
1552
+ if params.mcpPrompts is not None:
1553
+ filtered = [
1554
+ agent for agent in filtered
1555
+ if any(
1556
+ prompt in agent.get('registrationFile', {}).get('mcpPrompts', [])
1557
+ for prompt in params.mcpPrompts
1558
+ )
1559
+ ]
1560
+
1561
+ # Filter by mcpResources (if specified)
1562
+ if params.mcpResources is not None:
1563
+ filtered = [
1564
+ agent for agent in filtered
1565
+ if any(
1566
+ resource in agent.get('registrationFile', {}).get('mcpResources', [])
1567
+ for resource in params.mcpResources
1568
+ )
1569
+ ]
1570
+
1571
+ return filtered
1572
+
1573
+ def _deduplicate_agents_cross_chain(
1574
+ self,
1575
+ agents: List[Dict[str, Any]],
1576
+ params: SearchParams
1577
+ ) -> List[Dict[str, Any]]:
1578
+ """
1579
+ Deduplicate agents across chains (if requested).
1580
+
1581
+ Strategy:
1582
+ - By default, DON'T deduplicate (agents on different chains are different entities)
1583
+ - If params.deduplicate_cross_chain=True, deduplicate by (owner, registration_hash)
1584
+
1585
+ When deduplicating:
1586
+ - Keep the first instance encountered
1587
+ - Add 'deployedOn' array with all chain IDs where this agent exists
1588
+ """
1589
+ # Check if deduplication requested
1590
+ if not params.deduplicate_cross_chain:
1591
+ return agents
1592
+
1593
+ # Group agents by identity key
1594
+ seen = {}
1595
+ deduplicated = []
1596
+
1597
+ for agent in agents:
1598
+ # Create identity key: (owner, name, description)
1599
+ # This identifies "the same agent" across chains
1600
+ owner = agent.get('owner', '').lower()
1601
+ reg_file = agent.get('registrationFile', {})
1602
+ name = reg_file.get('name', '')
1603
+ description = reg_file.get('description', '')
1604
+
1605
+ identity_key = (owner, name, description)
1606
+
1607
+ if identity_key not in seen:
1608
+ # First time seeing this agent
1609
+ seen[identity_key] = agent
1610
+
1611
+ # Add deployedOn array
1612
+ agent['deployedOn'] = [agent['chainId']]
1613
+
1614
+ deduplicated.append(agent)
1615
+ else:
1616
+ # Already seen this agent on another chain
1617
+ # Add this chain to deployedOn array
1618
+ seen[identity_key]['deployedOn'].append(agent['chainId'])
1619
+
1620
+ logger.info(
1621
+ f"Deduplication: {len(agents)} agents → {len(deduplicated)} unique agents"
1622
+ )
1623
+
1624
+ return deduplicated
1625
+
1626
+ def _sort_agents_cross_chain(
1627
+ self,
1628
+ agents: List[Dict[str, Any]],
1629
+ sort: List[str]
1630
+ ) -> List[Dict[str, Any]]:
1631
+ """
1632
+ Sort agents from multiple chains.
1633
+
1634
+ Supports sorting by:
1635
+ - createdAt (timestamp)
1636
+ - updatedAt (timestamp)
1637
+ - totalFeedback (count)
1638
+ - name (alphabetical)
1639
+ - averageScore (reputation, if available)
1640
+ """
1641
+ if not sort or len(sort) == 0:
1642
+ # Default: sort by createdAt descending (newest first)
1643
+ return sorted(
1644
+ agents,
1645
+ key=lambda a: a.get('createdAt', 0),
1646
+ reverse=True
1647
+ )
1648
+
1649
+ # Parse first sort specification
1650
+ sort_spec = sort[0]
1651
+ if ':' in sort_spec:
1652
+ field, direction = sort_spec.split(':', 1)
1653
+ else:
1654
+ field = sort_spec
1655
+ direction = 'desc'
1656
+
1657
+ reverse = (direction.lower() == 'desc')
1658
+
1659
+ # Define sort key function
1660
+ def get_sort_key(agent: Dict[str, Any]):
1661
+ if field == 'createdAt':
1662
+ return agent.get('createdAt', 0)
1663
+
1664
+ elif field == 'updatedAt':
1665
+ return agent.get('updatedAt', 0)
1666
+
1667
+ elif field == 'totalFeedback':
1668
+ return agent.get('totalFeedback', 0)
1669
+
1670
+ elif field == 'name':
1671
+ reg_file = agent.get('registrationFile', {})
1672
+ return reg_file.get('name', '').lower()
1673
+
1674
+ elif field == 'averageScore':
1675
+ # If reputation search was done, averageScore may be available
1676
+ return agent.get('averageScore', 0)
1677
+
1678
+ else:
1679
+ logger.warning(f"Unknown sort field: {field}, defaulting to createdAt")
1680
+ return agent.get('createdAt', 0)
1681
+
1682
+ return sorted(agents, key=get_sort_key, reverse=reverse)
1683
+
1684
+ def _parse_multi_chain_cursor(self, cursor: Optional[str]) -> Dict[int, int]:
1685
+ """
1686
+ Parse multi-chain cursor into per-chain offsets.
1687
+
1688
+ Cursor format (JSON):
1689
+ {
1690
+ "11155111": 50, # Ethereum Sepolia offset
1691
+ "84532": 30, # Base Sepolia offset
1692
+ "_global_offset": 100 # Total items returned so far
1693
+ }
1694
+
1695
+ Returns:
1696
+ Dict mapping chainId → offset (default 0)
1697
+ """
1698
+ if not cursor:
1699
+ return {}
1700
+
1701
+ try:
1702
+ cursor_data = json.loads(cursor)
1703
+
1704
+ # Validate format
1705
+ if not isinstance(cursor_data, dict):
1706
+ logger.warning(f"Invalid cursor format: {cursor}, using empty")
1707
+ return {}
1708
+
1709
+ return cursor_data
1710
+
1711
+ except json.JSONDecodeError as e:
1712
+ logger.warning(f"Failed to parse cursor: {e}, using empty")
1713
+ return {}
1714
+
1715
+ def _create_multi_chain_cursor(
1716
+ self,
1717
+ global_offset: int,
1718
+ ) -> str:
1719
+ """
1720
+ Create multi-chain cursor for next page.
1721
+
1722
+ Args:
1723
+ global_offset: Total items returned so far
1724
+
1725
+ Returns:
1726
+ JSON string cursor
1727
+ """
1728
+ cursor_data = {
1729
+ "_global_offset": global_offset
1730
+ }
1731
+
1732
+ return json.dumps(cursor_data)
1733
+
1734
+ def _extract_order_by(self, sort: List[str]) -> str:
1735
+ """Extract order_by field from sort specification."""
1736
+ if not sort or len(sort) == 0:
1737
+ return "createdAt"
1738
+
1739
+ sort_spec = sort[0]
1740
+ if ':' in sort_spec:
1741
+ field, _ = sort_spec.split(':', 1)
1742
+ return field
1743
+ return sort_spec
1744
+
1745
+ def _extract_order_direction(self, sort: List[str]) -> str:
1746
+ """Extract order direction from sort specification."""
1747
+ if not sort or len(sort) == 0:
1748
+ return "desc"
1749
+
1750
+ sort_spec = sort[0]
1751
+ if ':' in sort_spec:
1752
+ _, direction = sort_spec.split(':', 1)
1753
+ return direction
1754
+ return "desc"