Routing quality
An A2A gateway moves traffic to an agent that already has a card. Whether other
agents choose to call that agent is decided by the card itself: a caller reads
skills[].description, examples and tags and picks. Those strings are the
routing interface.
This page covers measuring how well an agent can be routed to, and improving it with GEPA.
Why it is measurable
Routing outcomes are observable, so description quality produces a number. The following four skills differ only in how each describes itself. The queries are identical.
Vague:
billing "Handle a customer query"
refunds "Handle a customer request"
tech "Help the customer with the product"
accounts "Assist a customer"
Specific:
billing "Resolve invoice disputes, duplicate charges, payment failures and
subscription price questions"
refunds "Decide and process refund and money-back requests, including returns
and partial refunds"
tech "Debug product crashes, upload errors, broken integrations that stop
syncing, and login failures"
accounts "Change workspace seats, account settings, permissions and the email
on a login"
Scored against eight queries written in caller vocabulary:
| Catalogue | Invocation | Discovery |
|---|---|---|
| Vague | 12.5% | 75% |
| Specific | 100% | 100% |
The three metrics
score_routing reports three rates, because being invisible and being
confusable are different problems with different fixes.
Discovery โ the skill appeared in the top three candidates. A skill that is never surfaced cannot be called, whatever else is true of it.
Invocation โ the skill was the top choice. This corresponds to a caller reaching the right agent.
Confusion โ of the cases a skill lost, the proportion lost to a sibling rather than to nothing. A high rate means two descriptions do not distinguish themselves from each other; a low rate with poor invocation means the description does not cover the vocabulary callers use.
from superoptix.protocols.a2a.routing import (
LexicalRouter, catalogue_from_cards, score_routing,
)
skills = catalogue_from_cards([card_a, card_b, card_c])
report = score_routing(LexicalRouter(), skills, cases)
print(report.summary())
print(report.per_skill)
print(report.misroutes) # query, expected, what won instead
Routers
A router stands in for the calling agent. Two are provided.
LexicalRouter scores TF-IDF cosine similarity over each skill's routing text.
It is deterministic and needs no model provider, which makes baselines
reproducible and lets the metric run in CI. It measures whether a description
carries terms that separate the skill from its neighbours.
LLMRouter asks a model to choose, which is closer to how a real caller
behaves. It takes any callable accepting a prompt and returning text.
The interface is pluggable for a specific reason. Optimising descriptions
against the same router that scores them measures that router's reading habits
rather than interoperability. Pass a different router as validation_router to
re-score the result:
result = optimize_routing(
skills, cases,
reflection_lm=lm,
router=LexicalRouter(),
validation_router=LLMRouter(lm=other_model),
)
print(result.summary())
An improvement that survives the swap is real. One that does not has taught you something more useful than a number.
Optimising with GEPA
optimize_routing runs GEPA over the card's routing surface. Only
skills[].description and skills[].examples are candidates, matching what the
adapt intermediate representation declares optimisable. Identity and protocol
fields are outside its reach, so optimisation cannot change what an agent claims
to be.
from superoptix.protocols.a2a.routing.optimize import optimize_routing
result = optimize_routing(
skills,
cases,
reflection_lm=lm,
max_metric_calls=200,
)
print(result.baseline_score, result.optimized_score)
print(result.optimized_descriptions)
On the vague catalogue above, this raises invocation from 12.5% to 75%.
Failed cases are returned to GEPA as text naming what won instead:
Misrouted: 'I want my money back for last month' went to billing:billing
instead of refunds:refunds. Those two descriptions do not distinguish
themselves from each other for this kind of request.
Reflecting on why a routing decision failed is what makes the technique work
here, and it is the same shape as GEPA's gskill, which optimises coding-agent
skill files against bug-fix pass rates.
Building an evaluation set
Cases pair a query with the skill that should win it:
from superoptix.protocols.a2a.routing.queries import RoutingCase
cases = [
RoutingCase("I was charged twice for the same invoice", "billing:billing", "src"),
RoutingCase("the app crashes when I upload a file", "tech:tech", "src"),
]
generate_cases will derive cases from a catalogue, and hard=True withholds
each skill's own name so a query cannot win by echoing the title.
The generated set is a smoke test, not a benchmark. Every field on a card is
either the description under optimisation or was derived from it, so a query
generated from the card tends to reward the text it is meant to evaluate.
gskill avoids this because SWE-smith mines tasks from a repository, which is
ground truth independent of the artifact being optimised. No equivalent free
source exists here.
Two sources produce a meaningful evaluation set:
Model-generated queries. Ask a model for realistic caller phrasings per skill, using the agent's domain rather than its description. Faster to obtain, and carries the bias of whichever model wrote them.
Recorded traffic. Instrument real A2A calls and label the outcome. Slower to accumulate, and the only source that cannot be accused of circularity. It also doubles as an adoption signal.
Until one of those is in place, treat reported gains as directional.
Reference
| Object | Module |
|---|---|
LexicalRouter, LLMRouter, SkillRef |
superoptix.protocols.a2a.routing.router |
RoutingCase, generate_cases, catalogue_from_cards |
superoptix.protocols.a2a.routing.queries |
score_routing, RoutingReport |
superoptix.protocols.a2a.routing.metrics |
optimize_routing, make_evaluator |
superoptix.protocols.a2a.routing.optimize |