comment_create costs 791
tokens of schema and search_issues_full costs 45,168. They look alike from the
outside: both take one object, both read issues.
This page uses the Linear pack as it was before it was narrowed, because that is
the state you will find your own pack in. Every filter-bearing tool in it ships
curated today and keeps its original schema as a *_full twin, so the figures
below are all reachable — search_issues is 7,748 tokens and
search_issues_full is the 45,168 it was.
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 were 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,555 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 a 47,026-token tool it has bought all of it, 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 46,630 of the tool’s 47,026. 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_full 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 — teams_list_full, issues_list_full,
projects_list_full — measured through the adapters this library ships:
teams_list_full 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 the uncurated teams_list_full — 176 KB
and 77 definitions today — 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 the
same schema with a depth cap of 12 produced 115 MB, and no cap that is generous
enough to keep an ordinary REST schema whole is small enough to bound a cyclic
one.
The 1.92 GB and the 115 MB were measured against
langchain_core 1.6.1 on the
pack as it stood, when that schema was 183 KB. The schema’s size moves with the
pack and those two figures are not recomputed with it: what does not move is that
neither has a bound, because the expansion is multiplicative on a cycle.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 — and none of it is fixed. A projection takes
a response_handler of its own:
own_shape.py
pass_through is there for the day a field the
handler drops is the field your agent needs.
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