Skill Selection (agentskills-retrieval)¶
Narrows the catalog to the skills a query is actually about.
pip install agentskills-retrieval
agentskills_retrieval ¶
Query-time skill selection for the Agent Skills SDK.
The catalog is injected on every turn, so per-turn prompt cost is linear in the number of registered skills — and selection accuracy falls as the candidate list grows, so a large registry is both more expensive and worse at choosing. This package narrows the catalog to what the current query is actually about.
Two selectors ship. :class:LexicalSelector is BM25 and needs nothing
installed; :class:EmbeddingSelector takes any embedder behind a
one-method protocol. Both return scores, and both compose with the
include= filter the registry already has::
from agentskills_retrieval import LexicalSelector, build_selected_catalog
selector = LexicalSelector(registry)
catalog = await build_selected_catalog(registry, selector, "checkout is down")
Nothing here is on by default. A registry that is not asked to select behaves exactly as it did before this package existed.
Embedder ¶
Bases: Protocol
The whole contract: a name, and a way to turn text into vectors.
Implement it over any client::
class OpenAIEmbedder:
embedder_id = "text-embedding-3-small"
async def embed(self, texts):
reply = await client.embeddings.create(
model=self.embedder_id, input=list(texts)
)
return [item.embedding for item in reply.data]
embedder_id is part of every cache key, because two models'
vectors are not comparable and a cache that mixes them silently
returns nonsense.
embed
async
¶
embed(texts: Sequence[str]) -> list[list[float]]
Return one vector per text, in the same order.
EmbeddingCache ¶
Bases: Protocol
Somewhere to keep vectors between processes.
Two methods, both synchronous, because the interesting backends (a file, a table, Redis) are all fast enough that making callers write an async adapter buys nothing.
EmbeddingSelector ¶
Select skills by cosine similarity between query and skill vectors.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
registry
|
SkillRegistry
|
The registry whose skills are ranked. |
required |
embedder
|
Embedder
|
Anything satisfying :class: |
required |
cache
|
EmbeddingCache | None
|
Where skill vectors are kept. Defaults to an in-process dict; pass your own to survive a restart. |
None
|
min_score
|
float
|
Cosine below this is rejected. See
:data: |
DEFAULT_MIN_SCORE
|
negative_weight
|
float
|
How much similarity to |
0.5
|
InMemoryEmbeddingCache ¶
LexicalSelector ¶
Select skills by BM25 over their catalog metadata.
The corpus is built on first use and reused until the registry's set of skill IDs changes, so a long-lived agent pays for indexing once.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
registry
|
SkillRegistry
|
The registry whose skills are ranked. |
required |
min_score
|
float
|
Scores at or below this are rejected outright. See
:data: |
DEFAULT_MIN_SCORE
|
negative_weight
|
float
|
How much a |
NEGATIVE_WEIGHT
|
select
async
¶
select(query: str, *, limit: int = DEFAULT_LIMIT) -> Selection
Return the best limit skills for query.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
query
|
str
|
Free text to rank against. The caller decides what this is; see the README on why the last user message alone is a poor default. |
required |
limit
|
int
|
Maximum number of skills to return. |
DEFAULT_LIMIT
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
Selection
|
class: |
Selection
|
|
ScoredSkill
dataclass
¶
One skill and what it scored, in the selector's own units.
Selection
dataclass
¶
The outcome of one selection, including what it rejected.
Attributes:
| Name | Type | Description |
|---|---|---|
query |
str
|
The text that was ranked against. |
selected |
list[ScoredSkill]
|
Skills that cleared the floor, best first. |
rejected |
list[ScoredSkill]
|
Skills that did not, also best first. Kept because "the right skill scored just under the floor" and "the right skill was not registered" are different bugs and look identical without this. |
considered |
int
|
How many skills were scored, which is the denominator to report in the catalog. |
SkillDocument
dataclass
¶
One skill, as a ranker sees it.
Attributes:
| Name | Type | Description |
|---|---|---|
skill_id |
str
|
The registered ID, which is what a selection returns. |
positive_text |
str
|
Name, description, |
negative_text |
str
|
|
content_hash |
str
|
Digest of the text above, so an embedding can be cached against it and invalidated by nothing else. |
SkillSelector ¶
Bases: Protocol
Rank registered skills against a query.
One method, so a caller can supply its own ranker — a hosted reranking API, a hand-written routing table — without inheriting anything from this package.
select
async
¶
select(query: str, *, limit: int = DEFAULT_LIMIT) -> Selection
Return the best limit skills for query, with scores.
build_corpus
async
¶
build_corpus(registry: SkillRegistry, *, concurrency: int = 8) -> list[SkillDocument]
Fetch metadata for every registered skill and index it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
registry
|
SkillRegistry
|
The registry to read. |
required |
concurrency
|
int
|
Ceiling on simultaneous metadata fetches, which matters when the provider is network-backed. |
8
|
Returns:
| Name | Type | Description |
|---|---|---|
One |
list[SkillDocument]
|
class: |
build_selected_catalog
async
¶
build_selected_catalog(registry: SkillRegistry, selector: SkillSelector, query: str, *, limit: int = DEFAULT_LIMIT, format: Literal['xml', 'markdown'] = 'xml', **catalog_kwargs: Any) -> str
Rank the registry against query and build a catalog of the winners.
The result reports its own narrowing — shown/total on the
XML root, a closing note in Markdown — because from inside an agent
a skill that was ranked out is indistinguishable from one that was
never registered, and that is a miserable thing to debug.
When nothing clears the selector's floor the full catalog is returned. "Selection has no opinion" is not "the agent should have no skills": a wrong prune silently removes a capability, which is a worse failure than a few wasted tokens. The fallback is logged.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
registry
|
SkillRegistry
|
The registry to build from. |
required |
selector
|
SkillSelector
|
Any :class: |
required |
query
|
str
|
Text to rank against. |
required |
limit
|
int
|
Maximum number of skills to advertise. |
DEFAULT_LIMIT
|
format
|
Literal['xml', 'markdown']
|
|
'xml'
|
**catalog_kwargs
|
Any
|
Passed straight through to
:meth: |
{}
|
Returns:
| Type | Description |
|---|---|
str
|
A catalog string ready for a system prompt. |
cosine ¶
cosine(left: Sequence[float], right: Sequence[float]) -> float
Return the cosine similarity of two vectors, or 0.0 if either is zero.
document_of ¶
document_of(skill_id: str, meta: dict[str, Any]) -> SkillDocument
Build the searchable document for one skill's metadata.
load_embedder ¶
load_embedder(spec: str) -> Embedder
Resolve module:factory to an embedder.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
spec
|
str
|
A dotted module path, a colon, and the name of a
zero-argument callable returning an :class: |
required |
Returns:
| Type | Description |
|---|---|
Embedder
|
The embedder the factory built. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the spec is malformed, cannot be imported, or
does not produce something with |
tokenize ¶
tokenize(text: str) -> list[str]
Split text into lowercase alphanumeric terms, minus stopwords.
No stemming: it would need a dependency, and this package's whole claim is that it is useful with none. The cost is real — "deploys" will not match "deploy" — and it is the main thing an embedding selector buys back.