DEV Community

Cover image for Your agent doesn't need every tool in its context
DynoTable
DynoTable

Posted on • Edited on • Originally published at dynotable.com

Your agent doesn't need every tool in its context

DynoTable's AI agent can reach 38 tools. It rarely
sees all of them at once. Where we drew the line between what the model gets
immediately and what it has to go looking for turned out to be the most
consequential decision in the whole toolkit, and the line ended up nowhere
near where we first put it.

We built a discovery mechanism so the model would start with a small set and
search for the rest. Then we watched cheap models use it, and moved 27 of
the 38 tools back into the always-visible core
. The mechanism survived. Our
theory about who needed it did not.

We learned this building a tool surface for models that can't be
trusted to go looking.

A tool the model never calls still costs you

Every tool you expose is its name, its description, and its full input schema,
serialized into the request before the user has typed anything. Thirty-eight of
those is not free.

Tokens are the smaller half of the bill. The real cost is selection accuracy:
the more near-identical options a model scans, the more often it picks the
wrong one. Our catalog is full of near-identical options on purpose. Five
of our tools exist twice, openTable and proposeOpenTable, openWorkbench
and proposeOpenWorkbench, and so on. Each pair does the same thing; one does
it immediately, the other emits a chip the user clicks first. That distinction
is load-bearing for agent safety and nearly invisible in a flat
list of names.

The Model Context Protocol's own client guidance leads with this exact
point: loading every tool definition up front wastes tokens, adds latency, and
degrades the model's performance. Agreeing with that is easy. Deciding which
tools lose their seat is where it gets interesting.

We built a search tool. The floor model wouldn't call it.

The mechanism is two-tier. A set of tools is active from the first step. The
rest are invisible until the model calls searchTools(query), which scores the
catalog on names, descriptions and keywords, returns the matches, and adds them
to the set of tools the model is allowed to call on subsequent steps.

Diagram: view on dynotable.com

Then we ran it against our floor model. We don't tune this agent against a
frontier model, it runs on
your own Bedrock credentials, so people pick
cheap models, and we optimize for the cheapest one. Asked about an attached
file, that model went hunting through the open-tabs listing instead. It
rarely called the search tool at all.
Anything not directly visible did not exist to it.

That result kills the obvious design. If discovery is the only route to a tool,
every request that needs that tool depends on the model choosing to go looking, and the models most likely to need help are the least likely to ask for it.

So the split stopped being "small core, big tail" and became a question about
the request, not the tool: does the user's phrasing name the tool? The 11
tools we kept discoverable are the ones where the answer is yes. "Export this
to CSV" makes a model search for export. "Show me last month's orders" does
not make it search for a filter-setting tool, so that one stays inline. Index
statistics, saved specs, relationship introspection, and the staged-change
surfaces are all things a user asks for by name when they want them, and never
implicitly.

Twenty-seven inline is not a number we would have defended in advance. It's the
number that survived contact with the model we actually ship against.

The race that would have made discovery silently useless

Discovery has a timing constraint that is easy to get wrong and hard to notice.

When the search tool returns matches, those names have to join the allowed set
before the model's next step is prepared. The obvious place to do that is the
hook that fires when a step completes. That hook is documented to fire, in some
SDK versions, after the next step's preparation, which means the mutation
lands one step too late.

The failure mode is nasty. The model searches. It gets a correct result naming
the tool it needs. It calls that tool on the very next step and is told the tool
doesn't exist. Intermittent, dependent on which SDK version you resolved, and it
reads like a stupid model rather than a broken runner.

The fix is to mutate the allowed set inside the search tool's own execution,
which is guaranteed to complete before the loop advances. That is a one-line
difference in where a statement lives, and it is the difference between a
working discovery mechanism and one that fails a fraction of the time for
reasons no one will attribute correctly.

Three searches, then stop

Search is capped at 3 calls per agent turn. The fourth returns this instead
of running:

{"error": "search-budget-exhausted", "budgetCap": 3}
Enter fullscreen mode Exit fullscreen mode

The cap exists because of a specific loop. The model searches, doesn't find what
it imagined, searches again with a synonym, doesn't find that either, and burns
its entire step budget inside the search tool without ever touching the
database. Capping it forces a decision, commit to one of the tools already
found, or ask the user, at the point where more searching has stopped paying.

The error message when a model calls a tool it hasn't discovered follows the
same principle we use for
every validator in the agent:

Tool 'startExport' not in active set. Call searchTools(query='startExport')
to discover it, or use one of: <inline tool names>
Enter fullscreen mode Exit fullscreen mode

A rejection that names the recovery action costs one extra step. A rejection
that just says no costs the turn.

One row per tool, everything else derived

Each tool is declared once, in a single flat list, and the row carries the
tool's entire identity: its name and description, the keywords search matches
on, whether it starts inline or discoverable, which tier it runs in, and how it
is exposed over MCP.

Those tiers matter as much as the visibility split. Twenty-one tools are
silent, reads that run without interrupting anyone. Sixteen are gated
behind the authorization ladder. Exactly one belongs to neither, because the
search tool isn't a capability the agent uses on your data; it's part of the
loop itself. MCP exposure is a third axis on the same row: read-only, staging,
full, or excluded outright, which three tools are.

The rule that keeps this honest is that every other list in the system is
derived from those rows
, the silent-tier set, the MCP scope tiers, the
write-scoped set, and none of them is maintained by hand. A hand-kept silent
list next to a hand-kept MCP list is exactly how a tool ends up correctly gated
in chat and quietly ungated to an external client.

The constraint we didn't see coming is that the declaration list has to contain
zero runtime imports. It's shared by the desktop UI and the backend, and a
single import reaches, transitively, a Node-only crypto dependency by way of one
tool's implementation. Pull that into the browser bundle and the app fails at
module load. Neither the type checker nor the unit tests catch it, both resolve
the import happily. What catches it is a test that reads the file as text and
fails on any import statement at all, which feels crude right up to the first
time it saves you.

What transfers if you're building one

  • Count your tools before you defend your architecture. The right split is a measurement, not a principle.
  • Test discovery against your weakest model. A frontier model will search when it should; that tells you nothing about the model your users pick.
  • Decide visibility by whether the user's own phrasing names the tool. Tools invoked implicitly belong inline; tools people ask for by name can be found.
  • Check when your framework's step hooks actually fire before you put anything order-sensitive in one.
  • Cap the meta-tools. Anything that can be called repeatedly without touching real state will be, and a step budget spent searching is a turn wasted.
  • Make undiscovered-tool errors name the recovery call, the same as any other validator error.
  • Declare each tool once and derive every other list from it. Two hand-kept lists of the same tools disagree eventually, and the disagreement shows up at a security boundary.
  • If a module carries a load-bearing constraint your compiler can't express, write the crude test that enforces it as text.

Where this runs

All of this ships inside DynoTable's
tool catalog, schema-aware querying on your own
AWS Bedrock credentials, with writes that only ever land in a
reviewable staging area. The same declarations drive the
MCP server that external agents connect to, where
the exposure tier on each row becomes the scope an external client is granted;
how we made that safe (OAuth, consent,
credential isolation) is a separate story.

And the layer underneath all of it, the validators that make each of these tools
survivable by a cheap model, is
its own post.

Top comments (0)