Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/agent_endpoints/agent_search.py: 65%
74 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""Semantic ranking over the in-memory A2A agent registry, shared by GET /v1/agents?query= and the agent_search MCP tool."""
3from __future__ import annotations
5from collections.abc import Sequence
6from dataclasses import dataclass
7from typing import TYPE_CHECKING, Final, TypeAlias
9from pydantic import BaseModel, ConfigDict, ValidationError
11from litellm.proxy.common_utils.semantic_text_index import (
12 Embedder,
13 EmbeddingFailed,
14 SemanticTextIndex,
15 router_embedder,
16)
17from litellm.types.agents import AgentResponse
19if TYPE_CHECKING: 19 ↛ 20line 19 didn't jump to line 20 because the condition on line 19 was never true
20 from litellm.proxy._types import UserAPIKeyAuth
21 from litellm.proxy.utils import ProxyLogging
22 from litellm.router import Router
24DEFAULT_AGENT_SEARCH_TOP_K: Final = 5
27@dataclass(frozen=True, slots=True)
28class AgentSearchHit:
29 agent: AgentResponse
30 score: float
33@dataclass(frozen=True, slots=True)
34class AgentSearchHits:
35 hits: tuple[AgentSearchHit, ...]
38@dataclass(frozen=True, slots=True)
39class AgentSearchNotConfigured:
40 reason: str
43@dataclass(frozen=True, slots=True)
44class AgentSearchEmbeddingFailed:
45 reason: str
48AgentSearchOutcome: TypeAlias = AgentSearchHits | AgentSearchNotConfigured | AgentSearchEmbeddingFailed
51class _SearchableSkill(BaseModel):
52 model_config = ConfigDict(frozen=True, extra="ignore")
54 name: str = ""
55 description: str = ""
56 tags: tuple[str, ...] = ()
59class _SearchableCard(BaseModel):
60 model_config = ConfigDict(frozen=True, extra="ignore")
62 description: str = ""
63 skills: tuple[_SearchableSkill, ...] = ()
66class AgentSearchResult(BaseModel):
67 model_config = ConfigDict(frozen=True)
69 agent_id: str
70 agent_name: str
71 description: str
72 skills: tuple[_SearchableSkill, ...]
73 score: float
76def _searchable_card(agent: AgentResponse) -> _SearchableCard:
77 try:
78 return _SearchableCard.model_validate(agent.agent_card_params)
79 except ValidationError:
80 return _SearchableCard()
83def _skill_text(skill: _SearchableSkill) -> str:
84 return " ".join(part for part in (skill.name, skill.description, " ".join(skill.tags)) if part)
87def agent_search_text(agent: AgentResponse) -> str:
88 card: Final = _searchable_card(agent)
89 skill_lines: Final = tuple(_skill_text(skill) for skill in card.skills)
90 return "\n".join(part for part in (agent.agent_name, card.description, *skill_lines) if part)
93def agent_search_result(hit: AgentSearchHit) -> AgentSearchResult:
94 card: Final = _searchable_card(hit.agent)
95 return AgentSearchResult(
96 agent_id=hit.agent.agent_id,
97 agent_name=hit.agent.agent_name,
98 description=card.description,
99 skills=card.skills,
100 score=hit.score,
101 )
104class AgentSearchIndex:
105 """Caches one vector per distinct agent text per embedding model, so repeat searches only embed the query."""
107 def __init__(self) -> None:
108 self._index: Final = SemanticTextIndex()
110 async def search(
111 self, query: str, agents: Sequence[AgentResponse], top_k: int, embed: Embedder, embedding_model: str
112 ) -> AgentSearchHits | AgentSearchEmbeddingFailed:
113 texts: Final = tuple(agent_search_text(agent) for agent in agents)
114 scores: Final = await self._index.scores(query, texts, embed, embedding_model)
115 if isinstance(scores, EmbeddingFailed):
116 return AgentSearchEmbeddingFailed(reason=scores.reason)
117 ranked: Final = sorted(
118 (AgentSearchHit(agent=agent, score=score) for agent, score in zip(agents, scores, strict=True)),
119 key=lambda hit: hit.score,
120 reverse=True,
121 )
122 return AgentSearchHits(hits=tuple(ranked[:top_k]))
125global_agent_search_index: Final = AgentSearchIndex()
128async def search_agents(
129 query: str,
130 agents: Sequence[AgentResponse],
131 top_k: int,
132 router: Router | None,
133 embedding_model: str | None,
134 index: AgentSearchIndex,
135 user_api_key_dict: UserAPIKeyAuth,
136 proxy_logging_obj: ProxyLogging,
137) -> AgentSearchOutcome:
138 if embedding_model is None: 138 ↛ 142line 138 didn't jump to line 142 because the condition on line 138 was always true
139 return AgentSearchNotConfigured(
140 reason="agent search needs litellm_settings.agent_search_embedding_model set to an embedding model from model_list"
141 )
142 if router is None:
143 return AgentSearchNotConfigured(reason="agent search needs a model_list so the embedding model can be called")
144 embed: Final = router_embedder(router, embedding_model, user_api_key_dict, proxy_logging_obj)
145 return await index.search(query, agents, top_k, embed, embedding_model)