Mode is declared on a field, by whoever writes the
pack. A projection is the other lever, and it needs nothing from the pack at all.
Both belong to whoever deploys the tool, and they are not interchangeable: see
which one to reach for below.
You did not write documents_batch_update and cannot edit it. You are the one
who knows that this agent may insert text and must never delete a range.
projection.py
extra="forbid". Only the view narrows.
Narrowing the view does two things at once: it reduces what the tool can do, and how
much of the context window it occupies. This page is about the first. The second is a
context-window lever in its own right, and on a
recursive schema it decides whether the tool is callable at all.
Why the tool is shaped this way
The Google Docs API has three endpoints, and one of them carries every write.documents.batchUpdate takes a list of requests, each setting exactly one of 33
kinds of edit. Charter models all 33, which is what makes the tool complete and
also what makes it cost 7,336 tokens of schema before the model reads the task.
Narrowing to three members takes that to 1,553, or 79% smaller.
The permission matters more than the tokens. Google’s documents scope grants
every one of those 33 edits as a single indivisible grant. There is no scope for
“may edit text, may not delete content”. The union member is the capability, so
pruning it is the only place that permission can be expressed.
keep and drop
keep selects within the sibling group it names. Keeping three members of the
union drops the other 30 and leaves document_id and body.write_control
alone, so the tool is still callable.
drop removes one path outright:
drop.py
DeclarationError when you build the
projection, not when the API answers 400.
pin
pin removes a field and sends a fixed value on every request. The field is
neither visible to the model nor reachable by it:
pin.py
Path(),
Query() or Body()
marker says, picks up the Format transform, the
key casing cascade and the percent-encoding, and a single body field still
unwraps to become the body. A value that does not satisfy its field raises
DeclarationError when you build the
projection.
Pin top-level fields. A nested path raises.
The field has to reach that executed model, so a field
Mode withholds cannot be pinned. response_only and
disabled are refused at every mode, so pinning one raises as soon as you build
the projection. A field carrying a custom mode is refused only at the labels that
do not name it, so it raises when a mode that hides it resolves the view, naming
the mode. Both used to be accepted and then fail every call with Extra inputs are not permitted, on a field the caller never set.
Naming a path
Tool.paths() lists what a projection can select,
one level at a time:
paths.py
selectors.py
index names 19 different fields, and naming it raises an error
listing the candidates rather than guessing.
What a path costs
A list of names does not say which branch is expensive, and on a large tool that is the only thing you need to know.search_issues_full in the Linear pack has
one top-level field. Every projection worth writing lives underneath it.
Pass by_cost=True to price the level instead of naming it, most expensive
first:
by_cost.py
narrow.py
search_issues_full: every filter-bearing Linear tool ships
curated, and keeps an undiminished twin under *_full for a caller who needs the
complete filter. linear.search_issues itself is 7,748 tokens.
The figure is measured, not estimated. Each path is pruned for real and the
schema regenerated, so what by_cost reports is what drop produces. Sizes
are schema_tokens(), serialised
JSON characters over four.
Pricing costs one schema generation per path, so only the level being returned
is priced. Pass a prefix to drill, or depth=2 to price a level and its
children in one call.
Four properties to read the numbers by. Each is something drop really does,
so the number is the warning:
- Costs do not sum to the total. Where two fields share a
$def, dropping either one alone leaves it in place, so both price cheap and dropping both is worth more than the sum of the two. - A negative cost means the drop makes the tool larger. Pruning inside a model
several siblings share splits one
$definto per-path copies. Droppingbody.requests.insert_text.location.indextakesdocuments_batch_updatefrom 7,336 tokens to 7,584, for a tool that can do less. - A cost of zero means
Modealready removed the path, so a projection naming it does nothing.events_insertpricesevent.i_cal_uidat 0 undermode="write";events_importprices the same path at 103. - A required path is priced even though
droprefuses it.documents_batch_updatecannot losebody.requests, and the price is what says whetherpinis worth reaching for.
What is enforced
A projection can only remove, and removal is checked by the schema rather than requested in a prompt:enforced.py
Request oneof rule still rejects a request that sets two edits at once.
Composition
Deriving from a projection narrows what is left:compose.py
Changing what comes back
Every argument above narrows the schema the model fills in.response_handler is
the exception: it replaces what the model is handed back, and a projection that
leaves it unset inherits the one the pack wrote for that endpoint.
response_shape.py
pass_through turns the trimming off
and hands back the response as the API sent it. Your own async callable reshapes
it instead; a non-async one raises
DeclarationError here rather than mid-call.
A projection or a mode
Tool.with_mode and
ToolSession(tools, mode=...)
are the other way to narrow a tool you did not write. They read labels the pack
author already declared, so they work only where those labels exist, and they are
not a projection in the sense this page means.
“Only removes” is what makes a projection safe to hand to a deployment without
reading the pack: whatever it produces, the tool underneath can do less. A mode
rebuilds the view from the declarations, so
with_mode("max") on a tool built
mode="free" is wider than what it started from. The floor still holds, because
response_only and disabled are refused without consulting the mode at all, so
no label reaches a field the author withheld unconditionally.
They compose, in either order, and the composition keeps both:
both.py
keep is still in it. Which is the order a
deployment tends to write anyway: the projection is a startup decision about what
this agent may do, and the mode is a per-conversation one about which surface this
conversation gets.
Reading the boundary
Both halves print inegress_map(), which reads the
same declarations the runtime executes:
egress.txt
Where projections live
In your code, not in the pack. Charter ships the mechanism and no named projections, because a projection is a policy decision and the policy is yours.gdocs.documents_edit_text in a pack namespace would be Charter deciding what
text editing means for every deployment that installs it.
Keeping them in your repo also puts them in your pull requests, next to the rest
of what your agent is allowed to do.
Related
- Context window — finding which tool is worth narrowing, and what it was worth
Tool.derivedandTool.paths- Egress control for what the map reports
- The mode system for the label-side half, and both ends of it
DeclarationErrorfor what a bad selector raises