Skip to content

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.

get

get(key: str) -> list[float] | None

Return the vector stored under key, or None.

set

set(key: str, vector: list[float]) -> None

Store vector under key.

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:Embedder. Build one with :func:load_embedder when the name comes from config.

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.

DEFAULT_MIN_SCORE
negative_weight float

How much similarity to when_not_to_use subtracts. Pass 0.0 to ignore disclaimers.

0.5

index async

index() -> None

Embed every registered skill, reusing anything already cached.

select async

select(query: str, *, limit: int = DEFAULT_LIMIT) -> Selection

Return the best limit skills for query, by cosine similarity.

InMemoryEmbeddingCache

The default cache: a dict that dies with the process.

__len__

__len__() -> int

Number of cached vectors, which is what tests assert on.

get

get(key: str) -> list[float] | None

Return the vector stored under key, or None.

set

set(key: str, vector: list[float]) -> None

Store vector under key.

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 for why the default is zero.

DEFAULT_MIN_SCORE
negative_weight float

How much a when_not_to_use match subtracts. Pass 0.0 to ignore disclaimers entirely.

NEGATIVE_WEIGHT

index async

index() -> None

Build the BM25 index, discarding any previous one.

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:~agentskills_retrieval.Selection, whose

Selection

skill_ids feed get_skills_catalog(include=...).

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.

is_empty property

is_empty: bool

Whether nothing cleared the floor.

skill_ids property

skill_ids: list[str]

The selected IDs, ready to pass to include=.

describe

describe() -> str

Return a one-line summary with scores, for logs and reports.

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, when_to_use and tags.

negative_text str

when_not_to_use, scored separately.

content_hash str

Digest of the text above, so an embedding can be cached against it and invalidated by nothing else.

negative_terms property

negative_terms: list[str]

The tokens a query is penalised for matching.

positive_terms property

positive_terms: list[str]

The tokens a query is matched against.

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:SkillDocument per registered skill, in ID order.

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:~agentskills_retrieval.SkillSelector.

required
query str

Text to rank against.

required
limit int

Maximum number of skills to advertise.

DEFAULT_LIMIT
format Literal['xml', 'markdown']

"xml" or "markdown", as for :meth:~agentskills_core.SkillRegistry.get_skills_catalog.

'xml'
**catalog_kwargs Any

Passed straight through to :meth:~agentskills_core.SkillRegistry.get_skills_catalog. include and total are supplied by this function.

{}

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:Embedder.

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 embedder_id and embed.

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.