agent0-sdk 1.4.2__py3-none-any.whl → 1.5.1b1__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.
@@ -37,12 +37,14 @@ from datetime import datetime
37
37
 
38
38
  from .models import (
39
39
  AgentId, ChainId, Address, URI, Timestamp,
40
- AgentSummary, Feedback, SearchParams, SearchFeedbackParams
40
+ AgentSummary, Feedback, SearchFilters, SearchOptions, SearchFeedbackParams
41
41
  )
42
42
  from .web3_client import Web3Client
43
43
 
44
44
  logger = logging.getLogger(__name__)
45
45
 
46
+ from .semantic_search_client import SemanticSearchClient
47
+
46
48
 
47
49
  class AgentIndexer:
48
50
  """Indexer for agent discovery and search."""
@@ -323,10 +325,25 @@ class AgentIndexer:
323
325
  registration_data: Dict[str, Any]
324
326
  ) -> AgentSummary:
325
327
  """Create agent summary from registration data."""
326
- # Extract endpoints
328
+ # Extract endpoints (legacy/non-subgraph path)
327
329
  endpoints = registration_data.get("endpoints", [])
328
- mcp = any(ep.get("name") == "MCP" for ep in endpoints)
329
- a2a = any(ep.get("name") == "A2A" for ep in endpoints)
330
+ mcp: Optional[str] = None
331
+ a2a: Optional[str] = None
332
+ web: Optional[str] = None
333
+ email: Optional[str] = None
334
+ for ep in endpoints:
335
+ name = (ep.get("name") or "").upper()
336
+ value = ep.get("endpoint")
337
+ if not isinstance(value, str):
338
+ continue
339
+ if name == "MCP":
340
+ mcp = value
341
+ elif name == "A2A":
342
+ a2a = value
343
+ elif name == "WEB":
344
+ web = value
345
+ elif name == "EMAIL":
346
+ email = value
330
347
 
331
348
  ens = None
332
349
  did = None
@@ -352,6 +369,8 @@ class AgentIndexer:
352
369
  operators=[], # Would be populated from contract
353
370
  mcp=mcp,
354
371
  a2a=a2a,
372
+ web=web,
373
+ email=email,
355
374
  ens=ens,
356
375
  did=did,
357
376
  walletAddress=registration_data.get("walletAddress"),
@@ -360,6 +379,8 @@ class AgentIndexer:
360
379
  mcpTools=mcp_tools,
361
380
  mcpPrompts=mcp_prompts,
362
381
  mcpResources=mcp_resources,
382
+ oasfSkills=[],
383
+ oasfDomains=[],
363
384
  active=registration_data.get("active", True),
364
385
  extras={}
365
386
  )
@@ -422,18 +443,28 @@ class AgentIndexer:
422
443
  description=reg_file.get('description', ''),
423
444
  owners=[agent_data.get('owner', '')],
424
445
  operators=agent_data.get('operators', []),
425
- mcp=reg_file.get('mcpEndpoint') is not None,
426
- a2a=reg_file.get('a2aEndpoint') is not None,
446
+ mcp=reg_file.get('mcpEndpoint') or None,
447
+ a2a=reg_file.get('a2aEndpoint') or None,
448
+ web=reg_file.get('webEndpoint') or None,
449
+ email=reg_file.get('emailEndpoint') or None,
427
450
  ens=reg_file.get('ens'),
428
451
  did=reg_file.get('did'),
429
- walletAddress=reg_file.get('agentWallet'),
452
+ walletAddress=agent_data.get('agentWallet'),
430
453
  supportedTrusts=reg_file.get('supportedTrusts', []),
431
454
  a2aSkills=reg_file.get('a2aSkills', []),
432
455
  mcpTools=reg_file.get('mcpTools', []),
433
456
  mcpPrompts=reg_file.get('mcpPrompts', []),
434
457
  mcpResources=reg_file.get('mcpResources', []),
458
+ oasfSkills=reg_file.get('oasfSkills', []) or [],
459
+ oasfDomains=reg_file.get('oasfDomains', []) or [],
435
460
  active=reg_file.get('active', True),
436
461
  x402support=reg_file.get('x402Support', reg_file.get('x402support', False)),
462
+ createdAt=agent_data.get('createdAt'),
463
+ updatedAt=agent_data.get('updatedAt'),
464
+ lastActivity=agent_data.get('lastActivity'),
465
+ agentURI=agent_data.get('agentURI'),
466
+ agentURIType=agent_data.get('agentURIType'),
467
+ feedbackCount=agent_data.get('totalFeedback'),
437
468
  extras={}
438
469
  )
439
470
 
@@ -442,540 +473,605 @@ class AgentIndexer:
442
473
 
443
474
  def search_agents(
444
475
  self,
445
- params: SearchParams,
446
- sort: List[str],
447
- page_size: int,
448
- cursor: Optional[str] = None,
449
- ) -> Dict[str, Any]:
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)
476
+ filters: SearchFilters,
477
+ options: SearchOptions,
478
+ ) -> List[AgentSummary]:
479
+ """Unified search entry point (replaces all legacy search variants)."""
480
+ if filters.keyword and str(filters.keyword).strip():
481
+ return self._search_unified_with_keyword(filters, options)
482
+ else:
483
+ return self._search_unified_no_keyword(filters, options)
484
+
485
+ # -------------------------------------------------------------------------
486
+ # Unified search (v2)
487
+ # -------------------------------------------------------------------------
488
+
489
+ def _parse_sort(self, sort: Optional[List[str]], keyword_present: bool) -> tuple[str, str]:
490
+ default = "semanticScore:desc" if keyword_present else "updatedAt:desc"
491
+ spec = (sort[0] if sort and len(sort) > 0 else default) or default
492
+ parts = spec.split(":", 1)
493
+ field = parts[0] if parts and parts[0] else ("semanticScore" if keyword_present else "updatedAt")
494
+ direction = (parts[1] if len(parts) > 1 else "desc").lower()
495
+ if direction not in ("asc", "desc"):
496
+ direction = "desc"
497
+ return field, direction
498
+
499
+ def _resolve_chains(self, filters: SearchFilters, keyword_present: bool) -> List[int]:
500
+ if filters.chains == "all":
501
+ return self._get_all_configured_chains()
502
+ if isinstance(filters.chains, list) and len(filters.chains) > 0:
503
+ return filters.chains
504
+ if keyword_present:
505
+ return self._get_all_configured_chains()
506
+ return [self.web3_client.chain_id]
507
+
508
+ # Pagination removed: cursor helpers deleted.
509
+
510
+ def _to_unix_seconds(self, dt: Any) -> int:
511
+ if isinstance(dt, int):
512
+ return dt
513
+ if isinstance(dt, datetime):
514
+ return int(dt.timestamp())
515
+ s = str(dt).strip()
516
+ if not s:
517
+ raise ValueError("Empty date")
518
+ # If no timezone, treat as UTC by appending 'Z'
519
+ if not ("Z" in s or "z" in s or "+" in s or "-" in s[-6:]):
520
+ s = f"{s}Z"
521
+ return int(datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp())
522
+
523
+ def _normalize_agent_ids(self, filters: SearchFilters, chains: List[int]) -> Optional[Dict[int, List[str]]]:
524
+ if not filters.agentIds:
525
+ return None
526
+ by_chain: Dict[int, List[str]] = {}
527
+ for aid in filters.agentIds:
528
+ s = str(aid)
529
+ if ":" in s:
530
+ chain_str = s.split(":", 1)[0]
531
+ try:
532
+ chain_id = int(chain_str)
533
+ except Exception:
534
+ continue
535
+ by_chain.setdefault(chain_id, []).append(s)
536
+ else:
537
+ if len(chains) != 1:
538
+ raise ValueError("agentIds without chain prefix are only allowed when searching exactly one chain.")
539
+ by_chain.setdefault(chains[0], []).append(f"{chains[0]}:{s}")
540
+ return by_chain
541
+
542
+ def _build_where_v2(self, filters: SearchFilters, ids_for_chain: Optional[List[str]] = None) -> Dict[str, Any]:
543
+ base: Dict[str, Any] = {}
544
+ and_conditions: List[Dict[str, Any]] = []
545
+
546
+ # Default: only agents with registration files
547
+ if filters.hasRegistrationFile is False:
548
+ base["registrationFile"] = None
549
+ else:
550
+ base["registrationFile_not"] = None
551
+
552
+ if ids_for_chain:
553
+ base["id_in"] = ids_for_chain
554
+
555
+ if filters.walletAddress:
556
+ base["agentWallet"] = str(filters.walletAddress).lower()
557
+
558
+ # Feedback existence filters can be pushed down via Agent.totalFeedback when they are the ONLY feedback constraint.
559
+ fb = filters.feedback
560
+ if fb and (getattr(fb, "hasFeedback", False) or getattr(fb, "hasNoFeedback", False)):
561
+ has_threshold = any(
562
+ x is not None
563
+ for x in [
564
+ getattr(fb, "minCount", None),
565
+ getattr(fb, "maxCount", None),
566
+ getattr(fb, "minValue", None),
567
+ getattr(fb, "maxValue", None),
568
+ ]
486
569
  )
570
+ has_any_constraint = any(
571
+ [
572
+ bool(getattr(fb, "hasResponse", False)),
573
+ bool(getattr(fb, "fromReviewers", None)),
574
+ bool(getattr(fb, "endpoint", None)),
575
+ bool(getattr(fb, "tag", None)),
576
+ bool(getattr(fb, "tag1", None)),
577
+ bool(getattr(fb, "tag2", None)),
578
+ ]
579
+ )
580
+ if not has_threshold and not has_any_constraint:
581
+ if getattr(fb, "hasFeedback", False):
582
+ base["totalFeedback_gt"] = "0"
583
+ if getattr(fb, "hasNoFeedback", False):
584
+ base["totalFeedback"] = "0"
585
+
586
+ if filters.owners:
587
+ base["owner_in"] = [str(o).lower() for o in filters.owners]
588
+
589
+ if filters.operators:
590
+ ops = [str(o).lower() for o in filters.operators]
591
+ and_conditions.append({"or": [{"operators_contains": [op]} for op in ops]})
592
+
593
+ if filters.registeredAtFrom is not None:
594
+ base["createdAt_gte"] = self._to_unix_seconds(filters.registeredAtFrom)
595
+ if filters.registeredAtTo is not None:
596
+ base["createdAt_lte"] = self._to_unix_seconds(filters.registeredAtTo)
597
+ if filters.updatedAtFrom is not None:
598
+ base["updatedAt_gte"] = self._to_unix_seconds(filters.updatedAtFrom)
599
+ if filters.updatedAtTo is not None:
600
+ base["updatedAt_lte"] = self._to_unix_seconds(filters.updatedAtTo)
601
+
602
+ rf: Dict[str, Any] = {}
603
+ if filters.name:
604
+ rf["name_contains_nocase"] = filters.name
605
+ if filters.description:
606
+ rf["description_contains_nocase"] = filters.description
607
+ if filters.ensContains:
608
+ rf["ens_contains_nocase"] = filters.ensContains
609
+ if filters.didContains:
610
+ rf["did_contains_nocase"] = filters.didContains
611
+ if filters.active is not None:
612
+ rf["active"] = filters.active
613
+ if filters.x402support is not None:
614
+ rf["x402Support"] = filters.x402support
615
+
616
+ if filters.hasMCP is not None:
617
+ rf["mcpEndpoint_not" if filters.hasMCP else "mcpEndpoint"] = None
618
+ if filters.hasA2A is not None:
619
+ rf["a2aEndpoint_not" if filters.hasA2A else "a2aEndpoint"] = None
620
+ if filters.hasWeb is not None:
621
+ rf["webEndpoint_not" if filters.hasWeb else "webEndpoint"] = None
622
+ if filters.hasOASF is not None:
623
+ # Exact semantics: true iff (oasfSkills OR oasfDomains) is non-empty (via subgraph derived field).
624
+ rf["hasOASF"] = bool(filters.hasOASF)
625
+
626
+ if filters.mcpContains:
627
+ rf["mcpEndpoint_contains_nocase"] = filters.mcpContains
628
+ if filters.a2aContains:
629
+ rf["a2aEndpoint_contains_nocase"] = filters.a2aContains
630
+ if filters.webContains:
631
+ rf["webEndpoint_contains_nocase"] = filters.webContains
632
+
633
+ if rf:
634
+ base["registrationFile_"] = rf
635
+
636
+ def any_of_list(field: str, values: Optional[List[str]]):
637
+ if not values:
638
+ return
639
+ and_conditions.append({"or": [{"registrationFile_": {f"{field}_contains": [v]}} for v in values]})
640
+
641
+ any_of_list("supportedTrusts", filters.supportedTrust)
642
+ any_of_list("a2aSkills", filters.a2aSkills)
643
+ any_of_list("mcpTools", filters.mcpTools)
644
+ any_of_list("mcpPrompts", filters.mcpPrompts)
645
+ any_of_list("mcpResources", filters.mcpResources)
646
+ any_of_list("oasfSkills", filters.oasfSkills)
647
+ any_of_list("oasfDomains", filters.oasfDomains)
648
+
649
+ if filters.hasEndpoints is not None:
650
+ if filters.hasEndpoints:
651
+ and_conditions.append(
652
+ {
653
+ "or": [
654
+ {"registrationFile_": {"webEndpoint_not": None}},
655
+ {"registrationFile_": {"mcpEndpoint_not": None}},
656
+ {"registrationFile_": {"a2aEndpoint_not": None}},
657
+ ]
658
+ }
659
+ )
660
+ else:
661
+ and_conditions.append({"registrationFile_": {"webEndpoint": None, "mcpEndpoint": None, "a2aEndpoint": None}})
487
662
 
488
- # Use subgraph if available (preferred)
489
- if self.subgraph_client:
490
- return self._search_agents_via_subgraph(params, sort, page_size, cursor)
491
-
492
- # Fallback to blockchain queries
493
- return self._search_agents_via_blockchain(params, sort, page_size, cursor)
663
+ if not and_conditions:
664
+ return base
665
+ return {"and": [base, *and_conditions]}
494
666
 
495
- async def _search_agents_across_chains(
667
+ def _intersect_ids(self, a: Optional[List[str]], b: Optional[List[str]]) -> Optional[List[str]]:
668
+ if a is None and b is None:
669
+ return None
670
+ if a is None:
671
+ return b or []
672
+ if b is None:
673
+ return a or []
674
+ bset = set(b)
675
+ return [x for x in a if x in bset]
676
+
677
+ def _utf8_to_hex(self, s: str) -> str:
678
+ return "0x" + s.encode("utf-8").hex()
679
+
680
+ def _prefilter_by_metadata(self, filters: SearchFilters, chains: List[int]) -> Optional[Dict[int, List[str]]]:
681
+ key = filters.hasMetadataKey or (filters.metadataValue.get("key") if isinstance(filters.metadataValue, dict) else None)
682
+ if not key:
683
+ return None
684
+ value_str = None
685
+ if isinstance(filters.metadataValue, dict):
686
+ value_str = filters.metadataValue.get("value")
687
+ value_hex = self._utf8_to_hex(str(value_str)) if value_str is not None else None
688
+
689
+ first = 1000
690
+ out: Dict[int, List[str]] = {}
691
+
692
+ for chain_id in chains:
693
+ sub = self._get_subgraph_client_for_chain(chain_id)
694
+ if sub is None:
695
+ out[chain_id] = []
696
+ continue
697
+ ids: List[str] = []
698
+ skip = 0
699
+ while True:
700
+ where: Dict[str, Any] = {"key": key}
701
+ if value_hex is not None:
702
+ where["value"] = value_hex
703
+ rows = sub.query_agent_metadatas(where=where, first=first, skip=skip)
704
+ for r in rows:
705
+ agent = r.get("agent") or {}
706
+ aid = agent.get("id")
707
+ if aid:
708
+ ids.append(str(aid))
709
+ if len(rows) < first:
710
+ break
711
+ skip += first
712
+ out[chain_id] = sorted(list(set(ids)))
713
+ return out
714
+
715
+ def _prefilter_by_feedback(
496
716
  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
- }
717
+ filters: SearchFilters,
718
+ chains: List[int],
719
+ candidate_ids_by_chain: Optional[Dict[int, List[str]]] = None,
720
+ ) -> tuple[Optional[Dict[int, List[str]]], Dict[str, Dict[str, float]]]:
721
+ fb = filters.feedback
722
+ if fb is None:
723
+ return None, {}
724
+
725
+ include_revoked = bool(getattr(fb, "includeRevoked", False))
726
+ has_threshold = any(
727
+ x is not None
728
+ for x in [
729
+ getattr(fb, "minCount", None),
730
+ getattr(fb, "maxCount", None),
731
+ getattr(fb, "minValue", None),
732
+ getattr(fb, "maxValue", None),
733
+ ]
734
+ )
735
+ has_any_constraint = any(
736
+ [
737
+ bool(getattr(fb, "hasResponse", False)),
738
+ bool(getattr(fb, "fromReviewers", None)),
739
+ bool(getattr(fb, "endpoint", None)),
740
+ bool(getattr(fb, "tag", None)),
741
+ bool(getattr(fb, "tag1", None)),
742
+ bool(getattr(fb, "tag2", None)),
743
+ ]
744
+ )
557
745
 
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
- )
746
+ # If hasNoFeedback/hasFeedback are the ONLY feedback constraint, we push them down via Agent.totalFeedback in _build_where_v2.
747
+ if getattr(fb, "hasNoFeedback", False) and not has_threshold and not has_any_constraint:
748
+ return None, {}
749
+ if getattr(fb, "hasFeedback", False) and not has_threshold and not has_any_constraint:
750
+ return None, {}
751
+
752
+ # Otherwise, hasNoFeedback requires an explicit candidate set to subtract from.
753
+ if getattr(fb, "hasNoFeedback", False):
754
+ if not candidate_ids_by_chain or not any(candidate_ids_by_chain.get(c) for c in chains):
755
+ raise ValueError("feedback.hasNoFeedback requires a pre-filtered candidate set (e.g. agentIds or keyword).")
756
+
757
+ first = 1000
758
+
759
+ sums: Dict[str, float] = {}
760
+ counts: Dict[str, int] = {}
761
+ matched_by_chain: Dict[int, set[str]] = {}
762
+
763
+ for chain_id in chains:
764
+ sub = self._get_subgraph_client_for_chain(chain_id)
765
+ if sub is None:
766
+ continue
767
+ candidates = (candidate_ids_by_chain or {}).get(chain_id)
768
+
769
+ base: Dict[str, Any] = {}
770
+ and_conditions: List[Dict[str, Any]] = []
771
+
772
+ if not include_revoked:
773
+ base["isRevoked"] = False
774
+ from_reviewers = getattr(fb, "fromReviewers", None)
775
+ if from_reviewers:
776
+ base["clientAddress_in"] = [str(a).lower() for a in from_reviewers]
777
+ endpoint = getattr(fb, "endpoint", None)
778
+ if endpoint:
779
+ base["endpoint_contains_nocase"] = endpoint
780
+ if candidates:
781
+ base["agent_in"] = candidates
782
+
783
+ tag1 = getattr(fb, "tag1", None)
784
+ tag2 = getattr(fb, "tag2", None)
785
+ tag = getattr(fb, "tag", None)
786
+ if tag1:
787
+ base["tag1"] = tag1
788
+ if tag2:
789
+ base["tag2"] = tag2
790
+ if tag:
791
+ and_conditions.append({"or": [{"tag1": tag}, {"tag2": tag}]})
792
+
793
+ where: Dict[str, Any] = {"and": [base, *and_conditions]} if and_conditions else base
794
+
795
+ skip = 0
796
+ while True:
797
+ rows = sub.query_feedbacks_minimal(where=where, first=first, skip=skip, order_by="createdAt", order_direction="desc")
798
+ for r in rows:
799
+ agent = r.get("agent") or {}
800
+ aid = agent.get("id")
801
+ if not aid:
802
+ continue
803
+ if getattr(fb, "hasResponse", False):
804
+ responses = r.get("responses") or []
805
+ if not isinstance(responses, list) or len(responses) == 0:
806
+ continue
807
+ try:
808
+ v = float(r.get("value"))
809
+ except Exception:
810
+ continue
811
+ aid_s = str(aid)
812
+ sums[aid_s] = sums.get(aid_s, 0.0) + v
813
+ counts[aid_s] = counts.get(aid_s, 0) + 1
814
+ matched_by_chain.setdefault(chain_id, set()).add(aid_s)
815
+ if len(rows) < first:
816
+ break
817
+ skip += first
818
+
819
+ stats: Dict[str, Dict[str, float]] = {}
820
+ for aid, cnt in counts.items():
821
+ avg = (sums.get(aid, 0.0) / cnt) if cnt > 0 else 0.0
822
+ stats[aid] = {"count": float(cnt), "avg": float(avg)}
823
+
824
+ def passes(aid: str) -> bool:
825
+ st = stats.get(aid, {"count": 0.0, "avg": 0.0})
826
+ cnt = st["count"]
827
+ avg = st["avg"]
828
+ min_count = getattr(fb, "minCount", None)
829
+ max_count = getattr(fb, "maxCount", None)
830
+ min_val = getattr(fb, "minValue", None)
831
+ max_val = getattr(fb, "maxValue", None)
832
+ if min_count is not None and cnt < float(min_count):
833
+ return False
834
+ if max_count is not None and cnt > float(max_count):
835
+ return False
836
+ if min_val is not None and avg < float(min_val):
837
+ return False
838
+ if max_val is not None and avg > float(max_val):
839
+ return False
840
+ return True
613
841
 
614
- logger.info(f"Chain {chain_id}: fetched {len(agents)} agents")
842
+ allow: Dict[int, List[str]] = {}
843
+ for chain_id in chains:
844
+ matched = matched_by_chain.get(chain_id, set())
845
+ candidates = (candidate_ids_by_chain or {}).get(chain_id)
615
846
 
616
- return {
617
- "chainId": chain_id,
618
- "status": "success",
619
- "agents": agents,
620
- "count": len(agents),
621
- }
847
+ if getattr(fb, "hasNoFeedback", False):
848
+ base_list = candidates or []
849
+ allow[chain_id] = [x for x in base_list if x not in matched]
850
+ continue
622
851
 
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
- }
852
+ ids = list(matched)
853
+ if has_threshold:
854
+ ids = [x for x in ids if passes(x)]
855
+ elif has_any_constraint or getattr(fb, "hasFeedback", False):
856
+ ids = [x for x in ids if counts.get(x, 0) > 0]
631
857
 
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]
858
+ if candidates:
859
+ cset = set(candidates)
860
+ ids = [x for x in ids if x in cset]
635
861
 
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
- )
862
+ allow[chain_id] = ids
676
863
 
677
- logger.info(f"Multi-chain query: {len(successful_chains)} successful, {len(failed_chains)} failed, {len(all_agents)} total agents")
864
+ return allow, stats
678
865
 
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
- )
866
+ def _search_unified_no_keyword(self, filters: SearchFilters, options: SearchOptions) -> List[AgentSummary]:
867
+ if not self.subgraph_client:
868
+ raise ValueError("Subgraph client required for searchAgents")
684
869
 
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")
870
+ field, direction = self._parse_sort(options.sort, False)
871
+ chains = self._resolve_chains(filters, False)
872
+ ids_by_chain = self._normalize_agent_ids(filters, chains)
873
+ metadata_ids_by_chain = self._prefilter_by_metadata(filters, chains)
688
874
 
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")
875
+ candidate_for_feedback: Dict[int, List[str]] = {}
876
+ for c in chains:
877
+ ids0 = self._intersect_ids((ids_by_chain or {}).get(c), (metadata_ids_by_chain or {}).get(c))
878
+ if ids0:
879
+ candidate_for_feedback[c] = ids0
692
880
 
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")
881
+ feedback_ids_by_chain, feedback_stats_by_id = self._prefilter_by_feedback(
882
+ filters, chains, candidate_for_feedback if candidate_for_feedback else None
883
+ )
696
884
 
697
- # Step 9: Apply pagination
698
- start_idx = global_offset
699
- paginated_agents = sorted_agents[start_idx:start_idx + page_size]
885
+ order_by = field if field in ("createdAt", "updatedAt", "name", "chainId", "lastActivity", "totalFeedback") else "updatedAt"
886
+ if field == "feedbackCount":
887
+ order_by = "totalFeedback"
700
888
 
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 {}
889
+ def to_summary(agent_data: Dict[str, Any]) -> AgentSummary:
890
+ reg_file = agent_data.get("registrationFile") or {}
705
891
  if not isinstance(reg_file, dict):
706
892
  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', 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
893
+ aid = str(agent_data.get("id", ""))
894
+ st = feedback_stats_by_id.get(aid) or {}
895
+ return AgentSummary(
896
+ chainId=int(agent_data.get("chainId", 0)),
897
+ agentId=aid,
898
+ name=reg_file.get("name") or aid,
899
+ image=reg_file.get("image"),
900
+ description=reg_file.get("description", "") or "",
901
+ owners=[agent_data.get("owner", "")] if agent_data.get("owner") else [],
902
+ operators=agent_data.get("operators", []) or [],
903
+ mcp=reg_file.get("mcpEndpoint") or None,
904
+ a2a=reg_file.get("a2aEndpoint") or None,
905
+ web=reg_file.get("webEndpoint") or None,
906
+ email=reg_file.get("emailEndpoint") or None,
907
+ ens=reg_file.get("ens"),
908
+ did=reg_file.get("did"),
909
+ walletAddress=agent_data.get("agentWallet"),
910
+ supportedTrusts=reg_file.get("supportedTrusts", []) or [],
911
+ a2aSkills=reg_file.get("a2aSkills", []) or [],
912
+ mcpTools=reg_file.get("mcpTools", []) or [],
913
+ mcpPrompts=reg_file.get("mcpPrompts", []) or [],
914
+ mcpResources=reg_file.get("mcpResources", []) or [],
915
+ oasfSkills=reg_file.get("oasfSkills", []) or [],
916
+ oasfDomains=reg_file.get("oasfDomains", []) or [],
917
+ active=bool(reg_file.get("active", False)),
918
+ x402support=bool(reg_file.get("x402Support", reg_file.get("x402support", False))),
919
+ createdAt=agent_data.get("createdAt"),
920
+ updatedAt=agent_data.get("updatedAt"),
921
+ lastActivity=agent_data.get("lastActivity"),
922
+ agentURI=agent_data.get("agentURI"),
923
+ agentURIType=agent_data.get("agentURIType"),
924
+ feedbackCount=agent_data.get("totalFeedback"),
925
+ averageValue=float(st.get("avg")) if st.get("avg") is not None else None,
926
+ extras={},
746
927
  )
747
928
 
748
- # Step 12: Build response with metadata
749
- query_time = time.time() - start_time
929
+ batch = 1000
930
+ out: List[AgentSummary] = []
931
+ for chain_id in chains:
932
+ client = self._get_subgraph_client_for_chain(chain_id)
933
+ if client is None:
934
+ continue
935
+ ids0 = self._intersect_ids((ids_by_chain or {}).get(chain_id), (metadata_ids_by_chain or {}).get(chain_id))
936
+ ids = self._intersect_ids(ids0, (feedback_ids_by_chain or {}).get(chain_id))
937
+ if ids is not None and len(ids) == 0:
938
+ continue
939
+ where = self._build_where_v2(filters, ids)
940
+
941
+ skip = 0
942
+ while True:
943
+ agents = client.get_agents_v2(where=where, first=batch, skip=skip, order_by=order_by, order_direction=direction)
944
+ for a in agents:
945
+ out.append(to_summary(a))
946
+ if len(agents) < batch:
947
+ break
948
+ skip += batch
949
+
950
+ reverse = direction == "desc"
951
+
952
+ def sort_key(a: AgentSummary):
953
+ if field == "name":
954
+ return (a.name or "").lower()
955
+ v = getattr(a, field, None)
956
+ if v is None and field == "totalFeedback":
957
+ v = getattr(a, "feedbackCount", None)
958
+ if v is None:
959
+ return 0.0
960
+ try:
961
+ return float(v)
962
+ except Exception:
963
+ return 0.0
964
+
965
+ return sorted(out, key=sort_key, reverse=reverse)
750
966
 
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
- }
967
+ def _search_unified_with_keyword(self, filters: SearchFilters, options: SearchOptions) -> List[AgentSummary]:
968
+ field, direction = self._parse_sort(options.sort, True)
969
+ chains = self._resolve_chains(filters, True)
766
970
 
767
- def _search_agents_via_subgraph(
768
- self,
769
- params: SearchParams,
770
- sort: List[str],
771
- page_size: int,
772
- cursor: Optional[str] = None,
773
- ) -> Dict[str, Any]:
774
- """Search for agents using the subgraph."""
775
- # Build subgraph query filters
776
- where_clause = {}
777
- reg_file_where = {}
778
-
779
- if params.name is not None:
780
- reg_file_where["name_contains"] = params.name
781
- if params.active is not None:
782
- reg_file_where["active"] = params.active
783
- if params.x402support is not None:
784
- reg_file_where["x402support"] = params.x402support
785
- if params.mcp is not None:
786
- if params.mcp:
787
- reg_file_where["mcpEndpoint_not"] = None
788
- else:
789
- reg_file_where["mcpEndpoint"] = None
790
- if params.a2a is not None:
791
- if params.a2a:
792
- reg_file_where["a2aEndpoint_not"] = None
793
- else:
794
- reg_file_where["a2aEndpoint"] = None
795
- if params.ens is not None:
796
- reg_file_where["ens"] = params.ens
797
- if params.did is not None:
798
- reg_file_where["did"] = params.did
799
- if params.walletAddress is not None:
800
- reg_file_where["agentWallet"] = params.walletAddress
801
-
802
- if reg_file_where:
803
- where_clause["registrationFile_"] = reg_file_where
804
-
805
- # Owner filtering
806
- if params.owners is not None and len(params.owners) > 0:
807
- # Normalize addresses to lowercase for case-insensitive matching
808
- normalized_owners = [owner.lower() for owner in params.owners]
809
- if len(normalized_owners) == 1:
810
- where_clause["owner"] = normalized_owners[0]
811
- else:
812
- where_clause["owner_in"] = normalized_owners
813
-
814
- # Operator filtering
815
- if params.operators is not None and len(params.operators) > 0:
816
- # Normalize addresses to lowercase for case-insensitive matching
817
- normalized_operators = [op.lower() for op in params.operators]
818
- # For operators (array field), use contains to check if any operator matches
819
- where_clause["operators_contains"] = normalized_operators
820
-
821
- # Calculate pagination
822
- skip = 0
823
- if cursor:
971
+ client = SemanticSearchClient()
972
+ semantic_results = client.search(
973
+ str(filters.keyword),
974
+ min_score=options.semanticMinScore,
975
+ top_k=options.semanticTopK,
976
+ )
977
+
978
+ allowed = set(chains)
979
+ semantic_results = [r for r in semantic_results if r.chainId in allowed]
980
+ ids_by_chain: Dict[int, List[str]] = {}
981
+ score_by_id: Dict[str, float] = {}
982
+ for r in semantic_results:
983
+ ids_by_chain.setdefault(r.chainId, []).append(r.agentId)
984
+ score_by_id[r.agentId] = r.score
985
+
986
+ fetched: List[AgentSummary] = []
987
+
988
+ metadata_ids_by_chain = self._prefilter_by_metadata(filters, chains)
989
+ feedback_ids_by_chain, feedback_stats_by_id = self._prefilter_by_feedback(filters, chains, ids_by_chain)
990
+
991
+ # Query agents by id_in chunks and apply remaining filters via where.
992
+ chunk_size = 500
993
+ for chain_id in chains:
994
+ sub = self._get_subgraph_client_for_chain(chain_id)
995
+ ids = ids_by_chain.get(chain_id, [])
996
+ if sub is None:
997
+ continue
824
998
  try:
825
- skip = int(cursor)
826
- except ValueError:
827
- skip = 0
828
-
829
- # Determine sort
830
- order_by = "createdAt"
831
- order_direction = "desc"
832
- if sort and len(sort) > 0:
833
- sort_field = sort[0].split(":")
834
- if len(sort_field) >= 1:
835
- order_by = sort_field[0]
836
- if len(sort_field) >= 2:
837
- order_direction = sort_field[1]
838
-
839
- try:
840
- agents = self.subgraph_client.get_agents(
841
- where=where_clause if where_clause else None,
842
- first=page_size,
843
- skip=skip,
844
- order_by=order_by,
845
- order_direction=order_direction
846
- )
847
-
848
- results = []
849
- for agent in agents:
850
- reg_file = agent.get('registrationFile') or {}
851
- # Ensure reg_file is a dict
852
- if not isinstance(reg_file, dict):
853
- reg_file = {}
854
-
855
- agent_data = {
856
- "agentId": agent.get('id'),
857
- "chainId": agent.get('chainId'),
858
- "name": reg_file.get('name', f"Agent {agent.get('agentId')}"),
859
- "description": reg_file.get('description', ''),
860
- "image": reg_file.get('image'),
861
- "owner": agent.get('owner'),
862
- "operators": agent.get('operators', []),
863
- "mcp": reg_file.get('mcpEndpoint') is not None,
864
- "a2a": reg_file.get('a2aEndpoint') is not None,
865
- "ens": reg_file.get('ens'),
866
- "did": reg_file.get('did'),
867
- "walletAddress": reg_file.get('agentWallet'),
868
- "supportedTrusts": reg_file.get('supportedTrusts', []),
869
- "a2aSkills": reg_file.get('a2aSkills', []),
870
- "mcpTools": reg_file.get('mcpTools', []),
871
- "mcpPrompts": reg_file.get('mcpPrompts', []),
872
- "mcpResources": reg_file.get('mcpResources', []),
873
- "active": reg_file.get('active', True),
874
- "x402support": reg_file.get('x402Support', reg_file.get('x402support', False)),
875
- "totalFeedback": agent.get('totalFeedback', 0),
876
- "lastActivity": agent.get('lastActivity'),
877
- "updatedAt": agent.get('updatedAt'),
878
- "extras": {}
879
- }
880
-
881
- if params.chains is not None:
882
- if agent_data["chainId"] not in params.chains:
999
+ for i in range(0, len(ids), chunk_size):
1000
+ chunk = ids[i : i + chunk_size]
1001
+ ids2 = self._intersect_ids(chunk, (metadata_ids_by_chain or {}).get(chain_id))
1002
+ ids3 = self._intersect_ids(ids2, (feedback_ids_by_chain or {}).get(chain_id))
1003
+ if ids3 is not None and len(ids3) == 0:
883
1004
  continue
884
- if params.supportedTrust is not None:
885
- if not any(trust in agent_data["supportedTrusts"] for trust in params.supportedTrust):
1005
+ if ids3 is not None and len(ids3) == 0:
886
1006
  continue
887
-
888
- results.append(agent_data)
889
-
890
- next_cursor = str(skip + len(results)) if len(results) == page_size else None
891
- return {"items": results, "nextCursor": next_cursor}
892
-
893
- except Exception as e:
894
- logger.warning(f"Subgraph search failed: {e}")
895
- return {"items": [], "nextCursor": None}
896
-
897
- def _search_agents_via_blockchain(
898
- self,
899
- params: SearchParams,
900
- sort: List[str],
901
- page_size: int,
902
- cursor: Optional[str] = None,
903
- ) -> Dict[str, Any]:
904
- """Search for agents by querying the blockchain (fallback)."""
905
- return {"items": [], "nextCursor": None}
1007
+ where = self._build_where_v2(filters, ids3)
1008
+ agents = sub.get_agents_v2(where=where, first=len(ids3 or []), skip=0, order_by="updatedAt", order_direction="desc")
1009
+ for a in agents:
1010
+ reg_file = a.get("registrationFile") or {}
1011
+ if not isinstance(reg_file, dict):
1012
+ reg_file = {}
1013
+ aid = str(a.get("id", ""))
1014
+ st = feedback_stats_by_id.get(aid) or {}
1015
+ fetched.append(
1016
+ AgentSummary(
1017
+ chainId=int(a.get("chainId", 0)),
1018
+ agentId=aid,
1019
+ name=reg_file.get("name") or aid,
1020
+ image=reg_file.get("image"),
1021
+ description=reg_file.get("description", "") or "",
1022
+ owners=[a.get("owner", "")] if a.get("owner") else [],
1023
+ operators=a.get("operators", []) or [],
1024
+ mcp=reg_file.get("mcpEndpoint") or None,
1025
+ a2a=reg_file.get("a2aEndpoint") or None,
1026
+ web=reg_file.get("webEndpoint") or None,
1027
+ email=reg_file.get("emailEndpoint") or None,
1028
+ ens=reg_file.get("ens"),
1029
+ did=reg_file.get("did"),
1030
+ walletAddress=a.get("agentWallet"),
1031
+ supportedTrusts=reg_file.get("supportedTrusts", []) or [],
1032
+ a2aSkills=reg_file.get("a2aSkills", []) or [],
1033
+ mcpTools=reg_file.get("mcpTools", []) or [],
1034
+ mcpPrompts=reg_file.get("mcpPrompts", []) or [],
1035
+ mcpResources=reg_file.get("mcpResources", []) or [],
1036
+ oasfSkills=reg_file.get("oasfSkills", []) or [],
1037
+ oasfDomains=reg_file.get("oasfDomains", []) or [],
1038
+ active=bool(reg_file.get("active", False)),
1039
+ x402support=bool(reg_file.get("x402Support", reg_file.get("x402support", False))),
1040
+ createdAt=a.get("createdAt"),
1041
+ updatedAt=a.get("updatedAt"),
1042
+ lastActivity=a.get("lastActivity"),
1043
+ agentURI=a.get("agentURI"),
1044
+ agentURIType=a.get("agentURIType"),
1045
+ feedbackCount=a.get("totalFeedback"),
1046
+ semanticScore=float(score_by_id.get(aid, 0.0)),
1047
+ averageValue=float(st.get("avg")) if st.get("avg") is not None else None,
1048
+ extras={},
1049
+ )
1050
+ )
1051
+ except Exception:
1052
+ continue
1053
+
1054
+ # Default keyword sorting: semanticScore desc, unless overridden.
1055
+ sort_field = field if options.sort and len(options.sort) > 0 else "semanticScore"
1056
+ sort_dir = direction if options.sort and len(options.sort) > 0 else "desc"
1057
+
1058
+ def sort_key(agent: AgentSummary):
1059
+ v = getattr(agent, sort_field, None)
1060
+ if v is None:
1061
+ return 0
1062
+ if sort_field == "name":
1063
+ return (agent.name or "").lower()
1064
+ try:
1065
+ return float(v)
1066
+ except Exception:
1067
+ return 0
906
1068
 
907
- def _apply_filters(self, agents: List[Dict[str, Any]], params: SearchParams) -> List[Dict[str, Any]]:
908
- """Apply search filters to agents."""
909
- filtered = agents
910
-
911
- if params.chains is not None:
912
- filtered = [a for a in filtered if a.get("chainId") in params.chains]
913
-
914
- if params.name is not None:
915
- filtered = [a for a in filtered if params.name.lower() in a.get("name", "").lower()]
916
-
917
- if params.description is not None:
918
- # This would use semantic search with embeddings
919
- filtered = [a for a in filtered if params.description.lower() in a.get("description", "").lower()]
920
-
921
- if params.owners is not None:
922
- filtered = [a for a in filtered if any(owner in params.owners for owner in a.get("owners", []))]
923
-
924
- if params.operators is not None:
925
- filtered = [a for a in filtered if any(op in params.operators for op in a.get("operators", []))]
926
-
927
- if params.mcp is not None:
928
- filtered = [a for a in filtered if a.get("mcp") == params.mcp]
929
-
930
- if params.a2a is not None:
931
- filtered = [a for a in filtered if a.get("a2a") == params.a2a]
932
-
933
- if params.ens is not None:
934
- filtered = [a for a in filtered if a.get("ens") and params.ens.lower() in a.get("ens", "").lower()]
935
-
936
- if params.did is not None:
937
- filtered = [a for a in filtered if a.get("did") == params.did]
938
-
939
- if params.walletAddress is not None:
940
- filtered = [a for a in filtered if a.get("walletAddress") == params.walletAddress]
941
-
942
- if params.supportedTrust is not None:
943
- filtered = [a for a in filtered if any(trust in params.supportedTrust for trust in a.get("supportedTrusts", []))]
944
-
945
- if params.a2aSkills is not None:
946
- filtered = [a for a in filtered if any(skill in params.a2aSkills for skill in a.get("a2aSkills", []))]
947
-
948
- if params.mcpTools is not None:
949
- filtered = [a for a in filtered if any(tool in params.mcpTools for tool in a.get("mcpTools", []))]
950
-
951
- if params.mcpPrompts is not None:
952
- filtered = [a for a in filtered if any(prompt in params.mcpPrompts for prompt in a.get("mcpPrompts", []))]
953
-
954
- if params.mcpResources is not None:
955
- filtered = [a for a in filtered if any(resource in params.mcpResources for resource in a.get("mcpResources", []))]
956
-
957
- if params.active is not None:
958
- filtered = [a for a in filtered if a.get("active") == params.active]
959
-
960
- if params.x402support is not None:
961
- filtered = [a for a in filtered if a.get("x402support") == params.x402support]
962
-
963
- return filtered
1069
+ fetched.sort(key=sort_key, reverse=(sort_dir == "desc"))
1070
+ return fetched
964
1071
 
965
- def _apply_sorting(self, agents: List[AgentSummary], sort: List[str]) -> List[AgentSummary]:
966
- """Apply sorting to agents."""
967
- def sort_key(agent):
968
- key_values = []
969
- for sort_field in sort:
970
- field, direction = sort_field.split(":", 1)
971
- if hasattr(agent, field):
972
- value = getattr(agent, field)
973
- if direction == "desc":
974
- value = -value if isinstance(value, (int, float)) else value
975
- key_values.append(value)
976
- return key_values
977
-
978
- return sorted(agents, key=sort_key)
1072
+ # Pagination removed: legacy cursor-based multi-chain agent search deleted.
1073
+
1074
+ # Pagination removed: legacy cursor-based agent search helpers deleted.
979
1075
 
980
1076
  def get_feedback(
981
1077
  self,
@@ -1116,8 +1212,6 @@ class AgentIndexer:
1116
1212
  minValue: Optional[float] = None,
1117
1213
  maxValue: Optional[float] = None,
1118
1214
  include_revoked: bool = False,
1119
- first: int = 100,
1120
- skip: int = 0,
1121
1215
  agents: Optional[List[AgentId]] = None,
1122
1216
  ) -> List[Feedback]:
1123
1217
  """Search feedback via subgraph.
@@ -1186,8 +1280,6 @@ class AgentIndexer:
1186
1280
  minValue=minValue,
1187
1281
  maxValue=maxValue,
1188
1282
  include_revoked=include_revoked,
1189
- first=first,
1190
- skip=skip,
1191
1283
  subgraph_client=subgraph_client,
1192
1284
  )
1193
1285
 
@@ -1208,8 +1300,6 @@ class AgentIndexer:
1208
1300
  minValue: Optional[float],
1209
1301
  maxValue: Optional[float],
1210
1302
  include_revoked: bool,
1211
- first: int,
1212
- skip: int,
1213
1303
  subgraph_client: Optional[Any] = None,
1214
1304
  ) -> List[Feedback]:
1215
1305
  """Search feedback using subgraph."""
@@ -1238,34 +1328,39 @@ class AgentIndexer:
1238
1328
  includeRevoked=include_revoked
1239
1329
  )
1240
1330
 
1241
- # Query subgraph
1242
- feedbacks_data = client.search_feedback(
1243
- params=params,
1244
- first=first,
1245
- skip=skip,
1246
- order_by="createdAt",
1247
- order_direction="desc"
1248
- )
1249
-
1250
- # Map to Feedback objects
1251
1331
  feedbacks = []
1252
- for fb_data in feedbacks_data:
1253
- # Parse agentId from feedback ID
1254
- feedback_id = fb_data['id']
1255
- parts = feedback_id.split(':')
1256
- if len(parts) >= 2:
1257
- agent_id_str = f"{parts[0]}:{parts[1]}"
1258
- client_addr = parts[2] if len(parts) > 2 else ""
1259
- feedback_idx = int(parts[3]) if len(parts) > 3 else 1
1260
- else:
1261
- agent_id_str = feedback_id
1262
- client_addr = ""
1263
- feedback_idx = 1
1264
-
1265
- feedback = self._map_subgraph_feedback_to_model(
1266
- fb_data, agent_id_str, client_addr, feedback_idx
1332
+ batch = 1000
1333
+ skip = 0
1334
+ while True:
1335
+ feedbacks_data = client.search_feedback(
1336
+ params=params,
1337
+ first=batch,
1338
+ skip=skip,
1339
+ order_by="createdAt",
1340
+ order_direction="desc",
1267
1341
  )
1268
- feedbacks.append(feedback)
1342
+
1343
+ for fb_data in feedbacks_data:
1344
+ # Parse agentId from feedback ID
1345
+ feedback_id = fb_data['id']
1346
+ parts = feedback_id.split(':')
1347
+ if len(parts) >= 2:
1348
+ agent_id_str = f"{parts[0]}:{parts[1]}"
1349
+ client_addr = parts[2] if len(parts) > 2 else ""
1350
+ feedback_idx = int(parts[3]) if len(parts) > 3 else 1
1351
+ else:
1352
+ agent_id_str = feedback_id
1353
+ client_addr = ""
1354
+ feedback_idx = 1
1355
+
1356
+ feedback = self._map_subgraph_feedback_to_model(
1357
+ fb_data, agent_id_str, client_addr, feedback_idx
1358
+ )
1359
+ feedbacks.append(feedback)
1360
+
1361
+ if len(feedbacks_data) < batch:
1362
+ break
1363
+ skip += batch
1269
1364
 
1270
1365
  return feedbacks
1271
1366
 
@@ -1320,15 +1415,12 @@ class AgentIndexer:
1320
1415
  since: Optional[Timestamp] = None,
1321
1416
  until: Optional[Timestamp] = None,
1322
1417
  sort: List[str] = None,
1323
- page_size: int = 100,
1324
- cursor: Optional[str] = None,
1325
1418
  ) -> Dict[str, Any]:
1326
1419
  """Get reputation summary for an agent."""
1327
1420
  # This would aggregate feedback data
1328
1421
  # For now, return empty result
1329
1422
  return {
1330
1423
  "groups": [],
1331
- "nextCursor": None
1332
1424
  }
1333
1425
 
1334
1426
  def get_reputation_map(
@@ -1591,7 +1683,7 @@ class AgentIndexer:
1591
1683
  def _apply_cross_chain_filters(
1592
1684
  self,
1593
1685
  agents: List[Dict[str, Any]],
1594
- params: SearchParams
1686
+ params: SearchFilters
1595
1687
  ) -> List[Dict[str, Any]]:
1596
1688
  """
1597
1689
  Apply filters that couldn't be expressed in subgraph WHERE clause.
@@ -1656,7 +1748,7 @@ class AgentIndexer:
1656
1748
  def _deduplicate_agents_cross_chain(
1657
1749
  self,
1658
1750
  agents: List[Dict[str, Any]],
1659
- params: SearchParams
1751
+ params: SearchFilters
1660
1752
  ) -> List[Dict[str, Any]]:
1661
1753
  """
1662
1754
  Deduplicate agents across chains (if requested).
@@ -1669,42 +1761,8 @@ class AgentIndexer:
1669
1761
  - Keep the first instance encountered
1670
1762
  - Add 'deployedOn' array with all chain IDs where this agent exists
1671
1763
  """
1672
- # Check if deduplication requested
1673
- if not params.deduplicate_cross_chain:
1674
- return agents
1675
-
1676
- # Group agents by identity key
1677
- seen = {}
1678
- deduplicated = []
1679
-
1680
- for agent in agents:
1681
- # Create identity key: (owner, name, description)
1682
- # This identifies "the same agent" across chains
1683
- owner = agent.get('owner', '').lower()
1684
- reg_file = agent.get('registrationFile', {})
1685
- name = reg_file.get('name', '')
1686
- description = reg_file.get('description', '')
1687
-
1688
- identity_key = (owner, name, description)
1689
-
1690
- if identity_key not in seen:
1691
- # First time seeing this agent
1692
- seen[identity_key] = agent
1693
-
1694
- # Add deployedOn array
1695
- agent['deployedOn'] = [agent['chainId']]
1696
-
1697
- deduplicated.append(agent)
1698
- else:
1699
- # Already seen this agent on another chain
1700
- # Add this chain to deployedOn array
1701
- seen[identity_key]['deployedOn'].append(agent['chainId'])
1702
-
1703
- logger.info(
1704
- f"Deduplication: {len(agents)} agents → {len(deduplicated)} unique agents"
1705
- )
1706
-
1707
- return deduplicated
1764
+ # Deduplication across chains was part of an older API surface; the unified search does not deduplicate.
1765
+ return agents
1708
1766
 
1709
1767
  def _sort_agents_cross_chain(
1710
1768
  self,
@@ -1764,55 +1822,7 @@ class AgentIndexer:
1764
1822
 
1765
1823
  return sorted(agents, key=get_sort_key, reverse=reverse)
1766
1824
 
1767
- def _parse_multi_chain_cursor(self, cursor: Optional[str]) -> Dict[int, int]:
1768
- """
1769
- Parse multi-chain cursor into per-chain offsets.
1770
-
1771
- Cursor format (JSON):
1772
- {
1773
- "11155111": 50, # Ethereum Sepolia offset
1774
- "84532": 30, # Base Sepolia offset
1775
- "_global_offset": 100 # Total items returned so far
1776
- }
1777
-
1778
- Returns:
1779
- Dict mapping chainId → offset (default 0)
1780
- """
1781
- if not cursor:
1782
- return {}
1783
-
1784
- try:
1785
- cursor_data = json.loads(cursor)
1786
-
1787
- # Validate format
1788
- if not isinstance(cursor_data, dict):
1789
- logger.warning(f"Invalid cursor format: {cursor}, using empty")
1790
- return {}
1791
-
1792
- return cursor_data
1793
-
1794
- except json.JSONDecodeError as e:
1795
- logger.warning(f"Failed to parse cursor: {e}, using empty")
1796
- return {}
1797
-
1798
- def _create_multi_chain_cursor(
1799
- self,
1800
- global_offset: int,
1801
- ) -> str:
1802
- """
1803
- Create multi-chain cursor for next page.
1804
-
1805
- Args:
1806
- global_offset: Total items returned so far
1807
-
1808
- Returns:
1809
- JSON string cursor
1810
- """
1811
- cursor_data = {
1812
- "_global_offset": global_offset
1813
- }
1814
-
1815
- return json.dumps(cursor_data)
1825
+ # Pagination removed: multi-chain cursor helpers deleted.
1816
1826
 
1817
1827
  def _extract_order_by(self, sort: List[str]) -> str:
1818
1828
  """Extract order_by field from sort specification."""