comment_create costs 911
tokens of schema and search_issues costs 47,026. They look alike from the
outside: both take one object, both read issues.
Measure before narrowing
schema_tokens() prices a tool. Run
it across a pack and the distribution is usually lopsided:
survey.py
project_update
at 1,793, and the remaining 110 are smaller than that.
A cliff like this is the signal. Sizes that cluster inside 3% of each other are
not seventeen expensive tools. They are one expensive thing, reached by
seventeen routes.
Why the fat tools are fat
Linear’s API is GraphQL, and its filter types are mutually referential. AnIssueFilter has an and and an or that hold more IssueFilter, and a team
that holds a TeamFilter that holds an IssueFilter. Fifty-six of the pack’s 77
generated definitions sit in a cycle.
One filter argument reaches all of them, which is what makes the seventeen
sizes nearly equal: each of those tools is paying for the same graph. The 111
tools that never name a filter are all 1,793 tokens or less.
This is worth separating from merely large. gsheets.spreadsheets_batch_update
generates 201 definitions and gdocs.documents_batch_update generates 51, and
not one of either is in a cycle. Wide is a cost you can decide to pay. Recursive
is a different problem, because above a certain size it stops being a cost at
all and becomes a rejected request.
Deferring is not the same as shrinking
ToolSession defers schemas: the model
gets a search tool, and a tool’s parameters arrive only once it has asked for
that tool by name.
That is the right default and it is not sufficient here. Progressive disclosure
changes how many schemas are in the prompt. It does not change what one costs,
and once the model searches for search_issues it has bought all 47,026 tokens,
on every turn from then until the end of the run.
Deferral and narrowing solve different halves. Use both.
Find the branch, not the tool
The tool is not expensive. One field in it is, and a list of field names does not say which.Tool.paths(by_cost=True) prices the
level instead of naming it:
by_cost.py
filter is 99% of the tool. term, first and team_id are the rest.
The figure is measured rather than estimated. Each path is pruned for real and
the schema regenerated, so what by_cost reports is what drop will produce.
Narrow it
derived() removes the branch. Two shapes, and
which one you want depends on what the tool’s cost is made of.
drop when one branch is the whole cost:
drop.py
keep when the cost is a union and you want a few of its members:
keep.py
search_issues keeps its other seven arguments, including
the search term, the team and the ordering, which is what an agent triaging issues
actually sends. documents_edit_text still inserts, replaces and deletes text.
Recursive schemas can fail outright
Above some size the problem stops being cost and becomes whether the tool is callable at all. Providers cap how deep a tool schema may resolve, and a cycle resolves deeper than anything a human writes. Sendinglinear.search_issues unnarrowed to Fireworks returns:
drop={"filter"} removes the cycle and the same request is
accepted.
Two things follow, and neither is specific to this library:
- The limit is the provider’s, and they differ. There is no schema a pack can emit that satisfies every one of them. The cap is a deployment fact, which is the layer a projection belongs to.
- It fails as a request error, not a bad answer. Nothing degrades gracefully. If a recursive tool is in the payload, the turn returns 400 and the agent makes no progress, so this shows up as an agent that does nothing rather than as a cost line.
Every tool carries its own definitions
One more reason the fat tools compound.$defs is scoped to a single schema
document, and in every tool-calling format a document is one tool. There is no
cross-tool definition space in an OpenAI tools array, in an MCP tools/list
response, or in an Anthropic input_schema, so a type two tools share is sent
twice.
Three Linear list tools, measured through the adapters this library ships:
linear.teams_list 77 of 77,
gsheets.spreadsheets_batch_update 201 of 201, gdocs.documents_batch_update 51
of 51. Nothing is emitted that the tool does not use, so the copies cannot be
trimmed, only made small.
Making them small is the whole of it, and it compounds the same way the cost
does. The same three tools with drop={"filter"} applied:
Clients that flatten make it worse
Support for$ref in tool schemas is uneven, so some clients resolve references
before sending. Resolving is multiplicative, and on a cyclic schema there is no
size it converges to.
langchain_core 1.6.1 does this in convert_to_openai_function, which calls
dereference_refs and then discards $defs. Its cycle guard is per branch, so a
definition reached along ten paths is expanded ten times. On small Linear tools
the result is about twice the input. On linear.teams_list, 187,579 bytes and 77
definitions, it had not returned after 45 seconds and had allocated 1.92 GB.
That is the shape of the problem rather than one library’s bug. Expanding this
pack’s 188 KB Linear schema with a depth cap of 12 produces 115 MB, and no cap
that is generous enough to keep an ordinary REST schema whole is small enough to
bound a cyclic one.
A projection fixes this at the source, because it removes the cycle rather than
bounding the expansion of one.
The same edit is a permission
A projection can only remove. That is what lets the saving and the restriction be the same line of code:drop={"filter"} is a smaller prompt and it is also an
agent that cannot construct an arbitrary query against your issue tracker.
Both halves print in egress_map(), so what was
narrowed is reviewable rather than implied:
egress.txt
The other direction: responses coming back
Everything above is about the schema, which is the half you can see in your own code. The other half arrives from someone else. Two fields separate them, and the gap between the two is the whole subject:
A response handler runs after a successful call
and returns whatever the model should see. Gmail’s
threads_get is the ordinary
case: the API answers with a MIME tree — nested parts, multipart/alternative
duplicates of the same text, base64 attachment bytes inline — and the pack’s
handler returns an id, the headers worth keeping, the decoded body text, and
attachment metadata rather than attachment bodies. The attachment ids survive
because messages.attachments.get is the call the model makes next; dropping
them would save bytes and cost a turn.
Across the shipped packs, 286 tool definitions in 14 packs name a handler. None
of that is configuration you write.
What it is worth
From the two campaigns on the measured results page, per run:ToolCall is deliberate about:
- It is not bandwidth.
payload_bytesis read after decompression. The currency here is the model’s context window, not egress. - It is not tokens. Tokens need a tokeniser this library does not ship, and a bytes-per-token constant is a guess with a decimal point in it.
- It is not all trimming.
payload_bytesis the response as the server formatted it andcontext_bytesis compact JSON, so against an API that pretty-prints, some of the difference is that API’s whitespace.
Find your own
The same survey shape as the schema half, one axis over. Attach a sink, run the thing you actually run, and sort by what the handler failed to remove:survey_responses.py
saved_ratio near zero is one whose handler is missing
or is not removing what the endpoint actually sends. saved_bytes going
negative is not hidden either — a handler that adds more than it removes is
worth seeing.
Two things to keep
Trimming a list endpoint has exactly two ways to go wrong, and both cost a turn rather than raising:- Keep what a
Paginationdeclaration reads — the objectidbehind a derived cursor, andhas_moreeven when it isFalse. - Keep what the model needs for the next call, which is usually an id it would otherwise have to guess.
Related
- Projections for
keep,drop,pinand what a selector may name Tool.pathsandPathCostToolSessionfor deferring the schemas you keep- Egress control for reading the boundary back
ResponseHandlerfor the return direction- Measured results for where the 4x comes from