Repository navigation
fix(mcpl): the toolsets host follows the server's lists and tells each change once - #813
Conversation
…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>
…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>
There was a problem hiding this comment.
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 eachendedid 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
pushEventsgrant, 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:
- Medium severity. A recovery whose tools/list fails gives up: no toolsets/list and no retry.
- Medium severity. A tools/list read during a recovery that straddles a change is applied and never recovered from.
- Medium severity. The model's dispatch path takes and marks a change told without checking that the agent still awaits the call.
- Medium severity. Depth-capped canonicalJson makes deep definition changes invisible to redefinition detection.
For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.
| 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; |
There was a problem hiding this comment.
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.
| 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(); | ||
| } |
There was a problem hiding this comment.
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.
| const toolsets = forScript || !mcplPeer ? undefined : this.toolsetHub?.get(serverId); | ||
| const cs = toolsets?.takeChange(result); | ||
| if (toolsets && cs && !isEmptyChange(cs)) taken = { toolsets, cs }; |
There was a problem hiding this comment.
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.
| 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 '"…"'; |
There was a problem hiding this comment.
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.
…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>
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>
This fixes the RFC-014 host side released in 0.27.0 (#799), which applies to servers configured with
toolHints: true.Problem
With
toolHintson, 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.tools/listwas 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").toolsets/listentry naming a tooltools/listhadn'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 differentmcpl/revs ("a tools/list whose pages carry different revs starts a recovery, …").endedrecord whose notice wasn't written was lost ("an ended record is told once it reaches the agent: …").toolsets--look("a tool a server describes malformed …").pushEventsgrant;toolHintsserver's connect let it dial the server, and a stop failed it with the module's own refusal;toolHintsoff, left toolset state behind;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
tools/liststates, 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).tools/listreaches the controller whole, as its last page is read, before anything read after it. A list whose pages carry different numericmcpl/revs straddled a change, and the host recovers from it.pushEventsgrant, a result's change that a notice can't carry is dropped, the framework logs the refused notice, andtoolsets--lookshows the state. A push keeps its change for its own path.endedid is told once. Its note travels with it, and the id is forgotten once the server stops reporting it (§5.3).…, 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
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 latesttools/listshows the tool static, andtoolsets--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.toolsets--look, and look, which re-reads each server that presents toolsets, says when it couldn't.toolHintsoff, forgets its toolset state. A stop or a disconnect while atoolHintsserver is still connecting ends the connect.toolHints,toolsetsandsurfacealone, and operator tool-name patterns naming a dynamic tool count as matched.Five changelog fragments say what changed since 0.27.0, by what a caller meets. One is breaking: a host that set
toolHintswithout granting the serverpushEventsnow 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--looknaming 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.tools/liststates it. Until the rebuild, the tool is called at either place.pushEvents(the breaking entry).endedid is told once" becomes "once while the host runs", and rare interleavings can tell one twice.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'stest/mcpl-toolsets-allowed-tools.test.tsamong them.8f71c3bb.mcpl-tool-hints,mcpl-toolsets-e2eandmcpl-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.8f71c3bbthe 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 whatJSON.stringifycan write skipped, since Node 26 writes it out).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
mainate7e2391f(a78a4ba8). One conflict, at the head ofexecuteToolCallFrominsrc/framework.ts:main(#804, for #363) reads the call throughasCalled, and this PR adds a trace helper after it; both are kept,main's lines first.Not verified
toolHintsserver. Every test uses the suite's scripted servers.tools/listread hook's guard, which nothing known reaches; it stays as containment, since a throw there would leave the request unsettled;pushEventsgrant, 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").tools/listhas no dynamic tool, so until the rebuild, act takes its last one only withservernamed.tools/listfails, a tool itstoolsets/listnames that the last list lacks stays unknown until a later change or list starts a recovery.JSON.stringifycan write reads as 0.27.0 read it.03d1fe13(withpushEventsgranted and not);toolsets--lookshows the state it left. The same holds when the agent is gone, by reading alone.endedrecords 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.What each part is for
Hint state
ChangeSet.ended,markToldanduntoldNotesmark an ended id only when its note reaches an agent, and render it only while untold.adoptforgets an id once the server stops reporting it.endedKeysis built from the ends that will be noted.ApplyOutcome.unlistedand the recovery it starts: a nametools/listhasn't listed starts a recovery. A recovery whosetools/listfails still readstoolsets/list, as 0.27.0's did.RequestOptions.onReadandsendToolsList's read hook, with its pages, revs and guard: a whole list, applied as its last page is read, markedstraddled(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_METHODSwithouttools/list, and the controller'sstraddledread.open-list guard inreadToolsetList.unclaimed(ordered, with each carrier's method),tellUnclaimed,deliverandnotice(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),toldandtell: the seam a caller uses to show a change and say when it reached an agent.refreshOpenreturns the change it took, and a look that restates the present hands its taker each change markedblocksShown: false:toldthen 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.changeTextgoes, 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.canNotifyandnotify's boolean onToolsetHostDeps; the push path'sfinally.pendingMovesandapplyMoves, withsetTools'seenpresences: the moves owed to the next rebuild.listedPresence: where the server's latest list put a tool, the half of the §4.2 rule thatshownAsreads besides the published list.render'smarkoption and its overlay of the shown set (Shown),toolBlock'sseen,markShown, andshowsBlock, the one rule bothrenderandmarkShownuse for which changes show a key's tools: a first sentence owed until it reaches the model.firstSentencetaking any value and cutting by code points; the depth bounds intypeSigandcanonicalJson, andtypeSigreading a type only from text;literal, which writes a schema's enum values, const and default,…where they're nested too deeply to write, andjsonTextfor look, both bounded bynestsWithinat the depthcanonicalJsonuses rather than by a stack overflow, which comes at different depths on different Node versions, andjsonText'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.groups.deleteinsetManifest;defKeyFor's string return.tappedController, which feeds a controller reads in process;onList;present, a list that moves a tool;chunk,rawLineandholdingServer, 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;roundTripandnextMacrotask, waits for an effect;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 ownstoredToolResultTextat the call's cap, the change compared as the store makes it well-formed.changeTakerandpuppetChanges(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 astakerFor, which it requires.executeMcplToolCall, withwithChange'salone(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'sJSON.stringifycan write, reads as 0.27.0's.taken(framework.ts): the change between its take and the hand-off, so the dispatch's failure can tell it.ToolsetController's{ shown }onopen,closeandrefreshOpen, andchangeOf: an unshown call leaves the change for its notice and still gets it to show its caller.render'sdescribeAllandcatalog'smark(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.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.requestFailureandwithLookHint(toolsets-module.ts): the not-sent/unconfirmed rule and the look hint in one place each;withLookHintserves 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_PRESENTSandnoToolset(toolsets-module.ts): the refusals' texts, used at two and three sites.release(toolset-host.ts): lets go of whatever connection a controller holds, forforget.field,failure(toolset-host.ts): the controller's log redaction, used by its log lines.runDisconnectMcplServer'skeepToolsets(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): replacesmodelFacingToolNames, whose only caller is the pattern check.UNMASKABLE_KEYSgains RFC-014's three keys (capability-mask.ts).published(ToolsetHostDeps; framework.ts answers it) andshownAs(tool-presence.ts): the §4.2 rule in one place, read bydynamicCallRefusalandresolveAct.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'smovesListed(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'soptionsandhintingServer'speek(test helpers): let a test enable code execution, set agents, class overrides or the inline cap, or give the dynamic tool another definition.withPresence,movingServerandarcAndSlow(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,nextMacrotaskandstoredResult(the wiring test file):peek;nestedPastStringify,stringifyOverflowsand 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'sJSON.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.tsline 690, throughAgent.onCompiledToSend). A definition's first sentence is said once per server (ToolPresence.shown,src/mcpl/tool-presence.tsline 128).game:g1, then an ephemeral run with its own store and window openedgame:g2of the same toolset. The ephemeral's window held no description ofplay. With nothing opened first, its window held it.seenandduelive in memory (toolset-host.tslines 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
messageIdwithcomplete, and a summary as a source of kindsummary(CompileProvenancein@animalabs/context-manager0.22.0, read bynoteRenderedInjections,src/agent.tsline 1375). We propose marking the row that carries each announcement, the notice or the tool result it follows, with the open toolsets it announced.That replaces the marker scan,
seen,dueandshown, keeps nothing in memory across restarts, and keeps the hook inside the framework rather than inAgent's public API. Is that the shape you'd want for reminders and first sentences?2. The host's argument check,
checkArgscheckArgs(src/mcpl/tool-signature.tsline 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, whilecheckArgsrefusesargs.x-trace: not an argument of this tool.{type: "array", prefixItems: [{type: "string"}, {type: "number"}], items: {type: "number"}}with["a", 1]: ajv valid, whilecheckArgsrefusesargs[0]: must be number.{anyOf: [{type: "object", required: ["point"]}, {type: "string"}]}with5: both refuse, andcheckArgssaysargs: must be object, naming only the first form.Read:
@animalabs/mcpl-core0.4.0's validator fillsdefaultbefore it checksrequired(dist/src/toolsets/schema.jslines 98–104). So a call that leaves out a required key with a default runs onToolsetServerand is refused here.anyOf/oneOfwalk their first alternative again.items,properties,additionalPropertiesand 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 owntoolArgsProblems(src/tool-args.tsline 164) checks required keys and, withvalues, 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 orToolsetServer's?3. Prose routing for a send made through
toolsets--actProse routing decides by a call's name whether it silences its round's prose (
silenceCauseOf,src/framework.ts). A call through act has the nameact, so a send that is a dynamic tool, called the only way a dynamic tool can be called, doesn't silence the round.ToolsetServerwithtoolHints: trueofferingsay, and an agent in locus mode (the default). A round held the prose "Ordinary field speech." and asaycall; the next round's prose was "turn over".saystatic, called by name: the server'ssayran once, and both prose segments were held as drafts.sayin an opened toolset, called throughtoolsets--act: the server'ssayran once, and prose routing also published both segments to the locus, so the agent's words went out twice.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
code-execution-isolate-runnerfailure onmainthat feat(mcpl): RFC-014 tool presence and toolsets, host side (opt-in toolHints) #799's description names? On Linux with Node 24.22.1 it passed 88 of 88, both at feat(mcpl): RFC-014 tool presence and toolsets, host side (opt-in toolHints) #799's merge base3b9c7dd0and at its headb9a7bc83. Thehost-quiescecrash test it names failed on a loaded host when its own 60-second deadline cut short a run that finishes correctly, until test: end the quiesce crash child on its exit, not on the clock #800 removed that deadline.invite: truea toolset notice writes into its metadata (src/framework.tsline 27712) meant for a reader outside agent-framework? Nothing in agent-framework reads it.changelog.d/mcpl-toolsets-hint-state.breaking.md,mcpl-toolsets-hint-state.changed.md,mcpl-toolsets-hint-state.fixed.md,mcpl-toolsets-wiring.changed.mdandmcpl-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-seruaccount.