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.
- agent0_sdk/__init__.py +7 -3
- agent0_sdk/core/contracts.py +1 -0
- agent0_sdk/core/feedback_manager.py +75 -78
- agent0_sdk/core/indexer.py +645 -635
- agent0_sdk/core/models.py +91 -12
- agent0_sdk/core/sdk.py +26 -314
- agent0_sdk/core/semantic_search_client.py +70 -0
- agent0_sdk/core/subgraph_client.py +182 -239
- {agent0_sdk-1.4.2.dist-info → agent0_sdk-1.5.1b1.dist-info}/METADATA +163 -9
- agent0_sdk-1.5.1b1.dist-info/RECORD +22 -0
- agent0_sdk-1.4.2.dist-info/RECORD +0 -21
- {agent0_sdk-1.4.2.dist-info → agent0_sdk-1.5.1b1.dist-info}/WHEEL +0 -0
- {agent0_sdk-1.4.2.dist-info → agent0_sdk-1.5.1b1.dist-info}/licenses/LICENSE +0 -0
- {agent0_sdk-1.4.2.dist-info → agent0_sdk-1.5.1b1.dist-info}/top_level.txt +0 -0
agent0_sdk/core/indexer.py
CHANGED
|
@@ -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,
|
|
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 =
|
|
329
|
-
a2a =
|
|
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')
|
|
426
|
-
a2a=reg_file.get('a2aEndpoint')
|
|
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=
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
"
|
|
519
|
-
"
|
|
520
|
-
"
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
858
|
+
if candidates:
|
|
859
|
+
cset = set(candidates)
|
|
860
|
+
ids = [x for x in ids if x in cset]
|
|
635
861
|
|
|
636
|
-
|
|
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
|
-
|
|
864
|
+
return allow, stats
|
|
678
865
|
|
|
679
|
-
|
|
680
|
-
if
|
|
681
|
-
raise
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
702
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
"
|
|
715
|
-
"
|
|
716
|
-
"
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
"
|
|
730
|
-
"
|
|
731
|
-
"
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
749
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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
|
-
|
|
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
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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
|
-
|
|
908
|
-
|
|
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
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
#
|
|
1673
|
-
|
|
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
|
-
|
|
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."""
|