Skip to content

fix(mcpl): the toolsets host follows the server's lists and tells each change once - #813

Merged
nissa-seru merged 47 commits into
anima-research:mainfrom
nissa-seru:prudence/rfc014-toolsets-fixes
Oct 26, 2026
Merged

nissa-seru merged 47 commits into
anima-research:mainfrom
nissa-seru:prudence/rfc014-toolsets-fixes

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

This fixes the RFC-014 host side released in 0.27.0 (#799), which applies to servers configured with toolHints: true.

Problem

With toolHints on, 0.27.0 has these problems. Each makes at least one test this PR adds fail on 0.27.0's code, and the tests named here are among them.

  • A tools/list was taken page by page. A malformed page, or a list whose answer came after its timeout, still changed what the host believed ("a tools/list with a malformed page changes nothing the host believes", "a tools/list whose answer comes after its timeout applies nothing").
  • Recovery missed two cases. A hint or toolsets/list entry naming a tool tools/list hadn't listed started no recovery ("a hint naming a tool tools/list hasn't listed is recovered from tools/list, then toolsets/list"). Nor did a list whose pages carry different mcpl/revs ("a tools/list whose pages carry different revs starts a recovery, …").
  • Changes weren't told once and in order.
    • An ended record whose notice wasn't written was lost ("an ended record is told once it reaches the agent: …").
    • A told id was never forgotten, so a later record under that id wasn't heard ("a told ended id is forgotten once the server stops reporting it, …").
    • A result's change could be told after a change read later ("a result's change told as a notice comes before a later change read after it").
    • A definition counted as shown even when its notice wasn't written ("a definition first described in a notice that wasn't written is described again").
  • A result's change went with it even where the result reached no agent, such as a host's own open ("an open's tool change goes with its result where the agent reads it, and as a notice for a host's own open"). A model's call answered with structured content alone showed the change and not the content ("a model's call whose result is structured content alone shows that content, then the tool change its hints made"). A change could be cut with its result's tail at the inline cap ("a model's call whose result, with its tool change after it, would pass the inline cap keeps its result whole …").
  • Rendering a server's definitions could throw on a description that isn't a string, which stopped the notice or toolsets--look ("a tool a server describes malformed …").
  • Smaller ones, each with a failing test:
    • a hint that moved a tool to dynamic dropped the definition it carried;
    • reminders restated servers without the pushEvents grant;
    • a first sentence could be cut inside a surrogate pair;
    • a selector that isn't a name was read as left out;
    • an open or close of unknown outcome was reported as not done;
    • a disconnect during a toolHints server's connect let it dial the server, and a stop failed it with the module's own refusal;
    • removing a server whose reconnect was pending, or restarting it under another id or with toolHints off, left toolset state behind;
    • the capability mask reported RFC-014's declarations as masked;
    • log lines printed server-chosen values unredacted;
    • a refused programmatic act wasn't traced as a failed call.

Changes

The first commit fixes the hint state, in the toolset controller and tool presence. The fifteen after it fix the wiring: how the framework's call sites use that state. The twenty after those answer reviews of the whole change and their text checks, on both sides.

Hint state

  • A tool's presence is what the latest tools/list states, from the moment it's read. A hint's presence stages nothing; a move takes effect when a list states it (RFC-014 §4.1).
    • Once the next rebuild of the model's tool list that lists the server has published a move, a notice says where the tool went. A move is no reason to rebuild early, whether a hint announced it or the host's own re-read found it.
  • A tools/list reaches the controller whole, as its last page is read, before anything read after it. A list whose pages carry different numeric mcpl/revs straddled a change, and the host recovers from it.
  • Changes are taken only by a caller that shows them to an agent with their carrier, synchronously as the carrier is read. The caller marks them told once they reached the agent. Anything nobody took is told as a notice, in read order.
    • Without the pushEvents grant, a result's change that a notice can't carry is dropped, the framework logs the refused notice, and toolsets--look shows the state. A push keeps its change for its own path.
    • Each ended id is told once. Its note travels with it, and the id is forgotten once the server stops reporting it (§5.3).
    • A tool's first sentence is owed until text describing it reaches the model.
  • A malformed definition no longer makes rendering throw. Deep nesting is cut short the same way on every runtime: a signature stops at its depth bound and writes a literal nested more than 65 levels deep as …, definition identity stops at 65 levels, and look shows JSON nested more than 65 levels deep as too deep to show, and JSON too long for the runtime to write as too long to show. Look's definition leaves out a description or class entry that isn't text.

Wiring

  • A result's change goes with the result only where the result reaches an agent: the model's own call, counted once its result is handed back, and the operator's puppet, which stores its call and result in the agent's window. Elsewhere it's told as a notice. So is a dynamic tool's change that wouldn't fit whole within its result's inline cap, and the change of a call that fails after its result was read.
  • Until a move applies, a tool is called wherever the model has been shown it, in the tool list last published or in the server's latest tools/list (RFC-014 §4.2: "Until it applies the move, the tool keeps working where it is"). A call by name is refused unless the tool list last published or the server's latest tools/list shows the tool static, and toolsets--act's "not a dynamic tool" unless one of them shows it dynamic. look naming a tool shows the definition act would check, wherever act would take the tool.
  • An open or close whose outcome is unknown is reported as unconfirmed. It, and an act of unknown outcome, name toolsets--look, and look, which re-reads each server that presents toolsets, says when it couldn't.
  • A selector that is present but isn't a name is refused (null reads as left out), and a refusal for a name the server doesn't have says what it has.
  • Removing a server, or restarting it under another id or with toolHints off, forgets its toolset state. A stop or a disconnect while a toolHints server is still connecting ends the connect.
  • The capability mask leaves toolHints, toolsets and surface alone, and operator tool-name patterns naming a dynamic tool count as matched.
  • Server-chosen values in log lines, and in the trace of an act that can't be resolved, are redacted. A request kept for a late answer keeps only what that answer's read uses.
  • A result that is structured content alone shows that content, as its JSON, before the tool change that goes with it, for the model and for the puppet; with no change, it reads as 0.27.0 read it.

Five changelog fragments say what changed since 0.27.0, by what a caller meets. One is breaking: a host that set toolHints without granting the server pushEvents now needs the grant to keep the reminder of open toolsets at an agent's next start.

What moves from 0.27.0's README

  • toolsets--look naming a tool: 0.27.0's README calls what it shows "one tool's whole definition". It shows the tool's signature, description, class and input schema, as the code did, and while act would take the tool, the definition act would check.
  • "Hint changes apply as their message is read" still holds, except for a move between static and dynamic, which takes effect when tools/list states it. Until the rebuild, the tool is called at either place.
  • The reminder of open toolsets at an agent's next start now comes only for a server granted pushEvents (the breaking entry).
  • "Each ended id is told once" becomes "once while the host runs", and rare interleavings can tell one twice.
  • A dynamic tool called by its prefixed name is no longer always refused: while the tool list still offers it, the call goes through.

Tests

On Linux, with Node v24.22.1, at 2b022007 (the head, 8f71c3bb, changes one comment and the README's and fragments' text after it):

  • npm test: 7,794 tests, 7,750 pass, 0 fail, 44 skipped, main's test/mcpl-toolsets-allowed-tools.test.ts among them.
  • Typecheck is clean at 8f71c3bb.
  • The 141 tests in the three toolsets test files (mcpl-tool-hints, mcpl-toolsets-e2e and mcpl-toolsets-wiring), run on 0.27.0's code (0a4a2c69), give 60 passes and 81 failures. Of the 81, 79 fail on their assertions, on a wait for what they test, or on reading a result that isn't there. The other 2 call a method 0.27.0 doesn't have.
  • At 8f71c3bb the same three files pass on Node 24.22.1 (141 of 141) and on Node 26.12.1 (139, with the 2 tests of a result nested past what JSON.stringify can write skipped, since Node 26 writes it out).
  • 44 mutations of the wiring's pieces each fail at least one toolsets test; a forty-fifth left code that didn't build, and isn't counted. They ran at 10462ef6, and include each part of the §4.2 rule (the published list alone deciding, presence alone deciding, a move told whether or not the published list shows it, and a move left pending dropped), the structured-only view (chosen on the call's taker, or kept where the change doesn't join), and a script's act reading the model's view of the result instead of the server's structured result. Two of them, which make a request kept for a late answer keep no toolset or no instance, each fail the test that a close answered after its timeout still closes its instance.

Merged with main at e7e2391f (a78a4ba8). One conflict, at the head of executeToolCallFrom in src/framework.ts: main (#804, for #363) reads the call through asCalled, and this PR adds a trace helper after it; both are kept, main's lines first.

Not verified

  • A real toolHints server. Every test uses the suite's scripted servers.
  • Paths with no test:
    • a push refused before the framework's push path reads it, which needs a stopping host (its mechanism, a push nobody takes being told as a notice, is tested in-process);
    • a push handler that throws;
    • the tools/list read hook's guard, which nothing known reaches; it stays as containment, since a throw there would leave the request unsettled;
    • open's and close's marking of what they told;
    • look's "(too long to show)", which needs a schema whose pretty-printed JSON passes the runtime's string length, hundreds of megabytes.
  • Limits, stated rather than built for, by our reading of the code. Two have tests, named where they stand:
    • Every move reaches the tool list at the next rebuild that shows it, whatever brings it. A rebuild whose listing of a server fails publishes nothing for it and tells none of its moves. One whose listing breaks off partway (an entry it can't read) publishes what it read, as 0.27.0 did, and tells each move that list shows: a move to dynamic of a tool it no longer offers, read or not, and a move to static only for a tool it read before the break. The rest wait for the next rebuild that shows them. Until then the tool keeps working where it was, with one exception, and the agent isn't told the move. The exception: a tool moving to static that a broken-off listing never read leaves the tool list, as every tool it didn't read does; act then refuses it, naming its listed name, and a call by that name is taken. A move's note is a notice, so it follows the pushEvents grant, as every notice does (that rule's test: "a tool change with no result to ride on is a notice only where pushEvents is granted").
    • A list read while a rebuild is under way can undo a move that rebuild then publishes; neither is told, and the next rebuild puts the tool back (test: "a move a list undoes while the rebuild waits on another server is never told…").
    • While a call is in flight, the agent's window can show its result ahead of an earlier change's notice. It can also show a change after one read later: a call's result when results complete out of read order, a push's event while it waits to be stored, and a change its taker couldn't show, told as a notice when the taker finds that out.
    • A push's change counts as told once the push is accepted; if storing its event then fails, the change is lost.
    • Two definitions that differ only more than 65 levels deep count as one definition.
    • A move that changes the view is told in two notices: the view's at the read, the move's note at the rebuild.
    • A straddled list read while a recovery runs starts no other recovery; its earlier pages stand until the next change or list.
    • A server with no toolsets stops presenting them once its latest tools/list has no dynamic tool, so until the rebuild, act takes its last one only with server named.
    • When a recovery's tools/list fails, a tool its toolsets/list names that the last list lacks stays unknown until a later change or list starts a recovery.
    • A structured-only error whose content is past what the runtime's JSON.stringify can write reads as 0.27.0 read it.
    • Rendering is bounded in depth, not in size: a value too long for the runtime's strings, or a notice for more tools at once than a call takes arguments, can still throw.
    • A change that goes with the model's own call's result is counted told when the result is handed back. If the turn is stopped while the call is out, the result is dropped, and the change with it, as in 0.27.0, by a run at 0.27.0 and one at 03d1fe13 (with pushEvents granted and not); toolsets--look shows the state it left. The same holds when the agent is gone, by reading alone.
    • A result's change drained by a later read before its caller resumed, on a server without notices, is lost, not shown with its result.
    • "Told once" for ended records can fail in rare interleavings: a look held across another read of the server, an end told between a statement's build and its delivery, or ids colliding across principals under one configuration id. Told ids are kept in memory, so "once" holds while the host runs.
    • A turn cancelled or reset while a call is in flight drops that round's results, the change with them.
    • A change whose result is stored by shares, or rendered differently when stored, can still be cut with its result's tail.
    • An open's, close's or look's result is mostly its change; the inline cap cuts it as it cuts any result.
    • When the puppet's open, close or look can't store its pair, the notice that tells its change names the definitions it rendered without describing them.
    • Until the rebuild, a tool newly in a closed undeclared group stays callable by its listed name (§4.2).

What each part is for

Hint state

  • Ended records.
    • ChangeSet.ended, markTold and untoldNotes mark an ended id only when its note reaches an agent, and render it only while untold.
    • The prune in adopt forgets an id once the server stops reporting it.
    • endedKeys is built from the ends that will be noted.
  • Recovery and list reads.
    • ApplyOutcome.unlisted and the recovery it starts: a name tools/list hasn't listed starts a recovery. A recovery whose tools/list fails still reads toolsets/list, as 0.27.0's did.
    • RequestOptions.onRead and sendToolsList's read hook, with its pages, revs and guard: a whole list, applied as its last page is read, marked straddled (a field of its own on the read) when its pages carry different revs. The hook and the awaiting caller each gather pages with a loop, so a page too large to spread into one call's arguments can't throw after the hook has applied the list.
    • HINTED_METHODS without tools/list, and the controller's straddled read.
    • The open-list guard in readToolsetList.
  • Telling changes.
    • unclaimed (ordered, with each carrier's method), tellUnclaimed, deliver and notice (told, empty or refused): what nobody took is told in read order, and only a refused push keeps its change.
    • takeChange (one public way to take a change, used inside the controller too), told and tell: the seam a caller uses to show a change and say when it reached an agent. refreshOpen returns the change it took, and a look that restates the present hands its taker each change marked blocksShown: false: told then counts none of its definitions shown, since look showed the live catalog, which counts what it rendered, while a notice that tells the change (the puppet's fallback) still shows them. changeText goes, since its callers take the change and render it.
    • isEmptyChange: whether a change has anything to take, tell or drop, read where a change is taken, told or noticed.
    • canNotify and notify's boolean on ToolsetHostDeps; the push path's finally.
  • Moves. pendingMoves and applyMoves, with setTools' seen presences: the moves owed to the next rebuild. listedPresence: where the server's latest list put a tool, the half of the §4.2 rule that shownAs reads besides the published list.
  • First sentences. render's mark option and its overlay of the shown set (Shown), toolBlock's seen, markShown, and showsBlock, the one rule both render and markShown use for which changes show a key's tools: a first sentence owed until it reaches the model.
  • Names. The first-listing rule, and the tool-policy check for names a hint or statement gives.
  • Rendering bounds. firstSentence taking any value and cutting by code points; the depth bounds in typeSig and canonicalJson, and typeSig reading a type only from text; literal, which writes a schema's enum values, const and default, … where they're nested too deeply to write, and jsonText for look, both bounded by nestsWithin at the depth canonicalJson uses rather than by a stack overflow, which comes at different depths on different Node versions, and jsonText's note for pretty-printed JSON past the runtime's string length, since nothing bounds a message's size; look's definition taking only a string description and string class entries.
  • Small fixes. groups.delete in setManifest; defKeyFor's string return.
  • Test helpers.
    • tappedController, which feeds a controller reads in process;
    • the scripted server's onList;
    • present, a list that moves a tool;
    • chunk, rawLine and holdingServer, the connection's reads in one synchronous run, a line written as text, and a server that holds a request until the test answers it;
    • roundTrip and nextMacrotask, waits for an effect;
    • the RFC-014 vector cases added for coverage.

Wiring

  • withChangeText (framework.ts): adds a change to a ToolResult where the model's reading would have put a text block (after an error's text, as a last block, or after a string).
  • withWholeChange (framework.ts): the inline-cap check for a dynamic tool's result, measured with the store's own storedToolResultText at the call's cap, the change compared as the store makes it well-formed.
  • changeTaker and puppetChanges (framework.ts): who takes a call's change. The puppet's calls are keyed by call id, and the call act resolves to keeps that id, so a puppeted act needs nothing of its own; the agent's tool round names the model's own calls. The module gets it as takerFor, which it requires.
  • The JSON view of a structured-only result in executeMcplToolCall, with withChange's alone (framework.ts): a change that joins such a result follows its content rather than standing in for it; without the join, a success reads as 0.27.0's and an error keeps its JSON; an error whose content is null, or past what the runtime's JSON.stringify can write, reads as 0.27.0's.
  • The model dispatch's taken (framework.ts): the change between its take and the hand-off, so the dispatch's failure can tell it.
  • ToolsetController's { shown } on open, close and refreshOpen, and changeOf: an unshown call leaves the change for its notice and still gets it to show its caller.
  • render's describeAll and catalog's mark (tool-presence.ts): a result that may reach no agent describes every tool and counts none shown.
  • renderOthers (toolsets-module.ts): the other toolsets' changes in an open's or close's answer, counted shown only where the answer reaches an agent, every tool described where it may not.
  • look's couldn't-ask line (toolsets-module.ts), in its answer and in a refusal: the reader knows that what look shows of that server is what the host last knew.
  • checkSelectors (toolsets-module.ts): one check at each entry point (the module's handleToolCall, resolveAct) instead of a change at each of a dozen reads.
  • requestFailure and withLookHint (toolsets-module.ts): the not-sent/unconfirmed rule and the look hint in one place each; withLookHint serves open, close and act.
  • withActStateHint (framework.ts): act's look hint on a failure, left out for a request known not to have been sent, which never ran.
  • hintedParams (server-connection.ts): the subset of a hinted request's params that a late result's read uses.
  • NONE_PRESENTS and noToolset (toolsets-module.ts): the refusals' texts, used at two and three sites.
  • release (toolset-host.ts): lets go of whatever connection a controller holds, for forget.
  • field, failure (toolset-host.ts): the controller's log redaction, used by its log lines.
  • runDisconnectMcplServer's keepToolsets (framework.ts): a restart keeps the controller where its id and toolHints stay.
  • failed (framework.ts): traces a programmatic call's refusal as tool:failed, an act's refusal redacted.
  • patternableToolNames (framework.ts): replaces modelFacingToolNames, whose only caller is the pattern check.
  • UNMASKABLE_KEYS gains RFC-014's three keys (capability-mask.ts).
  • The connect's module wait moves under the connect's abort (framework.ts), so a stop or disconnect during a connect ends it.
  • The no-agent log line (framework.ts), beside the pushEvents one.
  • published (ToolsetHostDeps; framework.ts answers it) and shownAs (tool-presence.ts): the §4.2 rule in one place, read by dynamicCallRefusal and resolveAct.
  • actKey (toolsets-module.ts): act's key rule for the definition it checks, shared with look naming a tool.
  • moves(), applyMoves(moves) and the rebuild's movesListed (tool-presence.ts, toolset-host.ts, framework.ts): each pending move checked against the list a rebuild publishes, and told once that list shows it (a move to static once the list offers the tool, a move to dynamic once it doesn't); a move the list doesn't show yet stays pending, and a move a list has undone since isn't told.
  • toolStatus's static line (tool-presence.ts) says "called by name", which holds whether or not the tool list holds the tool yet.
  • makeFramework's options and hintingServer's peek (test helpers): let a test enable code execution, set agents, class overrides or the inline cap, or give the dynamic tool another definition. withPresence, movingServer and arcAndSlow (test helpers): a list that moves a tool, a server that holds the rebuild's tools/list, and a second server that holds it, so a test can stand in the window.
  • heldAtModuleAdd, hintingServer, connectedTo, modelCalls, resultFor, notices, nextMacrotask and storedResult (the wiring test file):
    • a connect held at its module add, so a test can stop or disconnect in that window;
    • a toolHints server whose call answers carry a hint that shows peek;
    • a framework connected to it;
    • one model turn making the given calls;
    • what the model received for a call;
    • the tool-change notices in the window;
    • one turn of the event loop, the point by which an unclaimed change is told;
    • the puppet's stored result for a call.
  • The tests: each pins one requirement, or behaviour feat(mcpl): RFC-014 tool presence and toolsets, host side (opt-in toolHints) #799 had untested.
  • nestedPastStringify, stringifyOverflows and the conditional skip (test/mcpl-toolsets-wiring.test.ts): the test of a model's call whose result the host can't read needs such a result. Over JSON-RPC, the only one a peer can send is one nested past the runtime's JSON.stringify. The probe runs the test where the runtime throws on that nesting, and skips it, saying why, where the runtime writes it out (Node 26), instead of letting it pass there through the inline cap.

Questions for @antra-tess

Two designs from #799 that this PR leaves as they are, a gap in how prose routing reads act, and two smaller questions. None of them is needed for this PR. Runs use this PR's source, on Linux with Node 24.22.1, against the suite's scripted servers unless named otherwise.

1. Reminders and first sentences, decided per agent from the compile's provenance

A reminder comes due when a compiled request no longer holds a marker such as [arc/game:g1] that an earlier compile held (observeCompile, src/mcpl/toolset-host.ts line 690, through Agent.onCompiledToSend). A definition's first sentence is said once per server (ToolPresence.shown, src/mcpl/tool-presence.ts line 128).

  • Run: an announcement that leaves the context before any compile carried it is never reminded. A toolset opened in a turn's tool round, with its announcement removed right after that turn, had no reminder in either of the two requests the run checked after it. With one more turn between the open and the removal, the reminder came and named it.
  • Run: a second agent gets bare signatures. The resident opened game:g1, then an ephemeral run with its own store and window opened game:g2 of the same toolset. The ephemeral's window held no description of play. With nothing opened first, its window held it.
  • Read:
    • Any text that keeps the marker counts as the announcement, such as the agent's own words, or a summary that kept the label and lost the signatures.
    • seen and due live in memory (toolset-host.ts lines 641–642), so after a process restart an announcement that had left the context is never reminded.

The compile already reports what it holds: each stored row by messageId with complete, and a summary as a source of kind summary (CompileProvenance in @animalabs/context-manager 0.22.0, read by noteRenderedInjections, src/agent.ts line 1375). We propose marking the row that carries each announcement, the notice or the tool result it follows, with the open toolsets it announced.

  • A reminder is then due for an agent when an open toolset's latest marked row isn't whole in that agent's compile.
  • A definition's first sentence is owed when no row carrying it is whole there.

That replaces the marker scan, seen, due and shown, keeps nothing in memory across restarts, and keeps the hook inside the framework rather than in Agent's public API. Is that the shape you'd want for reminders and first sentences?

2. The host's argument check, checkArgs

checkArgs (src/mcpl/tool-signature.ts line 216) is introduced as a check that "can only fail to refuse what the server would refuse on those keywords, never refuse what it would accept" (lines 173–175).

Run under Bun 1.4.4, beside ajv 8.20.0's 2020-12 validator:

  • {type: "object", properties: {point: {type: "string"}}, patternProperties: {"^x-": {}}, additionalProperties: false} with {point: "d4", "x-trace": "1"}: ajv valid, while checkArgs refuses args.x-trace: not an argument of this tool.
  • {type: "array", prefixItems: [{type: "string"}, {type: "number"}], items: {type: "number"}} with ["a", 1]: ajv valid, while checkArgs refuses args[0]: must be number.
  • {anyOf: [{type: "object", required: ["point"]}, {type: "string"}]} with 5: both refuse, and checkArgs says args: must be object, naming only the first form.

Read:

  • @animalabs/mcpl-core 0.4.0's validator fills default before it checks required (dist/src/toolsets/schema.js lines 98–104). So a call that leaves out a required key with a default runs on ToolsetServer and is refused here.
  • The check's step budget counts visits, not the work in each, and anyOf/oneOf walk their first alternative again.
  • Some traversals skip the budget.
  • Running out of budget skips the whole check, invalid siblings included.
  • The text's "the server checks every call itself" isn't something RFC-014 states: §6.1 gives the host the check.
  • Of the check's own tests, the step-budget one trips the depth bound first, the bounded-time one asserts wall-clock maxima on synchronous work, and the depth-bound ones skip items, properties, additionalProperties and the alternatives.

The server already refuses a bad call with an ordinary tool error (RFC-014 §4.1). We propose dropping checkArgs. If a pre-send check is wanted, the framework's own toolArgsProblems (src/tool-args.ts line 164) checks required keys and, with values, a declared key's type and enum. That covers an instance's legal moves without a second partial JSON Schema engine. Is a pre-send check meant? If so, which validator should it agree with, JSON Schema or ToolsetServer's?

3. Prose routing for a send made through toolsets--act

Prose routing decides by a call's name whether it silences its round's prose (silenceCauseOf, src/framework.ts). A call through act has the name act, so a send that is a dynamic tool, called the only way a dynamic tool can be called, doesn't silence the round.

  • Run: a ToolsetServer with toolHints: true offering say, and an agent in locus mode (the default). A round held the prose "Ordinary field speech." and a say call; the next round's prose was "turn over".
    • With say static, called by name: the server's say ran once, and both prose segments were held as drafts.
    • With say in an opened toolset, called through toolsets--act: the server's say ran once, and prose routing also published both segments to the locus, so the agent's words went out twice.
  • Read: the same holds on 0.27.0. It needs a server that puts a send tool in a toolset, and we know of none that does yet.

We propose routing a call through act as the tool it resolves to would be routed if called by name, with an act that resolves to no tool silencing nothing. Is that how you'd want it?

4. Two smaller questions


  • Changelog fragments added: changelog.d/mcpl-toolsets-hint-state.breaking.md, mcpl-toolsets-hint-state.changed.md, mcpl-toolsets-hint-state.fixed.md, mcpl-toolsets-wiring.changed.md and mcpl-toolsets-wiring.fixed.md.

Written by Prudence-2217 and Octavia-2412 (Claude Opus 5.5). Ada-1017 (Claude Opus 5.5) decided its scope and shape. Jerome-2511 (Claude Opus 5.5), who built none of it, read the source against requirements read from RFC-014 and what callers meet, and Abigail-2676 (Claude Opus 5.5) weighed the tests.

Description checked and posted by Lottie-2661 (Claude Opus 5.5).

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared nissa-seru account.

Prudence-2217 and others added 30 commits October 25, 2026 23:12
…unts as told only when an agent reads it

Fixes to the RFC-014 host side (tool presence and toolsets, opt-in with toolHints) released in 0.27.0, in the toolset controller and tool presence. The framework's calls into the seam below change only as far as this needs; the wiring of each call site follows in the commits after this one.

Requirements:

Presence and moves
- A tool's presence is what the latest tools/list states, from the moment it's read (RFC-014 §4.1, §6.1). A hint's presence stages nothing: a hinted move takes effect when a tools/list states it, as every rebuild's own list does. So a recovery repairs a lost move, a reconnect doesn't keep a stale one, and a move a later list reverses before the rebuild is dropped.
- A presence a list changed is a move, told as a note of where the tool went at the next rebuild of the model's tool list. When the controller's own tools/list (connect, recovery) moved a tool, it asks the framework to rebuild then (ToolsetHostDeps.refreshTools, the framework's tools/list_changed path).
- A list's visibility for a tool is applied by the presence the list states; a tool a list puts in a toolset moves like any other.
- A definition a hint gives is the tool's current one, kept whatever the tool's presence when the hint is read.
- A tool name listed twice is known by its first listing, as the framework's attribution has it.

Reading lists and recovering
- A tools/list reaches the controller once, whole, synchronously as its last page's response is read (RequestOptions.onRead), before anything read after it; so a hint in the same chunk applies after the list. Pages of lists read at once can't mix; a malformed or timed-out list applies nothing. tools/list leaves HINTED_METHODS.
- A list whose pages carry different numeric mcpl/revs straddled a change: it's applied, then recovered from. One read while a recovery runs starts no further recovery (its earlier pages stand until the next change or list).
- A hint or toolsets/list entry naming a tool tools/list hasn't listed starts a recovery; one naming a tool the operator's tool policy leaves out doesn't. A recovery's own toolsets/list starts no other, and a recovery whose tools/list failed reads no toolsets/list.
- A toolsets/list result with no `open` list is ignored, not read as an empty statement.
- The read hook is guarded, so a throw in it can't leave the request unsettled.

Told once, in order
- A change is taken only by a caller that shows it to an agent with its carrier (takeChange, called synchronously as the carrier is read, before the next macrotask); taking it marks nothing, and the caller says when it reached an agent (told). takeChangeText takes, renders and marks, for a caller that shows it at once.
- Any change nobody took is told as a notice, in read order: taking or claiming a carrier first tells the unclaimed ones read before it, and a change look hands back is told at once. A change already told as a notice is empty when taken.
- A result's change whose notice can't be written (no pushEvents grant, no agent) is dropped; the framework logs the refused notice, and look shows the state. A push's change is kept for the framework's push path, which takes it when the push gets there. A push whose handler throws still has its change told.
- Every tools/list's change is told as it's read, the controller's own included.
- An ended record is told once: its note travels with its id, is rendered only while the id is untold, and the id counts as told once a notice was written or a result carrying it was shown. The next statement that no longer reports an id forgets it (§5.3), which also forgets ids a replaced server or an evicted principal no longer reports. Only an end that will be noted stands in for an instance's closure note.
- A tool's first sentence is owed until text describing it reaches the model: rendering for a notice or a push doesn't count it shown; told() counts the definitions a change describes. A notice describes a definition it shows twice only once.
- look shows what its re-read changed only when its answer restates everything; otherwise, and when it fails, the change is told as a notice.
- Reminders restate only servers whose changes may be told unasked (the pushEvents grant).

Rendering
- Rendering a server's definitions never throws: a description that isn't a string has no first sentence; a schema nested very deep is cut short in a signature, in a definition's identity (past 64 levels) and in look's answer for a tool or a toolset's options.
- firstSentence cuts by code points, never between a surrogate pair.
- setManifest drops a newly declared toolset from the host-opened groups; defKeyFor always returns a key.

Limits, stated rather than built for: a rebuild whose own listing of the server fails leaves a move untold, and the tool list behind presence, until the next refresh; a move that changes the view is told in two notices (the view's at the read, the move's note at the rebuild); a result's change drained by a later read before its caller resumed, on a server without notices, is lost rather than shown with its result; told-once can fail for a look held across another server's re-read, an end told between a statement's build and its delivery, and ids that collide across principals under one configuration id. Told ids are kept in memory, so "once" holds while the host runs.

Text: the README describes when a move takes effect, look's re-read, notices needing pushEvents, told-once's limits, RFC-008 class rules seeing the dynamic tool, and look showing a tool's description and input schema; TOOLSET_REQUEST_TIMEOUT_MS covers an open or close; the e2e file's header no longer claims vector 38. The changelog fragments say what changed since 0.27.0, by what a caller meets: one breaking entry (reminders now need the pushEvents grant), the changed behaviour, and the fixes.

Tests: each requirement above has a test asserting what an agent, an operator or the server meets, except: a push refused before the push path reads it (needs a stopping host; its mechanism, a push nobody takes being told as a notice, is tested in-process); open's and close's marking; the read hook's guard (it throws only for a page too large to spread into one call's arguments); a throwing push handler; the push path's marking; and defKeyFor, which has no behaviour of its own. Each test added with a fix failed against the code before that fix, except those pinning behaviour that code already had (the RFC-014 vector cases added for coverage among them). Tests wait for the effect they read (a round trip, the agent going idle, one macrotask past a result); fixed waits stay only where a test asserts that nothing happens or waits out a request's timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…toolsets module

connectMcplServerInternal awaited ensureToolsetsModule() after its
stopHasBegun check and before it registered the connect's abort, so
neither stop() nor a disconnect of the id could end the connect while
the meta-tools module was being added.

- A disconnect then found no abort to fire: the connect went on to
  dial the server once the module was in, and only the post-handshake
  displaced() check removed it again.
- A stop then made the connect fail with addModule()'s own refusal
  ("addModule() refused: the framework is stopping") instead of the
  connect's "was not connected: the framework stopped while it was
  connecting".

The await now sits inside the try, after the abort is registered, so
either ends the connect through the abort: the handshake sees the
aborted signal before it opens a transport, and the catch words it as
any other connect ended that way. A module-name collision still
throws its own error.

Tests: a disconnect and a stop while the module is being added (its
addModule held open); both fail at 8aaee1c, the first with one dial
of the server, the second with addModule's refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The meta-tools read server, toolset, instance and tool through str(),
which turns anything that isn't a non-blank string into undefined, the
same as leaving the argument out. So close {toolset: 5} or
{instance: ""} closed the only open instance, and an act with a blank
instance picked the only one open. The module's schema check covers
required keys only.

Each entry point (the module's handleToolCall and resolveAct, which
the framework's act dispatch uses) now refuses a selector that is
present but isn't a non-blank string, naming the argument and what it
got, before anything is resolved or sent. Absent and null still read
as left out.

Tests: close with a blank instance, a numeric toolset and a list for a
server, look with an object for a tool, and act with a blank instance
are each refused, and nothing is closed or called; at 8aaee1c the
blank instance's close went to the server for g1. An act and a close
whose selectors are null resolve as if they were left out; that passes
at 8aaee1c, and fails once null is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nconfirmed

toolsets--open and toolsets--close wrapped every failure of their
request as "<server> did not open <toolset>: <error>", while the
connection's own error says whether the request took effect is
unknown: a timeout, a connection lost before the answer, or a JSON-RPC
error (which the connection classes as possibly having acted). A model
told "did not open" may open again, and an open without a name makes a
second instance (RFC-014 §5.2).

Now only a request known not to have been sent (McplRequestError's
"not-sent") says "did not". The controller's refusal before sending,
when the server isn't connected, is such an error now, so it still
says "did not". Any other failure says "<server> didn't confirm opening
<toolset>", keeps the connection's text as a sentence, and adds that
toolsets--look {server} asks it what's open (withLookHint).

Tests: an open whose connection drops before the answer, and a close
the server answers with an error, are each reported as unconfirmed
with look named; at 8aaee1c the first read "arc did not open build:
... whether the request took effect is unknown". With the connection
gone, a second open says "arc did not open build: arc is not connected
right now; try again later"; that fails if the controller's refusal is
a plain Error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…an agent

The seam that "tool hints follow the server's statements, and a change
counts as told only when an agent reads it" put in place:
takeChange(carrier) takes a carrier's change without marking it, and
must be called as the carrier is read, before the next macrotask;
told(cs) counts a change as shown; tell(cs) writes it as a notice, or
drops it when none can be written. RFC-014 §6.1 and
§6.2: a call reports its own consequences, and every change reaches
the agent once.

Who takes a change (changeTaker):
- The model's own call of a dynamic tool takes it as the server's
  result is read, and adds it to the tool result after the result has
  been read for the model. withToolsetChange had appended the change
  to the server's content first, and the model's reading shows
  structuredContent only when content is empty, so a structured-only
  answer that carried hints reached the model as the change text
  alone. The change is counted told once the result is handed back.
  An open, close or look the model calls counts its change told as the
  module takes it.
- The operator's puppet stores its call and result in the agent's
  window as a model's pair, so it takes the change too, keyed by call
  id (which the call toolsets--act resolves to keeps), and counts it
  told once the pair is stored. It stores a structured-only answer
  that isn't an error as that content's JSON, as the model's reading
  shows it. An open's or look's rendering counts the definitions it
  describes as shown as it renders them, so a notice after a failed
  store names them without describing them.
- Anything else (a script's call, a module's or a host's call in
  process) may never reach an agent, so its change is told as a notice.
  This holds for open, close and look as well as a dynamic tool's
  result: the controller's open, close and look re-read take a shown
  flag, and an unshown call leaves the change for its notice. Its
  result also shows the change to its caller, every tool described
  and nothing counted shown (render's describeAll, catalog's mark),
  unless a message read after it in the same chunk had the change told
  as a notice first.

Ways out, each telling the change as a notice instead:
- a dynamic tool's change that wouldn't fit whole within its result's
  inline cap, as the store renders the result when the call returns
  (the store's own storedToolResultText, at the call's cap, with the
  change compared as the store makes it well-formed), so the result is
  left whole rather than cut with the change in its tail;
- a model's call that fails after its result was read;
- a puppet whose pair can't be stored, or whose result is replaced by
  the stored error.

Also:
- A notice dropped because no agent is registered is logged, as the
  drop for want of pushEvents already was; both lines say look shows
  the state the change left.
- look says when it couldn't re-read a server ("X couldn't be asked
  just now: this is what the host last knew of it."), in its answer
  and in a refusal. The look hint an unconfirmed open, close or act
  gives says look asks the server what's open, or says it couldn't.
- ToolsetsModule's constructor takes the taker; left out, every call's
  result is taken to reach the agent, as 0.27.0's constructor did.
- withToolsetChange, which nothing calls now, is gone; withChangeText
  adds a change to a ToolResult in the same places the reading would
  have put a text block.
- The toolset test helper's makeFramework takes config options.
- The README says which calls' changes go as notices, and that look
  says when it couldn't ask.

Tests, each from a requirement. All fail at 8aaee1c except two: the
puppet's unstored pair, since anima-research#799's puppet took no change, and the
lone surrogate, since anima-research#799 appended every change:
- a model's call answered with structured content alone shows it, then
  the change, and nothing is told again (8aaee1c: change text only);
- a script's call leaves the change to a notice and its output doesn't
  hold it (8aaee1c: the change in the script's output, no notice);
- the puppet's stored result holds the change, and nothing is told
  again; it stores a structured-only answer as its JSON before the
  change; its toolsets--act stores the dynamic tool's result with the
  change;
- a puppet whose pair can't be stored tells the change as a notice
  (fails with the notice dropped, or with the change told at the take);
- a model's call whose result plus change would pass the inline cap
  keeps its result whole and tells the change (fails with no fit check);
- a model's call whose change fits goes with its result where a
  definition holds a lone surrogate (fails when the change is compared
  as it came);
- a model's call whose result the host can't read (structured content
  nested past what JSON.stringify can take) tells the change (fails
  with the failed dispatch's tell removed);
- an open's change goes with the puppet's stored result, read from the
  stored pair, and as a notice for a host's own open (fails if a
  private call counts as shown, or if the puppet stores a result
  without the change); a host's own close is told as a notice too;
- look says when it couldn't ask a server, in its answer and in a
  filtered look's refusal (fails with the refusal's line removed);
- a change with no agent to tell is logged;
- a standalone change is a notice only where pushEvents is granted, a
  notice starts no turn, and without pushEvents look shows the change.
The existing tests that read the change in a host's own look or
act's result now read it through the puppet, or check only the
change they name. The tests that build a ToolsetsModule themselves
use 0.27.0's form, ToolsetsModule(hub), which doesn't compile without
the taker's default (TS2554). A look through ToolsetsModule(hub) takes
its change as told, so no notice follows, and with a null taker it's
left to a notice; that fails when the default leaves the change to a
notice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hintedRequests kept each pending hinted request's whole params, up to
1,000 entries, until it was answered or the transport ended. A run at
b9a7bc8 with 1,200 timed-out tools/call requests of 10 KB arguments
each held 10,041,000 characters of params at the cap. The read of a
late result uses only its method, and an open's or close's toolset and
instance (readResult), so that is all that's kept now; a call's
arguments are never held. With tools/list out of HINTED_METHODS (since
"tool hints follow the server's statements, ...") no cursor is needed
either.

Test: a close answered after its timeout (the connection's own
request-local timeout, 300 ms) still closes the instance it named, and
is told. It passes at 8aaee1c, which kept every param, and fails once
the toolset or the instance isn't kept. That a call's arguments aren't
kept is memory no caller observes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
toolsetDeclarationsOf's comment says the host's capability mask never
applies to RFC-014's members (toolHints, toolsets, surface), since
they're declarations, not §6.2 capabilities; hints run from the raw
advertisement. But maskNegotiatedCapabilities masked every member but
version and featureSets, so a server config whose enabledCapabilities
named none of them logged them as "capabilities masked by host config"
and recorded them as dropped, while the hints went on regardless.

They join version and featureSets among the keys the mask passes
through, and enabledCapabilities' doc says so. A host turns hints off
per server with toolHints: false.

Test: with enabledCapabilities: ['pushEvents'], nothing of RFC-014 is
logged as masked, and the dynamic tool stays out of the tool list with
the meta-tools offered. At 8aaee1c the log read "capabilities masked
by host config: toolHints, toolsets.build.title, ..., surface".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A disconnect's permanent removal clears the server's feature-set and
checkpoint state itself, since a connection that already closed
transiently (its reconnect pending) emits no second close. Its toolset
controller, though, was detached only from the close handler, and
ToolsetHub.forget had no caller. So a toolHints server removed while
its reconnect was pending stayed active and presenting; re-added under
the same id with toolHints off, its stale presence still hid its
dynamic tools from the tool list, and look still spoke for it.

The removal now calls forget beside the other two, and forget has the
controller let go of whatever connection it holds (release) before
dropping it, so a server added later under the id starts from nothing.
A restart goes through the same removal: one under the same id with
toolHints on keeps the controller, and with it the ended ids it told,
the definitions it showed and the events it saw, as before; one that
moves the id or turns toolHints off forgets it.

Tests: a toolHints server whose connection is lost (reconnect pending)
is disconnected, and a server added under its id with hints off lists
its dynamic tool, while look says no server presents toolsets (at
8aaee1c the tool stayed hidden). A restart under the same id with
toolHints doesn't tell an ended record again; that passes at 8aaee1c,
which never forgot, and fails once a restart forgets. A toolHints
server whose reconnect is pending, restarted with toolHints off, lists
every tool, and one restarted under another id leaves a server added
under the old id listing every tool (both fail at 8aaee1c, and each
fails once a restart keeps the controller in its case).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ields

The controller's log lines printed values a server chose raw: a push's
eventId (a duplicate, or one with coalesce), a hint's current and
previous rev and a tools/list's rev, a proposed and a returned
instance name, and the text of a failed tools/list or toolsets/list.
This codebase's rule for such values is that they're redacted with the
server's configuration (its url, token and env) and printed through
log-value, so one value stays one field of one line and can't pose as
another record or field.

Each now goes through logValueRedacted with redactServerText for the
server's configuration (field), and a failure's text through
redactServerText then logDetail (failure).

Test: a push with hints whose eventId holds the configured token and a
line break, sent twice, logs the duplicate with neither, as one quoted
field; at 8aaee1c the line printed the token and broke onto a second
line. The other lines use the same two helpers and are checked by
reading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… what stops

- A refusal for a name the server doesn't have now says what it has:
  look's, open's and close's "has no toolset" list its toolsets (or say
  it has none), look's "no open instance" lists the open instances,
  look's "has no tool" names look as act's refusal already did, and
  "no server that presents toolsets has ..." names look.
- With no server presenting toolsets, a meta-tool call no longer asks
  for a server name that can't be given ("name a server: (no MCPL server
  presents toolsets now)"); it says no server presents toolsets now and
  that MCPL tools are called directly, by name. A named server that
  sends no hints, look with nothing presented, and the note telling the
  agent a server withdrew toolHints say the same; each had said every
  tool is in the agent's tool list, which an agent's allowedTools or
  the tool presentation can make untrue.
- A toolsets--act call whose outcome is unknown (anything but a request
  known not to have been sent) keeps the connection's text, whose remedy
  names no tool the agent has, and adds that toolsets--look {server}
  asks it what's open (withLookHint), on the model's dispatch and the
  programmatic call alike.
- close's description said its tools and its events stop; closing an
  undeclared group only gates its calls, and the server, never told,
  keeps its events. It now says the server is told and stops its events
  for a toolset it declares.

Tests: the refusals (fail at 8aaee1c: "arc has
no toolset "nope"" and the rest), and a meta-tool naming a server that
sends no hints (fails with that refusal's earlier wording, "its tools
are in your tool list"); and an act whose connection drops
before the answer names look, through a model's turn and called in
process (at 8aaee1c the connection's text alone). close's description
is text, checked by reading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s are traced, and two texts are corrected

Since 4d13f1e a dynamic tool keeps its class and lifecycle selectors
name it (act goes down the direct-call path), and the resolver's map
holds its listed name. Three places hadn't followed:

- checkToolPatterns judged a pattern against the names the model is
  offered only, so a toolClassOverrides key or lifecycle selector
  naming a dynamic tool was reported "matches no tool" though it
  applies. It now counts every listed name (patternableToolNames).
- executeToolCallFrom's act branch returned an act that couldn't be
  resolved, or that the caller's allowedTools refused, without the
  tool:failed trace the permission check beside it emits for the same
  caller; both now go through one traced-failure helper. Either
  refusal can quote the server (a schema's enum values, the server's
  name for its tool), so each trace is redacted with every server's
  configuration, as is the model's act refusal's trace beside it.
- The duplicate-listing alert said the model is offered the first
  listing, which a dynamic tool never is: the first listing is the one
  used, offered or called through toolsets--act. resolveMcplTool's doc
  and executeToolCallFrom's comment said the listing "offered" the
  name; it lists it, offered or dynamic.

Tests: a class rule naming a dynamic tool isn't reported unmatched
while one naming no tool is (8aaee1c reported "arc--peek" matches no
tool); a programmatic act naming no tool gives one tool:failed trace
(none at 8aaee1c); a refused act's trace, in process and from the
model, carries a server's credential redacted (fails with either
redaction removed). The two texts are checked by reading. The
duplicate-listing alert's own tests (mcpl-prefix-rule) read its new
wording, and mcpl-config-diagnostics' stub of the renamed method
follows the rename. The allowedTools refusal's trace, redacted, has no
test in this commit: it belongs with the test of that refusal itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anima-research#799's tests didn't reach these. Each test is written from what an
agent or an observer meets:

- A model's call of a dynamic tool by its listed name is refused at the
  dispatch, naming the act form, and a model's act that can't resolve
  is refused; the server receives no tools/call for either.
- act's results arrive as a direct call's do: a script's act gets the
  server's structuredContent, and a server's error reaches the model as
  an error result.
- A dynamic tool keeps its own feature set: with two stateful feature
  sets, calls through act and direct calls in sequence are each sent
  their own set's state (the existing state-exchange test has one set,
  so it passed through the single-set fallback).
- A classed dynamic tool called through act is observed with its class
  and, under an inputs grant by class, its arguments (the existing
  lifecycle test's dynamic tool was unclassed and its observer got
  metadata only).

All four pass at 8aaee1c, since they pin behaviour anima-research#799 already had.
Each fails under a mutation of what it pins (Linux, at this branch's
head): dropping the dispatch's dynamic refusal, dispatching act other
than down the direct-call path, keeping a feature set only for offered
tools, and keeping a class only for offered tools.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…three end-to-end gaps

Each written from what a server or an agent meets:

- toolHints off: the capabilities the server is offered are compared
  with 0.26.2's literal object, not with the framework's own field;
  every tool is compared whole (name, description, input schema, in the
  server's order), not by name; and no toolsets/* request of any kind
  is sent. The literal is what an unchanged host offered in a run
  against anima-research#799's merge base (3b9c7dd).
- vector 9: the drop is seen through the server's own count of
  connections rather than a swallowed wait; after the reconnect's
  toolsets/list, a static call's answer (the server answers in order)
  stands in for the 200 ms sleep; and the instance is used through act
  before any look, since look re-reads toolsets/list itself. The test
  pins that an instance the server kept is neither opened again nor
  lost across the reconnect.
- an open's result holds the server's own content (the board) beside
  the announcement.
- an instance the server names is used as given (act with that name
  plays it), and opening what is open is adopted ("already open"), one
  open sent (RFC-014 §5.2's two MUSTs).

All pass at 8aaee1c: they pin what anima-research#799 already did. Mutations
(Linux, at this branch's head): with no toolsets/list after a
(re)connect, vector 9 fails at its wait; with the statement not
applied, vector 9 passes, since the host keeps the instances it
believed open across a reconnect, and a later test that reads an
ended record fails instead. The server-named instance test still
passes with both places that read an open's instance name mutated,
since the server's hints carry the name too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or withdraws toolHints

RFC-014 §5.1's requirements had no test for withdrawing toolHints by a
manifest change, and vector 33's "closes its instances" removed only a
toolset declared without instances.

- A toolset with two open instances removed by a manifest change: each
  instance is told closed, and look shows none.
- toolHints withdrawn by a manifest change: the agent is told the
  server no longer presents toolsets and its tools are called directly
  now, and every tool is listed.

Both pin what anima-research#799 already did. The first passes at 8aaee1c; the
second fails there at the withdrawal note's wording, which an earlier
commit here changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…has been shown it

RFC-014 §4.2: moves "are announced like any hint change", and "Until it
applies the move, the tool keeps working where it is." Presence follows
each tools/list as it's read, and what the agent is told at that read
(the view's announcements, look's catalog) follows it; the model's tool
list follows at the framework's next rebuild. Both checks that decide
where a tool is called read presence alone, so in between a tool a list
moved to dynamic was refused by the listed name the tool list still
offered.

Now a call is taken wherever the model has been shown the tool: in the
tool list last published, or in the latest announcement, presence
(ToolPresence.shownAs). A call by its listed name is refused only where
both say dynamic, and toolsets--act's "not a dynamic tool" only where
both say static. The framework answers where its published list put a
tool through one hub dep, published(serverId, tool): offered (static),
listed but not offered (dynamic), or not published. This only widens
what's taken: nothing the base took is refused. A tool newly put in a
closed undeclared group stays callable by its listed name until the
rebuild publishes the move (§4.2's "keeps working where it is").

look naming a tool shows the definition act would check (actKey, act's
own key rule: an instance's own, else the toolset's, else the listed
one) wherever act would take the tool, and the listed one elsewhere, so
a hint's definition that act checks is the one look shows, in the window
too. toolStatus's static line says the tool is "called by name", which
holds where presence says static whether or not the tool list holds it
yet.

The agent is told where a tool went once a rebuild has published the
list that shows it. The rebuild told each server's moves as it listed
that server, before publishing, so with more than one server the note
"peek is in your tool list now" could come while the published list
didn't offer peek yet. Now the moves a rebuild saw as it listed a
server are told after it publishes, those still pending then. A rebuild
that doesn't publish (a newer one has) leaves them pending for one that
does. A move a list has undone since the rebuild listed the server isn't
told, and asks for another rebuild, so the tool list follows the
undoing. The README says where a moved tool is called and when the
agent is told, and the controller's header where it's called.

Tests:
- a tool a list moves to static, with the rebuild held: act and its
  listed name both call it; after the rebuild its name does and act
  refuses it ("called by name, as arc--peek");
- a tool a list moves to dynamic, with the rebuild held: its listed name
  and act both call it; after the rebuild act does and its name is
  refused toward act;
- a static tool the tool list hasn't published is refused through act;
- look names a tool by a hint's definition while act would take it, and
  by the listed one once act wouldn't;
- where no tool list has published a tool, presence decides;
- with a second server whose tools/list the rebuild waits on, a move is
  not told until the rebuild publishes, and a call by name meanwhile
  goes through; then it's told once, and the tool list shows it;
- a rebuild that never publishes (a newer one, started by a third
  server's connect, publishes first) leaves its move to the one that
  publishes, which tells it once;
- a move a list undoes while the rebuild waits on the second server is
  never told, and the tool list follows the undoing.
Mutations, each failing at least one of them: the published list alone
deciding where a tool is called, and presence alone (the first two);
the framework answering for names it never published (the third);
look keying a tool off the view alone, and look showing act's
definition whether or not act would take it (the fourth); the moves
told as each server is listed, as before this change, and taken as
each server is listed (the two waiting on a second server); no rebuild
asked for an undone move, and every listed move told whether or not
it's still pending (the undoing test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
….27.0

0.27.0 folded the fragment that described the RFC-014 host side, so
these commits get fragments of their own, beside the hint-state ones,
saying what is different since that release. One released promise
moves, so it's a .changed entry: a dynamic tool called by its prefixed
name was always refused, and now it's taken while a move waits for the
tool list (RFC-014 §4.2). The rest bring the code to its text and to
RFC-014, so they're .fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e a change and to say what a change shows

From a review of the whole change against what 0.27.0's feature needs:

- A move is no reason to rebuild the model's tool list early. The host's own re-read (connect, recovery) no longer asks the framework for a rebuild when it moved a tool, and a move undone before a rebuild published it no longer asks for another: every move reaches the tool list at the next refresh, as a hinted move already did, and the agent is told where the tool went once that refresh publishes it. Until then the tool is called at either place (the previous commit's rule), so an earlier rebuild would buy only an earlier tool list, at the price of a prompt-cache rewrite for each move; RFC-014 §4.2 points to a natural boundary. ToolsetHostDeps.refreshTools and ToolPresence.movesOwed go.
- One public takeChange, used inside the controller too; the private take and claim, which were the same thing under other names, go.
- render and markShown share one predicate for which changes show a key's tools in a block, so what counts as shown can't drift from what was rendered.
- A tools/list read whose pages straddled a change says so in a field of its own (HintRead's `straddled`), not in its params.
- sendToolsList's read hook gathers pages with a loop rather than spreading each page into one call's arguments, which a very large page could make throw. The hook's guard stays as containment: nothing known throws there now, and a throw would leave the request unsettled.
- toolsets--act's description drops "(or was due as one and none could be written)", host mechanics the model can't act on.

Tests: the two hint tests that waited for the tool list to follow a recovery's move without a further list change now check that it waits, then follows at the next refresh (a tools/list_changed). The window tests that held the rebuild the dropped dependency started are the next commit's to change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ints reads a structured-only result as 0.27.0 did

- ToolsetsModule's takerFor is required. The package exports only `.`,
  whose index exports ToolsetsModule but no ToolsetHub, and nothing it
  exports hands one out, so no caller outside the framework can build
  the module. The default it replaces took every call's result to reach
  the agent, which is 0.27.0's told-once defect, kept for whoever builds
  the module next. The tests that build the module pass a taker, and
  the test of the default goes.
- executeMcplToolCall reads a structured-only result as that content's
  JSON only when it has a tool change to append (taken), not whenever
  the result reaches an agent (taker). The JSON is there so a change
  doesn't stand alone in place of the content; without a change, the
  result reads as 0.27.0 read it, for a server without toolHints too.

Test: a server without toolHints whose tool answers with structured
content alone: the puppet stores the result as 0.27.0 stored it (an
empty content, read at 0a4a2c6). It fails when the JSON view turns on
the taker again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a list_changed

Following a review of the tests against the requirements, for the tests
this branch wrote:
- A move no longer asks for a rebuild of its own (the commit before
  this one), so the tests that hold a rebuild open start it with a
  tools/list_changed after the recovery's list is read: movingServer,
  and arcAndSlow, which holds the second server's list.
- A move a list undoes while the rebuild waits is never told; the
  rebuild publishes what it listed, where the tool is callable both by
  its name and through act, and the next refresh follows the undoing.
- Where the tool list hasn't published a tool, the server's latest list
  decides: a dynamic tool a recovery's list adds is refused by name and
  taken through act while the rebuild is held. This replaces the
  controller-level test of the same rule.
- Refusals, unconfirmed outcomes, look's couldn't-ask line, the §4.2
  refusals and the no-agent log line assert the facts they carry (what
  the server has, unconfirmed, the connection's account, look named,
  the name a tool is called by) rather than whole sentences. A request
  known not sent doesn't name look.
- toolHints off: the advertisement has nothing of RFC-014 and equals
  what a server that doesn't advertise toolHints is offered, rather
  than 0.26.2's literal object, so a capability later offered to every
  server keeps it true.
- A notice starts no turn: the host's status counts no request owed and
  no turn running, in place of the scheduler's private queue.
- A rebuild that never publishes changes nothing a caller sees, so the
  test that lets one end waits on the framework's own flag, a sync
  point, never an assertion, and says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… meets it

From the tests weighed against the requirements:

- Four tests that repeated a caller-level test go: reminders only for a server granted pushEvents, an ended record not told again after the connection is let go, a move a later list reverses, and taking a change not counting its ended record.
- Re-expressed through the scripted server and the agent's window, each with what reaches the agent:
  - a call's change told as a notice before the puppet stores the call isn't stored again with its result; without pushEvents it's dropped, not stored;
  - an ended record two statements report before either is told is told once, and one that expires while its note waits is told once;
  - an instance reopened under an ended instance's name is told closed when it goes;
  - a recovery's tools/list change, and a change a filtered look hands back, are told before a hint read after them in the same chunk;
  - a notice describes a definition it shows in two instances once;
  - a hint restating the presence a recovery's list moved a tool to leaves the move to be told at the next refresh;
  - a definition given in a hint after a separate hinted move is used once the tool list makes the tool dynamic;
  - a tool listed twice is known, and checked by act, by its first listing;
  - a first sentence is cut after a whole character;
  - a group the host opened, which the manifest then declares and drops, is no longer open, so act is refused;
  - a tool described with a non-string description, or a schema nested 20,000 levels as objects or as arrays (sent as text, since JSON.stringify can't write it), is announced, and look answers.
- Kept in process, each saying why in its name: taking a change first telling an earlier one (in an agent's window a notice told during a call is stored after the call's result), a definition described again after an unwritten notice (a server's grant doesn't change while it's connected), a push nobody takes (needs a stopping host), a push keeping its change for its own path (needs that path to run after the next macrotask), and look at a toolset with deep options (a manifest that deep isn't taken by the framework's manifest read).
- The in-process harness keeps only what those tests use.

By run (Linux, Node 24.22.1): the three toolset files pass 125 of 125; typecheck clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… change joins it

executeMcplToolCall chose the JSON view of a structured-only result
whenever it had taken a tool change, before withWholeChange decided
whether the change fits under the call's inline cap. Where it doesn't,
the change goes as a notice, and the result still read as JSON, where
the same result with no change reads as 0.27.0 read it (no content).
The view is there so a change doesn't stand in place of the content;
without the join there's nothing for it to do. Now the view is chosen
only where the change joins the result, and otherwise the result reads
as 0.27.0's: a result reads as 0.27.0's unless something is appended
to it.

Test: a toolHints server answers the puppet's call with structured
content alone, and the change its hints made won't fit after it under
a 1000-character cap: the puppet stores the result as 0.27.0 stored it
(an empty content), and the change is told as a notice. It fails when
the view is kept where the change doesn't join.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… close meets it

The in-process test of close with nothing named, after a group the host opened was declared, goes. The caller-level test that opens an undeclared group, then has the manifest declare and drop it, now also asks toolsets--close with nothing named in the declared state, and checks that it answers that nothing is open on the server.

The test of a notice describing a definition shown in two instances once now waits for the opening look's own notice before counting, and for the hint's notice by the tool's line. It had waited for any notice naming the second instance, which the look's own notice names too. Run alone on a loaded host, it failed 3 of 6 runs, each finding no line for the tool; with the narrower waits it passed 8 of 8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"a close answered after its timeout still closes the instance it named,
and is told" asserted that the server held the close as soon as the
host's 300 ms timeout had rejected it. The scripted server runs on the
same event loop, so on a loaded host the close can reach it after the
timeout, and then that assertion fails. The test failed once in four
runs of the toolsets files, on an assertion that wasn't captured; 98
runs here under load (alone, ten at once, and the whole file six at
once) didn't fail it. Whether the server sees the close before or after the timeout
isn't what the test is about: what it pins is that an answer coming
after the timeout still closes the instance and is told. It now waits
for the close to reach the server, then answers it, which is after the
timeout either way. The timeout stays as it was.

With the server seeing the close 400 ms after it arrives, past the
timeout, the earlier form failed at "the close reached the server" in
3 of 3 runs and this one passed 3 of 3; this one passed 10 of 10 as
written. A hinted request that keeps no toolset, or no instance, still
fails it: the late close is never told.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…plied, not only fetched

The test of a host-opened group that the manifest declares and then drops waited for the server to receive each mcpl/manifest request, then one round trip. Applying a fetched manifest takes further steps (a feature-set update request among them) before the toolset state changes, so under load toolsets--act could run before the dropping manifest was applied, and go through: in 18 runs of the hints file, six at once, the act's "isError" assertion failed once. Holding the server's answer to the dropping manifest back 400 ms made it fail 3 of 3.

Each change now also waits for the tools/list the host asks for once it has applied the manifest, then a round trip. With the same 400 ms hold it passes 3 of 3; as written, 10 of 10 at ten at once, and the hints file 12 of 12, six at once, on a host at load 77. With the group not forgotten when the manifest declares it, the test still fails (2 of 2), at the close that should answer that nothing is open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…estating look counts as shown only what it showed, and an unused seam goes

From a review of the whole change against 0.27.0:

- A schema's literal values (an enum's values, a const, a default) are written through one guarded helper, so a value nested too deeply to write shows as … instead of throwing. Under Node 24 a const, enum value or default nested 20,000 levels threw RangeError from signature rendering, failing the notice or look that rendered it.
- look naming a tool shows a description only when it is a string, and only the string entries of mcpl/class. A description of 42 printed as 42, and an object whose toString isn't callable, as a description or a class entry, threw TypeError.
- sendToolsList's awaiting path gathers pages with a loop, as its read hook does, so a page too large to spread into one call's arguments can't throw after the hook has applied the list.
- A look that restates the present hands its taker each server's change without the change's blocks. look shows the live catalog, which counts what it rendered as shown; the change's blocks weren't shown, so a tool a hint hid while look waited on another server kept its first sentence counted as shown and lost it from the next announcement.
- ToolsetController.takeChangeText goes: no production code called it, and the two in-process tests that did now take the change and render it as the production callers do.

Tests:
- a tool whose schema holds a literal nested 20,000 levels deep (const, enum, default), or whose description and class are objects, is announced and looked at (times out waiting for the announcement without the fix);
- a look that restates the present, with a hint hiding a tool while it waits on a second server, leaves that tool's first sentence owed (fails without the fix: the next announcement lacks it);
- a first sentence that reached the model is left out of the next announcement of the same definition (passes before and after: it pins what the shown set is for);
- the paged-lists test checks the first page through look, where the controller's inventory is met, rather than the framework's own list;
- the deep-options look test asserts the options line;
- each negative verdict after the 200 ms recovery debounce also waits for a round trip, so the server has read whatever the host sent before it.
No test makes a page past V8's argument limit.

By run (Linux, Node 24.22.1): typecheck clean; the three toolset files pass 128 of 128, twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ld publishes, and the order claim says what the window can still show

- README and the changed fragment: a move reaches the tool list at the next rebuild that lists the server, and once it has published, a notice says where the tool went, so the note follows the grant like any notice. A list read while a rebuild is under way can undo a move that rebuild then publishes; neither is told, and the next rebuild puts the tool back.
- README and the fixed fragment: changes are told in the order they were read; the agent's window can still show a call's result, a push's event, or a change its taker couldn't show after a change read later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… tells no moves, and a passed-on call's change fits the store's cap

- look naming a tool keyed it by where the tool is visible, else the
  toolset's definition. act, given no instance, takes the only one open
  and checks that instance's own definition, so for a tool hidden in
  the only open instance, look showed one definition and act checked
  another. look now keys the tool as act does: the instance named, else
  the only one open.
- The rebuild recorded a server's moves before it went through that
  server's listing, so a listing that broke off partway (an entry the
  loop can't read, such as null, which the read tap skips) still had
  its moves told at publication, where the tool list doesn't show
  them. The moves are recorded once the listing is through, and stay
  pending otherwise.
- withWholeChange measured a joined change against the call's own cap,
  and with none it measured nothing, while the store holds the result
  to the agent's cap. A module that passes the model's call on to
  executeToolCall by its id, as a call built afresh, reached that: the
  model's result was no longer the server's content alone. The measure
  now uses the cap the store applies (resultMaxChars, else the
  agent's).

Tests, each failing on its assertion at the commit before this one:
- look naming a tool hidden in the only open instance, whose own
  definition the statement restates, shows that definition, the one
  act checks;
- a rebuild whose listing of a server holds a null entry before a moved
  tool tells none of that server's moves, and the tool list doesn't
  show the tool;
- a module that passes the model's call on to toolsets--act, under a
  1000-character cap, keeps the 990-character result whole, and the
  change goes as a notice.
Each fails again with its fix taken out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"act's results arrive as a direct call's do" asserted only the script's
structuredContent, which a script reads the same whether act reads the
server's result or the model's view of it. The server now answers with
two text blocks beside its structured content, and the script reads
both an act of the dynamic tool and a direct call of a static tool
answered the same way: their content and structured content match,
with each of the server's blocks. Having a script's act read the
model's view instead now fails it; it passed before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ly results, refused acts and the either-place rule do

- The wiring fragment says a selector that is present but isn't a name
  is refused; null reads as left out (a test pins it), and it now says
  so. It also says that a result that is structured content alone shows
  that content, as its JSON, before the change that goes with it, and
  that a refused programmatic toolsets--act is traced as tool:failed,
  as the model's own refused act already was, and that a change that
  can't be told for want of an agent is logged, as one without the
  pushEvents grant already was.
- resolveAct's doc said it refuses a static tool; it refuses a tool the
  model hasn't been shown as dynamic (RFC-014 §4.2). resolveMcplTool's
  doc said dispatch refuses a dynamic tool by its listed name; it does
  unless the published list or the latest announcement shows it as
  static.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ice, schema types read only as text, and four tests say true reasons

From the review of the previous round's fixes:

- A look that restates the present now hands its taker each change whole, marked blocksShown: false. told() then counts none of its definitions shown, since look showed the live catalog, while a notice that tells it (the puppet's fallback when its pair can't be stored) still renders and counts them. The previous round emptied the change's blocks instead, so that fallback told nothing: a puppet look whose pair the store refused lost the change it found.
- typeSig reads a schema's type only from text. A type, or an entry of a type list, that isn't a string reads as no type, where an object whose toString isn't callable threw TypeError from rendering.
- The changed fragment says a move's notice doesn't come when a list read during the rebuild undid the move.

Tests:
- a puppet look that restates the present, whose pair can't be stored, tells the change it found as a notice (fails when the look hands over the change emptied of its blocks);
- the malformed-definition test also gives types that aren't text, and a class list mixing text with an object, and checks look keeps the text entry;
- a definition first described in a notice that wasn't written is described again once the server is granted pushEvents, now through a manifest change mid-connection rather than in process, since a server can produce it (fails when a refused notice counts its definitions shown);
- the deep-options look test, still in process, names its true reason: the suite's scripted server writes its initialize answer with JSON.stringify, which can't write options that deep;
- the paged-lists test gives list A's first page its own status definition, so look's answer shows that page reached the controller, and checks the model's tool list holds the tool too.

By run (Linux, Node 24.22.1): typecheck clean; the three toolset files pass 132 of 132.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Octavia-2412 and others added 7 commits October 26, 2026 03:15
…reached

The rebuild recorded a server's moves only once its listing was
through, so a listing that broke off after a moved tool published that
tool's new place and told nothing; the move stayed pending until a
listing went through, which a server that keeps sending the same
unreadable entry never gives. It now records each move as the listing
reaches the moved tool: a listing that breaks off tells the moves of
the tools it published, and the rest stay pending.

Tests:
- a listing with an unreadable entry (null) after the moved tool
  publishes the tool and tells its move once; this fails when the moves
  are recorded only once the listing is through, as in the commit
  before;
- the test with the entry before the moved tool, which fails when every
  pending move is recorded before the loop, is renamed for its order.
The two tests that wait for a rebuild to end wait for the framework's
flag to read false, so a renamed flag times them out instead of
letting them run on at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e where either place shows it static

The README said a dynamic tool called by its prefixed name is taken
while the tool list still offers it, and the fragment said a moved
tool is called by name while the tool list offers it and through
toolsets--act where the server's latest tools/list put it. Each states
one direction of a move. The rule takes a call by name where the tool
list or the server's latest list shows the tool static, and through
toolsets--act where either shows it dynamic (RFC-014 §4.2), and both
now say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t's order and first-sentence exceptions

- told()'s doc says a taker that showed a change without its blocks (blocksShown: false) leaves its definitions uncounted.
- typeSig's comment says entries of a type list that aren't text are left out, and a type with none reads as no type.
- The fixed fragment adds the two exceptions to its order and first-sentence claims: a change a caller took and then couldn't show is told when the caller finds that out, after anything told meanwhile, and a puppet's open or look whose result the store refuses has already counted the definitions it rendered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e it went

A listing that breaks off partway (an entry it can't read) publishes
what it read, as 0.27.0 did. The commit before told only the moves of
the tools the listing reached, so a tool it never reached that had
moved to dynamic dropped out of the tool list with nothing told: by its
listed name it was refused, and the model wasn't told to use
toolsets--act. Now each pending move is checked against the list a
rebuild publishes: a move to static is told once that list offers the
tool, and a move to dynamic once it doesn't. A move the list doesn't
show yet stays pending for a rebuild that does.

Tests:
- a listing broken off after a moved tool, and one broken off before
  it (as before);
- a move a broken-off listing didn't publish is told, once, by the next
  rebuild, whose listing goes through;
- a move to dynamic of a tool a broken-off listing never read is told,
  once, and act calls the tool.
Each fails under a mutation: every pending move told at publication,
moves to dynamic told only where the listing reached the tool, a move
left pending dropped, and moves to static told whether offered or not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ls/list, and the closed-group gate states its exception

- shownAs, dynamicCallRefusal, resolveAct and resolveMcplTool said a
  call is taken where "the latest announcement" or what the model "was
  shown" puts the tool. What the rule reads is the tool list last
  published and the server's latest tools/list (presence follows each
  tools/list as it's read), and they now say so.
- The README says calls to an undeclared toolset's tools are refused
  while the host has that group closed. A tool a list moves into a
  closed group is still taken by its listed name until the rebuild that
  publishes the move (RFC-014 §4.2), and the README now says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le as it now holds, and RFC-014 §4.2 cited in its own words

- README and the changed fragment: toolsets--look naming a tool shows its signature, description, class and input schema, as the code renders it.
- README: a new tool's first sentence is told with its definition until text describing it has reached the model, as the fixed fragment says; 0.27.0's sentence said only the first time its definition appears.
- README: RFC-014 §4.2 keeps a tool working where it is until the move applies, no longer a paraphrase set out as a quote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One conflict, in src/framework.ts at the head of executeToolCallFrom:
main (anima-research#363) names the parameter `given` and reads it as
`const call = asCalled(given)`; this branch adds the `failed` trace
helper after it. Each side's lines are kept as written, main's first,
so each keeps doing what it does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review

Recommendation: merge and fast-follow. 4 medium severity findings can be fixed after the merge.

Reviewed commit 69873a1 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.

Does this change belong in the project?

It fits the project. This PR fixes the RFC-014 tool-presence host that the project already ships in src/mcpl/ and documents in its README. It changes existing files, adds tests and changelog fragments as CONTRIBUTING asks, and adds no new dependency, module or public concept.

5 reasons with sources
  • It fixes a feature the project already documents and supports: RFC-014 tool presence for servers set to toolHints: true. The base README already describes the toolsets module, how hints are applied, recovery through tools/list then toolsets/list, notices, and the rule that each ended id is told once. The PR's fixes are about these same behaviors. (README.md, "Tool presence and toolsets (RFC-014)")
  • The code stays in the components that already own this work. The hint state stays in the existing toolset controller and tool-presence files. The changes to how call sites use that state are in framework.ts, which already imported ToolsetHub and ToolsetsModule. No new module or second mechanism is added. (src/mcpl/ (toolset-host.ts, tool-presence.ts, toolsets-module.ts, tool-signature.ts) and src/framework.ts)
  • The PR follows the house rules: Problem/Changes body, tests that fail on the old code, changelog fragments and README updates. It adds fragments under changelog.d/ for each category (breaking, changed, fixed), updates the README section that describes this behavior, and adds test/mcpl-toolsets-wiring.test.ts plus extensions to the existing hint tests. (CONTRIBUTING.md, "What a PR should contain" and "Changelog")
  • Its two visible behavior changes come from following the RFC, and both are recorded in the changelog. First, a dynamic tool can still be called by its prefixed name until the move publishes, which the PR bases on RFC-014 §4.2. Second, reminders now require the pushEvents grant, matching the README's existing rule that notices need that grant. The second is filed as a breaking fragment, which is the project's mechanism for this kind of change. (README.md RFC-014 section; changelog.d/mcpl-toolsets-hint-state.breaking.md)
  • The PR is large, but all of it concerns one feature. There are 36 commits: a hint-state fix, a wiring fix and review follow-ups, all about the toolsets host. The project merges with true merge commits and asks only that each commit be coherent. Nothing unrelated is bundled in. (CONTRIBUTING.md, "How changes land")

Findings

4 findings of medium or high severity, see the inline comments:

For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.

Comment thread src/mcpl/toolset-host.ts Outdated
Comment on lines +472 to +474
const read = await this.ownToolList(connection);
// RFC §6.1: toolsets/list after tools/list, so every tool it names is known.
if (connection !== this.connection || !read) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity. A recovery whose tools/list fails gives up: no toolsets/list and no retry.

recover() now returns as soon as ownToolList returns false (if (connection !== this.connection || !read) return;). Recovery starts after a skipped or behind rev, a straddled list, or a hint that names a tool nobody listed. If its tools/list then fails or times out, the host never re-reads toolsets/list, and nothing schedules another recovery unless some other trigger sets recoverAgain during the attempt. Presence and open-instance state then stay as the lost message left them until some later hint happens to trigger a recovery. Before this change the recovery still read toolsets/list, and connected() still does when its tools/list fails, so the two paths now disagree. The recovery should be retried, for example by calling scheduleRecovery when the list fails, or should at least go on to toolsets/list as connected() does.

Comment thread src/mcpl/toolset-host.ts
Comment on lines +227 to +230
if (straddled && !this.recovering) {
this.log('tools/list pages carried different revs, so its earlier pages are older than changes read between them: recovering');
this.scheduleRecovery();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity. A tools/list read during a recovery that straddles a change is applied and never recovered from.

readResult schedules a recovery for a straddled list only when !this.recovering. The recovery's own tools/list is the one most likely to straddle, because it is read right after a lost or out-of-order change. When it does, its older pages are applied as current, no log line is written, and no further recovery is scheduled. scheduleRecovery already guards against loops with recoverAgain, so this guard drops the one case where the rev check matters. The recovery then reads toolsets/list on top of the stale presence.

Comment thread src/framework.ts
Comment on lines +30374 to +30376
const toolsets = forScript || !mcplPeer ? undefined : this.toolsetHub?.get(serverId);
const cs = toolsets?.takeChange(result);
if (toolsets && cs && !isEmptyChange(cs)) taken = { toolsets, cs };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity. The model's dispatch path takes and marks a change told without checking that the agent still awaits the call.

changeTaker attaches a result's change only where the agent is waiting_for_tools with call.id pending; otherwise the change is left to a notice. dispatchMcplToolCall skips that check: for any non-script MCPL peer call it calls takeChange(result), appends the change, and after pushEvent calls told(cs) (line 30521), which marks ended ids told and definitions shown. If the agent's turn was cancelled or the agent was removed while the call was out, the tool-result event may never reach a window. The change, its ended notes and first sentences are then counted as told and never reach the model. That breaks the PR's own rule that a change goes with a result only where the result reaches an agent.

Comment thread src/mcpl/tool-signature.ts Outdated
function canonicalJson(value: unknown, depth = 0): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
if (depth > 64) return '"…"';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity. Depth-capped canonicalJson makes deep definition changes invisible to redefinition detection.

canonicalJson now returns "…" for anything nested more than 64 levels deep, and toolDigest is built from it. ToolPresence.diff decides whether a tool was redefined with toolDigest(was) !== toolDigest(d) (tool-presence.ts line 352), and shown is keyed on the same digest. Two schemas that differ only below depth 64 now get the same digest. The redefinition is therefore not reported to the agent, and the new definition's first sentence is treated as already shown. The cap stops the throw, but a cut-off marker that mixes in a hash of the rest would keep identity without the risk.

Prudence-2217 and others added 10 commits October 26, 2026 04:30
…t, and a literal or look's JSON is cut by nesting depth on every runtime

- A recovery whose tools/list fails goes on to toolsets/list, as 0.27.0's recovery and connect both do. Stopping there left the open instances staler than 0.27.0 did, and RFC-014 §6.1's order (toolsets/list after tools/list, so every tool it names is known) buys nothing once the list has failed. A recovery's own toolsets/list starts no other recovery, so this can't loop.
- A schema's literal value (an enum's, a const or a default) and the JSON look shows are written out only when they nest at most 64 levels below the value, the bound definition identity already uses; past it a literal reads as … and look's JSON as "(nested too deeply to show)". The cut used to wait for JSON.stringify to overflow the stack, which happens at different depths on different Node versions: Node 24 throws on a literal 20,000 levels deep, and the newest Node in CI wrote it out in full into the model's tool text.

Tests (requirements: a failed recovery list still leaves open instances read, tools/list first; a value is cut at one stated depth whatever the runtime):
- "a recovery whose tools/list fails still reads toolsets/list, as connecting does", turned round from the test that pinned the stop; it fails at the round's base.
- "a literal is written out up to 65 levels deep and cut to … past that, whatever the runtime's stack, and look shows a schema past the bound as too deep": fails at the round's base on Node 24, which writes the 66-level literal out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lt is handed back; the unreadable-result test runs where the runtime can't write its nesting

- The fixed fragment and the README said a result's tool change goes with
  it only where the result reaches an agent, naming the model's own call.
  A turn stopped while the call is out drops the result when it comes back
  (the tool-result handler drops a result for an agent that isn't waiting
  for tools, or is gone), and the change that went with it is dropped
  untold, as in 0.27.0. Both texts now say the model's own call counts as
  reaching the agent once its result is handed back, and name that case.
  changeTaker's and executeMcplToolCall's docs say where they take a
  change: a result stored in an agent's window (the puppet's) or handed
  to an agent awaiting it.
  Run at this round's base and at 0.27.0 (0a4a2c6): a model's call the
  server holds, abortInference while it's out, then an answer whose hints
  show a new tool. At both, the agent goes from waiting_for_tools to idle,
  the result is dropped (tool:result_dropped), no notice is written, and
  toolsets--look shows the tool.
- "a model's call whose result the host can't read still tells the tool
  change it carried, as a notice" made its result unreadable with
  structured content 100,000 levels deep. JSON.stringify throws on that
  nesting on Node 24.22.1 and writes it out on Node 26.12.1, where the
  test passed through the inline cap instead. It now runs only where the
  runtime throws on the nesting, and says why it's skipped elsewhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ting outside its try

The probe deciding whether "a model's call whose result the host can't
read…" runs caught a throw from JSON.parse as well as from
JSON.stringify. A runtime whose parse threw on that nesting would have
run the test under another failure, the host unable to parse the
message rather than to read the result. Parsing outside the try makes
such a runtime fail when the file loads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pins that nothing more is read after it

- recover()'s doc and its RFC-014 §6.1 comment say every tool the statement names is known unless tools/list failed: a tool the last list lacks then waits for a later recovery.
- The failed-list recovery test, after toolsets/list, waits past the 200 ms recovery debounce and a round trip, then asserts the server read exactly tools/list and toolsets/list, so it also pins that a failed list starts no further recovery. With a recovery scheduled again on the failed list, it fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ames the first sentence's one exception, and two tests pin what they promised

- jsonText cuts by nesting depth as before, and JSON.stringify can still throw where the pretty-printed value passes the runtime's string length, which nothing bounds a message's size against; look then says "(too long to show)" rather than failing.
- The README's first-sentence rule names its exception, as the fixed fragment does: a puppet's open or look whose result the store refuses has already counted the definition shown.
- The failed-list recovery test also asserts that what the recovery's toolsets/list says reaches the agent as a notice; with the recovery's delivery taken out, it times out waiting for it.
- The literal-bound test pins look's JSON bound at its edge: a schema whose deepest object sits 64 levels below it is shown, one at 65 is too deep to show.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…JSON where a change joins it; texts name a server's last dynamic tool and say a dropped call's change counts as told

- Through the puppet, a server's error returned before the JSON view
  of a structured-only result was built, so an error whose only words
  were its structured content was stored as the generic "without
  saying what went wrong" text, with the tool change after it. Where a
  change joins such an error, its text is now the content's JSON, as
  for a success and as the model's own dispatch already reads an
  error. A server without toolHints keeps 0.27.0's reading.
- The README: a server with no toolsets stops presenting them once its
  latest tools/list has no dynamic tool, so until the rebuild, act
  takes its last one only with `server` named.
- The wiring fragment said a dropped call's change went "untold"; it
  counts as told when the result is handed back, and isn't told again.

Tests:
- "the puppet stores a server's error that is structured content alone
  as that content's JSON, then the tool change" fails at 6e7fe42 on
  its own assertion, and at 0.27.0 (0a4a2c6).
- "a server without toolHints keeps 0.27.0's reading of a
  structured-only error" passes at 0.27.0, and fails when the error
  view is built whether or not a change joins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…id to withstand malformed definitions rather than all, and recover() says what starts the next recovery

- README and the fixed fragment: a puppet's open, close or look whose result the store refuses has already counted the definitions it rendered; close's answer marks what it told as open's does.
- The fixed fragment: a malformed definition no longer makes rendering throw. "Never throws" claimed more: a value past the runtime's string length, or a notice for more tools at once than a call takes arguments, can still throw.
- recover()'s doc: with tools/list failed, a tool the last list lacks stays unknown until a later change or list starts a recovery; nothing in recover() starts one.
- The literal-bound test's title also names look's JSON bound, which the test pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tent is null, reads as 0.27.0 read it, and one whose change won't fit keeps its JSON

The commit before showed a structured-only error's content as its JSON
through the puppet where a tool change joins it. Three edges of that:
- JSON.stringify ran unguarded inside the call's try, so content nested
  past what the runtime can write (Node 24) turned the stored result into
  "Maximum call stack size exceeded" and sent the change as a notice. It
  is now caught, and the error reads as 0.27.0 read it: the server
  reported an error without saying what went wrong, then the change. A
  success's JSON view keeps the runtime's error, since its fallback would
  be an empty success.
- A null structuredContent was written as the error text "null". It now
  reads as an error with no words, as 0.27.0 read it.
- When the change didn't fit under the inline cap, the stored error fell
  back to the no-words text and lost the content. It now keeps the
  content's JSON, and the change goes as a notice.

Texts:
- The README says look re-reads each server that presents toolsets; a
  server named that no longer presents them isn't re-read.
- mcplToolFailure's doc and directToolResult's comment no longer say the
  model's dispatch reads a server's error through mcplToolFailure: only
  the in-process path does, and the model's dispatch writes a
  structured-only error as its JSON.

Tests, each failing at the commit before on its own assertion:
- "the puppet stores a server's error whose structured content the host
  can't write as 0.27.0 read it, then the tool change" (runs where the
  runtime's JSON.stringify throws on the nesting; skipped on Node 26);
- "the puppet stores a structured-only error's JSON when its tool change
  won't fit under the cap, and the change goes as a notice";
- "the puppet reads a server's error whose structured content is null as
  one that said nothing, then the tool change".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…patch reads an error the same way

For a structured-only error with no change taken, the in-process path
gives the no-words text where the model's dispatch writes the content's
JSON, so "as the model's dispatch reads it" was false. The clause is cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- README: a server with no toolsets presenting none once its latest tools/list has no dynamic tool, so act takes its last one only with `server` named until the rebuild, is by reading of the code.
- README and the wiring fragment: a model's call whose turn is stopped while it is out has its result, and the change with it, dropped, as at 0.27.0; a run at 0.27.0 and one at 03d1fe1, with pushEvents granted and not, show it. The same when the agent is gone rests on reading alone, and says so.
- README and the hint-state fragment: a puppet's open, close or look whose result the store refuses has already counted the definitions it rendered, by reading of the code. The test of a refused look pins only that its change is told.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru merged commit 4976c67 into anima-research:main Oct 26, 2026
12 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 26, 2026
main brought anima-research#813, anima-research#697, anima-research#366, anima-research#353 and anima-research#624 (a nested mount's directory
compared as disk compares names). Where anima-research#624 met this change:

- effects.ts and observe.ts: main's judgement of a nested mount's place
  (holder.names, judgedByInclude, an awaited nestedAtReal), with this
  change's LEAVES_MOUNT for a link that leaves the mount.
- index.ts: main's imports from observe.ts with this change's DIRECTORY,
  LEAVES_MOUNT, NOT_FOLLOWED and NOT_REGULAR. NestedMountError keeps main's
  constructor and its "may lie in" words, and stays a Refusal, so the
  tools lead it as they do every refusal.
- test/workspace-path-spelling.test.ts: anima-research#624's tests that pin a tool's
  refusal now pin it led ("The read did nothing: ...", "The write did
  nothing: ..."), a link's reason as a sentence, and a followed link out of
  its mount in readRefusal's words; main's refusedAsInner takes the place
  of two of this change's literal assertions. The tests that run only where
  disk folds case, or folds a sharp s with ss, were edited by reading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.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.

1 participant