Skip to content

feat(state)!: remove Kind; a name is the contract - #99

Merged
ciaransweet merged 10 commits into
mainfrom
feat/not-authored-parameters
Aug 25, 2026
Merged

feat(state)!: remove Kind; a name is the contract#99
ciaransweet merged 10 commits into
mainfrom
feat/not-authored-parameters

Conversation

@ciaransweet

Copy link
Copy Markdown
Contributor

Kind was a nominal type two toolsets had to spell identically. Between
toolsets whose authors already talk to each other it worked; anywhere else it
went mute, and the way it went mute was the problem. _bindable dropped an
unmatched declaration, the parameter returned to the model's schema, and the
model invented a value. The misconfiguration and the invented geometry were
the same silent event.

What replaces it is the name a value is already stored under. Every
ToolResult data key is captured to <toolset>/<tool>/<field>, the model
reads that key off a [state updated: ...] breadcrumb and passes
@state:<key>, and the client substitutes the payload on the way out.
Producer and consumer still share nothing else — but a name degrades to a
model reading it, where a kind degraded to nothing at all. The tool segment is
in the key because the key is now read: gazet/get_aoi/bbox and
gazet/generate_aoi/bbox are different values, and under one shared BBOX
kind a consumer could not say which it wanted.

NotAuthored

NotAuthored covers what model_generatable=False covered, without the
coupling. It is a claim about one parameter — this caller must supply a value
that already exists — naming no type, so a tool carrying it works against a
client that has never heard of any of this.

What a client does with it scales with what the client implements. One that
ignores _meta leaves the parameter alone. One that reads the description
finds a sentence appended saying the value must already exist. This client
narrows the schema to {"type": "string", "pattern": "^@state:"}, so a
literal does not type-check and the model has no way to express an invented
value.

A call that writes one anyway, or omits a required one, is refused with a
listing of what session state actually holds. Refusals come back as tool
results, never exceptions: raising would leave an assistant message whose
tool_calls no ToolMessage answers, which most providers reject outright,
and one refusal would poison every later turn on the thread.

Provenance

StateEntry.inputs records, per producing call, where each argument came
from: another state key, or model. Recorded and never enforced, one level
deep, and the input side only — a tool owns its outputs, and an equality test
on returns would catch an echo while missing every transformation.

Verified against a real consumer

Ported ecmwf/dss-agentic-ai-services onto this branch and ran it, which is
where the last four commits come from. Two findings worth stating:

A model reading a dataset schema learned that CDS's own request body takes an
area, read the published bounding box back with inspect_state, and filled
both the opaque request dict and the narrowed parameter. Nothing in the
mechanism failed — the schema was narrowed, the handle was dereferenced, the
receipt was minted — but the marker binds a parameter, not a concept, and
a tool with an opaque sibling accepting the same value voids it. That limit is
now stated on NotAuthored itself, where an author will read it before
relying on it.

Worse, and now fixed: the refusal for a handle nested inside an opaque
argument ended "read a value with inspect_state and write the field yourself".
Right for an ordinary opaque field, and an instruction to carry the value
around the constraint on a tool that declares one. The model obeyed it. A
refusal is a prompt, and it has to be checked against every constraint the
same tool declares.

Examples

The client is where this is legible, so it was rebuilt rather than merely
ported: the state panel carries the whole of an entry's inputs, and hovering
any of a key, a call or an activity lights the same three-sided link. Four
rendering faults went with it, every one found by looking at the running UI —
worth noting that none of examples/agui-events/web is covered by a CI job,
which is why.

Not included

No migration guide: we are the only consumers and both ports are done or in
hand. Two known gaps are recorded rather than fixed — the listing note is a
judgement about that surface rather than about the record, and describe's
shape summary says nothing about what a value means, deliberately.

BREAKING CHANGE: Kind, mcp_runtime.kinds, mcp_state.wiring, detect_kind, entries_of_kind, published_kinds, publishers, BY_DECLARATION, BY_HANDLE, supplied, offers_handles and BuiltAgent.withheld are removed, along with the tools.withheld AG-UI activity and kind on every state entry the API serves. offer_handles loses its skip argument. State keys gain the producing tool, so a two-part key is now three parts.

ciaransweet and others added 10 commits August 24, 2026 16:01
Kind couples two toolsets through a string they must both spell the same
way, and carries model_generatable=False as a rider on that coupling. The
refusal never needed the coupling: "a model must not author this" is a fact
about one parameter, true whether or not anything downstream implements
session state.

NotAuthored says that and nothing else. It names no type, so no second
toolset has to agree with it, and a tool carrying it is unchanged against a
client that ignores it. What a client does with it scales with how much it
implements: ignore the _meta and the parameter behaves normally; read only
the description and there is a sentence saying the value must already exist;
implement mcp_state and the parameter's schema is narrowed until a
@State:<key> handle is the only thing that fits.

That last form is stronger than model_generatable=False. Today the parameter
is hidden and the client fills it; here the schema itself refuses a literal,
which closes the gap handles.py concedes as "the price of requiring nothing
of the server". A call that writes a literal anyway, or omits a required
narrowed parameter, is refused before it reaches the server with the
available keys listed, so the model can correct itself.

Also prunes $defs that nothing references any more. _prune left them
"byte-for-byte", so a pruned or narrowed parameter shipped the model a type
definition no parameter mentioned — the whole token cost of a richly typed
parameter with none of its benefit.

Additive: Kind is untouched and both existing paths still work. The design,
including the phases this does not implement, is in
docs/proposals/remove-kind-marker.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BREAKING CHANGE: `Kind`, `mcp_runtime.kinds`, `mcp_state.wiring` and
`BuiltAgent.withheld` are gone, along with the `tools.withheld` AG-UI activity
and `kind` on every state entry the API serves.

Kind was a nominal type two toolsets had to spell identically. Outside
toolsets whose authors already talk to each other it was mute, and the way it
went mute was the problem: `_bindable` dropped an unmatched declaration, the
parameter returned to the model's schema, and the model invented a value. The
misconfiguration and the invented geometry were the same silent event.

What replaces it is the name a value is already stored under. Every
`ToolResult` data key is captured to `<toolset>/<tool>/<field>`, the model
reads that key off a breadcrumb and passes `@state:<key>`, and the client
substitutes the payload on the way out. Producer and consumer still share
nothing else — but a name degrades to a model reading it, where a kind
degraded to nothing at all.

`NotAuthored` covers what `model_generatable=False` covered, without the
coupling: a parameter it tags accepts a handle and nothing else, so a model
cannot write the value even where no producer has run. A call that tries, or
that omits a required one, is refused as a tool result listing what state
holds — which is also what replaces the connect-time wiring check. Nothing is
withheld now: a producer may run later in the same turn, and a client cannot
know at connect what will have run by the time a call is made.

`detect_kind` goes too, and that one is measured rather than argued: run
against the example toolset's own area of interest it returns
`geojson.Footprint`. `detect.py` documents that choice, and it was harmless
only while a declaration could override it. `describe()` stays — shape, not
meaning.

State keys gain the tool that produced them, so a model choosing between
stored values can tell `search_datasets`'s geometry from another call's.
`with_server_name`/`owners` carry the server name for undeclared captures,
which `langchain_mcp_adapters` accepts and records nowhere.

339 tests pass, the session-state demo runs end to end, and the AG-UI example
client builds. Roughly 700 lines out and no domain vocabulary left in the
runtime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tool owns what it returns, so nothing here inspects a return to decide
whether a value was derived or echoed — an equality test against the call's
arguments catches the echo and misses every transformation, which is a label
that is sometimes right with no way to know which time.

What the client knows for certain is where each argument came from. A handle
is a reference to something a tool produced; anything else the model wrote.
StateEntry.inputs records that per producing call: parameter names and state
keys, never values. The middleware already sees the model's raw arguments
before any substitution, so it costs one comprehension and no plumbing.

This is what closes the hole NotAuthored leaves. A model can invent a bbox,
pass it to a tool with an ordinary parameter, and have that tool return it —
captured, it wears the tool's name and reads as a gazetteer lookup. Now the
entry says {"bbox": "model"} where a real lookup says {"place": "model"}.

Each recorded input names either the model or another key, and that key's
entry carries the same record, so a value's history is a walk over facts
rather than a propagated flag. Everything here stops at one level: deeper,
"the model wrote something upstream" is true of every value in a session.

Recorded, never enforced. Enforcing turns visibility into a guarantee at the
price of a tool going uncallable whenever its only producer was itself called
with a model-authored argument, and that lands on a user with no way to clear
it. Read by the handle listing a refusal shows the model, by step_input, by
the state channel and the state route, and taught in SESSION_STATE_PROMPT.

The session-state example's clip_raster now returns the bounds it already
computed, so the demo has a two-level chain to walk rather than a flat one.

358 tests. Verified on the live AG-UI service: STATE_DELTA carries inputs per
key, and a receipt's display line reads "query written by the model".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six places still spelled the illustrative state key
`dataset-search/search_datasets/geometry`, but the same change that made a
field name the contract renamed that field to `area_of_interest` — because a
footprint is also a geometry, which is the distinction the whole thing rests
on. An example that names a field nothing publishes argues against its own
point, and `handles.py`'s copy is not a comment: it is the description of the
handle branch, so every model choosing a stored value read it.

Two were stale on a second count, still showing a two-part key from before
keys carried the tool name.

`docs/CONSUMING.md` had the worse one. It told a consumer to **branch on
`via`**, distinguishing `declaration` from `handle` — and `Receipt.via` went
with `Kind`, along with the declaration path itself. A receipt is `{key, tool}`
now, so the instruction named a field that cannot be read. Its sample
`display` line was quoting the old shape too, kind included; the replacement is
what `step_input` actually returns, run rather than transcribed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Observed against a real consumer rather than reasoned about: a model
reading a dataset schema learned that CDS's own request body takes an
area, read the published bounding box back with inspect_state, and
filled both the opaque request dict and the narrowed area parameter.
The tool preferred the literal, so the value that reached the service
came from the transcript while the client's receipt still reported it
as supplied from session state.

Nothing in the mechanism failed. The schema was narrowed, the handle
was dereferenced, the receipt was minted. The marker simply does not
reach a second parameter that accepts the same value, and reading a
stored value is a supported move, so obtaining one to write costs the
model nothing.

A tool author tagging one parameter needs to know that before relying
on it, so the limit is stated where the tag is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A handle nested inside an opaque argument cannot be substituted, so
the call is refused and the model is told what to do instead. That
advice ended "read a value with inspect_state and write the field
yourself", which is right for an ordinary opaque field and wrong for a
tool holding a parameter its server said a model must not write.

Observed rather than anticipated. A model put a handle inside a CDS
request dict, was refused, read the closing line, fetched the value
with inspect_state and wrote it in. It was following the instruction
exactly, and the instruction was to carry the value around the
constraint the same tool had just declared.

The refusal now takes the tool's not-authored parameters and varies
the closing on them: where one exists it is named as the parameter to
pass the handle to, and the suggestion to write anything by hand is
gone. The diagnosis is unchanged, and an empty state still says so
either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
offer_handles took a `skip` set naming parameters an injected
declaration would fill, so offering them a handle branch would have
confused the model. Nothing injects any more, so the only production
caller went with Kind and the argument has been unreachable since. Its
docstring still described the deleted behaviour, and the test covering
it asserted that a parameter about to leave the schema was left alone
— a mechanism that no longer exists, passing trivially. Replaced with
two tests for what `only` actually does, including that it narrows a
scalar the structured-type test would have skipped.

offers_handles had no callers in src, examples or js, and was never
exported. bind_injected makes the same decision by identity.

state_declarations resolved not_authored(tool) twice per tool, each a
get_type_hints over the tool's signature.

Also: the StateEntry comment pointed at mcp_state.middleware for
MODEL_AUTHORED, which is defined eight lines below it in state.py, and
three docstring lines had drifted past the configured line length
where E501 is not selected to catch them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The provenance note on a listing line named every model-authored
argument of the call that produced the value, which inverts it. A
submit taking its area from state and seven scalars beside it carried
seven names; one invented outright from a single argument carried one.
So the value resting on a real AOI was marked more heavily than the
value with nothing behind it, on the listing a model reads to choose
between them.

The note means "no tool-found input anywhere behind this", so it now
gates on the whole call rather than filtering its arguments: a call
that drew on state at all says nothing, and one that drew on none
names what the model wrote. Past a few names the count stands in for
them, since by then the names are what stops the rest of the line
being read.

rests_on_state reads the other half of inputs, beside authored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The stream and the single-value route describe the same entry two
ways, and only one of them said so. events.py omits seq and inputs
where there is nothing to say; read_state returns a plain dict and
sends both as null, because a route serving one value in full answers
about every field of it, while the stream re-sends every key each turn
and there the same nulls are only weight.

Neither is wrong. Both are now documented, so a client reading both
surfaces is not surprised by one of them.

Also: the inputs entry on the stream was assembled by splatting an
empty dict on the negative branch, which reads backwards, and its
docstring drew a distinction between an absent field and an empty
object that the code does not make — capture never writes an empty
inputs, so a key that is present always names at least one argument.
Said that instead, since it is the property a client can rely on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The client is where this change is legible. With no Kind to match on a
model picks a stored value by reading its key, and a reader has to be
able to see whether it picked well. Ported off the removed vocabulary,
then built out to show what replaced it.

The state panel carries the whole of an entry's inputs — the
model-authored half and the state keys both, since the keys are what
make the chain walkable once the call has scrolled out of the log —
with the argument the model actually wrote recovered from the
transcript, which nothing on the wire carries. Hovering any of a key, a
call or an activity lights the same three-sided link.

Four rendering faults, every one found by looking at the running UI
rather than by a check. The caret marked whichever assistant message
was newest rather than the one receiving tokens, so it blinked on an
empty bubble through four consecutive tool calls. Markdown tables came
out as raw pipes, because react-markdown parses CommonMark and tables
are a GFM extension. Activity rows never said which call they belonged
to, and a turn emits one per tool. And a parameter was rendered
indistinguishably from its value.

The example toolsets move with it: sketch_area normalises what it
stores rather than echoing its argument back, and the build factory
drops the withheld list that no longer exists.

For review: none of examples/agui-events/web is covered by a CI job —
scripts/build-js reaches js/mcp-view alone — which is why every fault
above was found by eye.

@yellowcap yellowcap left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes a lot of sense and does not degrade performance. For testing, I did make a port of the geo-assistant to this and it worked really well. Thanks @ciaransweet !

@ciaransweet

Copy link
Copy Markdown
Contributor Author

This makes a lot of sense and does not degrade performance. For testing, I did make a port of the geo-assistant to this and it worked really well. Thanks @ciaransweet !

Amazing! Thanks!

@ciaransweet
ciaransweet merged commit 148492d into main Aug 25, 2026
4 checks passed
@ciaransweet
ciaransweet deleted the feat/not-authored-parameters branch August 25, 2026 08:26
ciaransweet pushed a commit that referenced this pull request Aug 25, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.8.0](mcp-toolsets-runtime-v0.7.1...mcp-toolsets-runtime-v0.8.0)
(2026-08-25)


### ⚠ BREAKING CHANGES

* **state:** `Kind`, `mcp_runtime.kinds`, `mcp_state.wiring`,
`detect_kind`, `entries_of_kind`, `published_kinds`, `publishers`,
`BY_DECLARATION`, `BY_HANDLE`, `supplied`, `offers_handles` and
`BuiltAgent.withheld` are removed, along with the `tools.withheld` AG-UI
activity and `kind` on every state entry the API serves. `offer_handles`
loses its `skip` argument. State keys gain the producing tool, so a
two-part key is now three parts.

### Features

* **state:** remove Kind; a name is the contract
([#99](#99))
([148492d](148492d))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: ds-release-bot[bot] <116609932+ds-release-bot[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants