Skip to content

feat(mcpl): RFC-014 tool presence and toolsets, host side (opt-in toolHints) - #799

Merged
antra-tess merged 4 commits into
mainfrom
feat/mcpl-toolsets-host
Oct 25, 2026
Merged

antra-tess merged 4 commits into
mainfrom
feat/mcpl-toolsets-host

Conversation

@antra-tess

@antra-tess antra-tess commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

What

The host side of MCPL RFC-014 (tool presence, toolsets and surfaces), revision 6, opt-in per server with toolHints: true (default false).

RFC-015 (delegated access) is deliberately not included: no server here needs to act as the agent yet, so it's parked. Nothing in this PR touches access/token, the access advertisement or the access.token grant path.

Off by default, unchanged when off

Without toolHints: true nothing changes: the server is offered the very McplHostCapabilities object every server gets, every tool is listed as before, no toolsets/* request is sent, tools/hints is still an unknown method (-32601), a push's hints are not read, and calls carry no _meta. test/mcpl-tool-hints.test.ts ("toolHints off") checks each of these.

With toolHints: true

  • Advertisement: experimental.mcpl.toolHints: true to that server only; its hints count only if it advertised toolHints too.
  • Tool list: static tools stay in the API tool list exactly as before. Dynamic tools (toolset tools, mcpl/presence: "dynamic") are filtered out in refreshMcplTools, so visibility changes never touch the list (the prompt cache). A dynamic tool called by its prefixed name is refused, pointing at toolsets--act.
  • Meta-tools: a toolsets module, registered by the first toolHints server, with four constant tools: toolsets--open {server?, toolset?, instance?, options?}, toolsets--act {server?, tool, instance?, args?}, toolsets--look {server?, toolset?, instance?, tool?}, toolsets--close {server?, toolset?, instance?}. server can be left out when the name is unambiguous. act is resolved by the module (resolveAct) and dispatched by the framework down the same path as a direct call of the server's tool: dispatchMcplToolCall for the model's and scripts' calls, executeMcplToolCall for a host's or module's. That covers allowedTools, tool policy, the state exchange, RFC-005 result reading and RFC-007 lifecycle, with only _meta["mcpl/instance"] added. The new ToolLifecycleEmitter.retarget describes the call as that tool before it opens, so observers see it and its class. Before sending, act refuses calls into an undeclared group the host has closed (§5.4). It also checks args against the instance's current definition: its own from hints, else the toolset's, else the listed one. That check is bounded: it never runs a server's pattern, and it leaves a schema deeper than 24 levels or bigger than 5,000 steps to the server, which checks every call itself.
  • Hint tracking (src/mcpl/tool-presence.ts, src/mcpl/toolset-host.ts): changes apply in rev order as each message is read, whatever becomes of it. McplServerConnection gains a read tap (setReadTap) that sees every hinted result (including one that arrives after its timeout: the request is remembered past its deadline), every push/event before the plane gate or grant decides on it, and every tools/hints. Before this, a late response was dropped unseen, so the tap was needed for vector 28. Revs are consecutive; a skip or a step back schedules recovery: tools/list, then toolsets/list. toolsets/list is a full statement (per-instance visibility and definitions; a dynamic tool not stated visible is invisible), read after every (re)connect once the policy has settled. What the server reports open is adopted, and nothing is re-opened. From a hint definition only description, inputSchema and annotations are taken. ended ids are told once, across reconnects and restarts. A duplicate eventId's hints are ignored, and so are a push's hints when it also carries coalesce (§7); that case triggers a recovery. A manifest change that removes a toolset closes its instances (receipt impact surface-changed); withdrawing toolHints makes every tool static.
  • Announcements (§8.3): compact signatures, with a tool's first sentence the first time its definition is shown. A result that goes to the agent carries its call's own change. A change nobody shows with its result by the next macrotask is told as a notice: the framework's own calls (reactions, sends, accounted side effects), and a host's or module's calls. A push carries its change after its content, and a refused push's change becomes a notice. A standalone tools/hints, a late result, an adoption or a recovery is a notice (kind: 'mcpl-toolsets', system: true, wakes nothing, gated on pushEvents like any delivery). Suggestions are shown, never acted on.
  • Reminders (§8.3), and the hook they needed: Agent.onCompiledToSend, a new optional callback called with the messages of every compile built to send, never a dry run. The framework sets it per agent. After each such compile the hub reads which open toolsets' markers ([server/toolset:instance]) the request carries. A marker the agent has had in view that a later request lacks (compressed, or out of the window) makes a reminder due. The toolsets module's gatherContext then returns it as an afterUser injection (namespace mcpl-toolsets), so feat(context): store each afterUser context injection where compile() placed it, so it stays; a module's queryMessages and addMessage take forAgent #782's stored-injection path stores it in the window. Like stored injections themselves, this costs one request: the compile that dropped the announcement is already being sent, and the reminder lands at the next start.

Not done

Projection (§8.4) and surface mode are out of scope: a surface server's static tools are listed like any server's. Streamable-HTTP early-rev holding (§6.1, vector 37) isn't needed: the legacy engine speaks stdio and WebSocket only, which deliver in order. Static↔dynamic moves (§4.2) apply at the next tool-list refresh, which is a list boundary, with a notice; vectors 15/24 aren't in this PR's test list.

Tests

  • test/mcpl-tool-hints.test.ts: a scripted WebSocket server. Covers "toolHints off = unchanged" and vectors 3, 4, 10, 11, 12, 13, 21, 22, 23, 27, 28, 30, 33, 34 and 38, plus suggestions and reminders (the reminder is checked on the compiled request itself).
  • test/mcpl-toolsets-e2e.test.ts: the reference ToolsetServer from @animalabs/mcpl-core@0.4.0, a new exact devDependency, served in-process. Covers open, act, look and close, instance routing, the argument check, a result-carried hide and close, adoption after a reconnect (vector 9), and toolHints off with the server's compat tools (vectors 26 and 36).
  • Review fixes add tests for: act and a direct call taking one dispatch path (state exchange, lifecycle observers, _meta, the change shown once), a change in a result the agent doesn't see told as a notice, close refusing an instance, and the bounded argument check. The vector 9 test now counts the toolsets/open requests the server received.
  • Full suite: on origin/main (3b9c7dd), 7544 tests with 2 failures: code-execution-isolate-runner ("a crash's detail has the stderr that comes after its process exits") and host-quiesce ("a REAL process exit after a 2,101-message resume batch…"). On this branch (babcb4b), 7574 tests with 2 failures. One is the same host-quiesce test. The other is an explicit-prose-routing subtest that timed out under load ("Timed out waiting for the next turn to settle"); it passes 3/3 on its own and touches no toolsets code. No new failures.
  • Smoke-tested by hand against games-mcpl's Go toolset (dev issuer, run locally). Its static tools are listed and the Go tools aren't. An instance opens with options. act plays, the instance is picked automatically when only one is open, and the bot replies. A move that doesn't fit the definition's pattern is refused before it's sent. look and close report as expected.

🤖 Generated with Claude Code

…lHints)

A server configured with `toolHints: true` is offered
`experimental.mcpl.toolHints`; its hints count only if it advertised
`toolHints` too. Off (the default), nothing changes.

- Static tools stay in the API tool list; dynamic tools never enter it.
- A `toolsets` module with four constant meta-tools: toolsets--open,
  toolsets--act (instance in `_meta["mcpl/instance"]`, args checked
  against the instance's current definition, closed undeclared groups
  gated), toolsets--look and toolsets--close.
- Hint changes apply in rev order as each message is read, through a new
  read tap on McplServerConnection that also sees results after their
  timeout and pushes before admission. A skipped rev recovers through
  tools/list, then toolsets/list, which is also read after every
  (re)connect. Per-instance visibility and definitions; ended ids told
  once; duplicate eventIds ignored; manifest removals close instances.
- Changes are announced in the call's result, after the push that
  explains them, or as `mcpl-toolsets` notices; suggestions are shown,
  never acted on.
- Reminders: Agent.onCompiledToSend lets the host see which open
  toolsets' announcements a request carries; when one has left an agent's
  context, the module's gatherContext stores a reminder at its next start.

RFC-015 (delegated access) is deliberately not included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
antra-tess added a commit to anima-research/connectome-host that referenced this pull request Oct 25, 2026
…ork shim

agent-framework's RFC-014 branch (anima-research/agent-framework#799)
declares McplServerConfig.toolHints as boolean, which is what the recipe
and mcpl-servers.json fields already hold. The host hands agent-framework
each server's config untyped, so it needs nothing of agent-framework's type
for the field to pass through, and the shim goes.

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review

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

Reviewed commit b9a7bc8 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 change fits. It implements another MCPL RFC on the host side, in src/mcpl/, which is what the project already does for RFC-005, RFC-006 and RFC-007.

6 reasons with sources

It is opt-in per server and leaves servers without toolHints unchanged.

  • Host-side MCPL protocol work is the stated purpose of the project's MCPL component, and RFC extensions already ship there. The README describes an optional host-side implementation of MCP Live and documents earlier RFCs (RFC-006 coalescing, RFC-007 patterns) as per-server features. RFC-014 tool presence and toolsets is the same kind of addition, and it gets its own README subsection in the same place. (README.md, "MCPL (MCP Live)" and "Event coalescing (RFC-006)")
  • New code sits in src/mcpl/ beside the existing MCPL host modules, and the framework wires it in the same way. The new files are tool-presence.ts, tool-signature.ts, toolset-host.ts and toolsets-module.ts. They match the existing pattern of one concern per file under src/mcpl. The meta-tools are a module that uses the existing gatherContext and stored-injection path rather than a new mechanism. (src/mcpl/ layout (tool-policy.ts, tool-lifecycle.ts, push-coalescer.ts, …))
  • It is off by default and leaves servers without the flag unchanged, consistent with how other MCPL options are added. toolHints defaults to false. The README rule that MCPL-only settings are refused on modern servers is extended to cover toolHints: true, which matches how the existing settings are treated. (README.md, "Modern MCP servers" config rules)
  • The new dependency is dev-only, but the change adds one public hook to Agent. @animalabs/mcpl-core is pinned as a devDependency for the end-to-end tests, so it costs nothing at runtime. Agent.onCompiledToSend is a new optional public callback that maintainers will have to keep. It is small and documented, and the reminder feature needs it. (package.json; src/agent.ts)
  • toolsets--act reaches servers through a module rather than the usual MCPL tool-dispatch path, so this part does not yet follow the existing dispatch design. The code review found that act skips the dispatch path's state exchange and its RFC-005 result handling. That is a defect for the code review to settle, but it means this part should not be counted as following the established design until it uses that path. (README.md, MCPL tool names and dispatch)
  • The PR has the body sections, changelog fragment, numeric test baseline and tests that CONTRIBUTING asks for, and it declares what it leaves out. It adds changelog.d/mcpl-toolsets-host.added.md and reports test counts against main with failures unchanged. It lists projection, surface mode and RFC-015 as out of scope, so the change stays one coherent feature. (CONTRIBUTING.md, "What a PR should contain" and "Changelog")

Findings

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

7 more findings, less severe
  • Low severity, test coverage, src/framework.ts:29981-29995. No test covers refusing a dynamic tool on the model's dispatch path. The refusal of a dynamic tool called by its prefixed name is tested only through executeToolCall (executeMcplToolCall, e2e 'a dynamic tool called by a namespaced name is refused'). The parallel check added in dispatchMcplToolCall has its own trace and pushEvent of a refused tool-result, and no test covers it. That is the path a model's tool_use takes. A scripted membrane response calling arc--play should be checked for the refusal result.
  • Low severity, test coverage, src/mcpl/toolset-host.ts:176-182. No test for a push carrying both hints and coalesce. The description says that a push's hints are ignored when it also carries coalesce (§7), and that this triggers a recovery. Neither test file sends a push with both. A test should check that the hints don't apply and that tools/list then toolsets/list are read.
  • Low severity, test coverage, src/mcpl/toolset-host.ts:494-514. No test for a manifest change that withdraws toolHints. manifestChanged has a separate branch for a server that withdraws toolHints: it deactivates presence, adds a note and is meant to put every tool back in the list through handleToolsListChanged. Vector 33 only covers removing a toolset. No test checks that the dynamic tools then appear as static prefix--tool entries and that toolsets--act refuses them. The branch where toolHints is newly advertised by a manifest change (it only logs) is not tested either.
  • Low severity, maintainability, src/mcpl/toolset-host.ts:549-552. ToolsetHub.forget is never called, so controllers outlive their servers. forget(serverId) is documented as 'A server's configuration went (removed): forget its controller', but nothing calls it. runDisconnectMcplServer deletes mcplServerConfigs and prefixes but leaves the controller. A later server under the same id inherits the old controller's toldEnded and shown sets, and hub.get(id) returns it even when the new config has no toolHints. Either call forget on disconnect or remove the method.
  • Low severity, maintainability, src/framework.ts:27565. Notice metadata invite is written but nothing reads it. recordToolsetNotice adds invite: true when a suggestion is to open, and casts the object to MessageMetadata to get it past the type. A search of src finds no reader of .invite. Since the README says these notices wake nothing, the field is dead data that suggests behaviour that does not exist. Remove it or wire it to something.
  • Low severity, documentation, test/mcpl-tool-hints.test.ts:1-5. Test header claims vector 36 is covered in this file. The header lists vector 36 among those this file covers, but no test here exercises it; vector 36 is in mcpl-toolsets-e2e.test.ts ('every tool is static there … (36)'). The header also says vectors 9 and 26 are in the e2e file, which is correct, so 36 is the outlier.
  • Low severity, documentation, test/mcpl-toolsets-e2e.test.ts:10. E2E header claims vector 38 is covered, but this file has no such test. The header lists 'toolsets/list with nothing visible clears what the host believed (38)'. None of the it blocks in this file checks that; the empty-statement case is in mcpl-tool-hints.test.ts ('vectors 23, 38'). The header should match what this file tests.

Comment thread src/mcpl/tool-signature.ts Outdated
Comment on lines +199 to +203
if (typeof s.pattern === 'string') {
let re: RegExp | null = null;
try { re = new RegExp(s.pattern, 'u'); } catch { /* a pattern this runtime can't compile isn't checked */ }
if (re && !re.test(value)) return `${at}: does not match ${s.pattern}`;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity. Host-side argument check runs a server-supplied regex on the event loop with no bound.

checkArgs compiles any pattern from the tool's inputSchema with new RegExp(s.pattern, 'u') and runs re.test(value) synchronously in the host process. The schema comes from the server (tools/list or a hint definition override), and the value comes from the model. A pattern with catastrophic backtracking, such as ^(a+)+$, plus a long non-matching argument blocks the framework's event loop for every agent and server. Before this change the host never evaluated a server's schema; the server did its own checking. Either skip pattern (the comment already allows unchecked keywords), or cap the input length and pattern complexity.

Comment thread src/mcpl/toolsets-module.ts Outdated
Comment on lines +298 to +302
const r = await c.call(tool, callArgs, instance);
const change = p.render(r.change);
const blocks = [...asBlocks(r.content), ...(change ? [{ type: 'text', text: change }] : [])];
if (!blocks.length) blocks.push({ type: 'text', text: r.isError ? `${tool} failed without saying why` : `${tool} done.` });
return blocksResult(blocks, r.isError);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity. toolsets--act skips the MCPL dispatch path's state exchange and RFC-005 result handling.

act sends the call through ToolsetController.call → connection.sendToolsCall(tool, args, undefined, meta) and turns the raw content blocks into a ToolResult itself (asBlocks/blocksResult with dataIsContent). The ordinary MCPL dispatch (dispatchMcplToolCall, framework.ts ~30140-30200) does more. It sends stateful feature-set state/checkpoint (checkpointManager) and reads the returned state. It registers and treats RFC-005 references (autoFetchReferences/mcpToolResult) and normalizes the result. None of that happens for a dynamic tool called through act. So a dynamic tool in a stateful feature set never gets its checkpoint, and its returned state is dropped. An MCPL peer's reference blocks reach the model untreated. The description only mentions that lifecycle observers see act calls as module calls; the other gaps are not mentioned.

Comment thread src/mcpl/toolset-host.ts
Comment on lines +241 to +248
if (awaited) {
this.readChanges.set(res, cs);
} else {
// RFC §6.1: a result nobody waits for any more (it timed out) applies
// all the same, and its change is told on its own.
this.log(`late ${method} result read: its hint changes applied`);
this.deliver(cs);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity. Hint changes on results of the framework's own tools/call requests are applied but never told.

For an awaited result, readResult only stores the ChangeSet in readChanges, keyed by the result object, and expects whoever handles that result to call changeText. Only executeMcplToolCall and dispatchMcplToolCall do that, through withToolsetChange. The framework sends other tools/call requests to the same server: server.sendToolsCall('add_reaction'…) at framework.ts:10035, sendToolsCall('send_message'…) at 10221, and sendToolsCallWithDeadline at 27292. Their results are tapped as awaited, so their hints change presence but are never rendered, and no notice is sent either. Tools can appear or disappear with no announcement to the model. Those callers should get a notice fallback, or the awaited flag should mean 'someone will render it'.

Comment on lines +395 to +401
if (p.isDeclared(toolset!)) {
if (p.declared[toolset!]!.instances && !instance) {
const open = p.openRecords(toolset);
if (open.length !== 1) fail(open.length ? `several ${toolset} instances are open (${open.map((o) => o.instance).join(', ')}): say which` : `${toolset} is not open`);
instance = open[0]!.instance;
}
let closed: Awaited<ReturnType<ToolsetController['close']>>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity. toolsets--close sends an instance for a toolset that has no instances.

open() refuses an instance for a declared toolset without instances (has no instances: open it without one), but close() does not check this. With toolsets--close {toolset:'build', instance:'x'} on a toolset without instances, the instance is passed to c.close(toolset, instance), sent to the server in toolsets/close, and used to build keyOf(toolset!, instance) for the fallback 'closed' label, giving a wrong [srv/build:x] marker. noteClosed ignores the instance, so the host's state and the label disagree. The same validation open() does should apply here.

Comment on lines +146 to +158
it('after a reconnect, an instance the server kept is adopted, not opened again (vector 9)', async () => {
const opensBefore = calls.length;
srv.dropAll();
await waitFor(() => (fw.framework.listMcplServers().find((s) => s.id === 'arcade')?.connected ?? false) === false, 'the drop seen', 5000).catch(() => {});
await waitFor(() => fw.framework.listMcplServers().find((s) => s.id === 'arcade')?.connected === true, 'reconnected');
// Its toolsets/list is read after the policy settles.
await new Promise((r) => setTimeout(r, 400));
const look = resultText(await callTool(fw.framework, 'toolsets--look', { server: 'arcade' }));
assert.match(look, /\[arcade\/game:g1\]/, 'g1 adopted');
assert.equal(calls.length, opensBefore);
const act = await callTool(fw.framework, 'toolsets--act', { tool: 'resign', instance: 'g1', args: {} });
assert.ok(!act.isError, resultText(act));
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity, test coverage. Vector 9 test cannot detect a re-open after reconnect.

The test asserts calls.length === opensBefore to show that nothing was opened again. But calls is the log array of arcadeSpec, which only records tool calls inside an instance (${instance}:${tool}:…). The toolset's open() callback never writes to it. If the host sent toolsets/open again after reconnecting, this assertion would still pass. The test should count toolsets/open requests the server received, or log inside open().

antra-tess and others added 3 commits October 25, 2026 14:40
The check toolsets--act makes against a dynamic tool's current definition
read the server's schema with no bound: it compiled and ran the schema's
`pattern` on the event loop (a backtracking pattern could stall every agent
and server), and recursed and fanned out (anyOf, enum) without limit.

`pattern` is no longer evaluated; the server checks every call itself. A
schema nested deeper than 24 levels, or whose check would visit more than
5,000 nodes and enum entries, isn't checked by the host at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…old; close and vector 9

- toolsets--act no longer calls the server itself: the framework resolves
  it (resolveAct) and dispatches the call of the server's tool down the
  same path as a direct call (dispatchMcplToolCall, or executeMcplToolCall
  for a host's or module's call): the agent's allowedTools, the server's
  tool policy, the state exchange, RFC-005's result reading and RFC-007's
  lifecycle, with only `_meta["mcpl/instance"]` added. The lifecycle's new
  retarget() describes the call as that tool before it opens, so observers
  see it and its class; refreshMcplTools keeps dynamic tools' server, class
  and feature set (they still never enter the tool list).
- A result's tool change is told by whoever shows that result to the
  agent; one nobody takes by the next macrotask (the framework's own calls:
  a reaction, a send, an accounted side effect; a host's or module's call)
  is told as a notice, as a standalone tools/hints is.
- toolsets--close refuses an instance for a toolset without instances, as
  toolsets--open does, and sends nothing.
- The vector 9 test counts the toolsets/open requests the server receives
  across the reconnect.

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

Unit tests build the framework with Object.create, so field initializers
don't run; the act marks are created on first use and read optionally, as
the framework's other lazily made state is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antra-tess
antra-tess merged commit 8aaee1c into main Oct 25, 2026
12 checks passed
nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 26, 2026
Three conflicts, each resolved as the union of both sides:

- HOST_METADATA_KEYS: main adds STORED_INJECTION_KEY and
  PRIMARY_IF_UNREAD_KEY beside this branch's 'callerTurnId'; the list
  keeps all three.
- addModuleMessage: main marks a write for an ephemeral run or a
  conversation fork (PRIMARY_IF_UNREAD_KEY, forAgent, markedForRun); this
  branch drops a module's own callerTurnId key. The write now drops the key
  first, then adds main's mark to what's left.
- test/ingress-host-keys.test.ts: the fixture of keys only the host writes
  holds main's two new keys and this branch's callerTurnId.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 26, 2026
A dynamic tool called through `toolsets--act` remains subject to the agent's `allowedTools` in model, `code_execution` script and ephemeral-agent calls. A local scripted MCPL peer and mock inference check a permitted control, a caller-visible refusal and that the peer executes only the permitted control.

The three cases fail at #799's earlier head and pass at merged main. This commit adds tests only and covers one visible standalone dynamic tool.

Co-Authored-By: GPT-6 Sol <noreply@openai.com>
nissa-seru pushed a commit that referenced this pull request Oct 26, 2026
The host's per-server `enabledTools` and `disabledTools` settings control whether a dynamic tool is announced, appears in `toolsets--look` and can be called through `toolsets--act`. The tests compare permitted and denied settings, with a permitted dynamic control and model and programmatic calls.

These policy cases pass at both #799's earlier head and merged main. They pin an existing guarantee. This commit adds tests only.

Co-Authored-By: GPT-6 Sol <noreply@openai.com>
nissa-seru pushed a commit that referenced this pull request Oct 26, 2026
…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 #799's puppet took no change, and the
lone surrogate, since #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>
nissa-seru pushed a commit that referenced this pull request Oct 26, 2026
#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 #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>
nissa-seru pushed a commit that referenced this pull request Oct 26, 2026
…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 #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 #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>
nissa-seru pushed a commit that referenced this pull request Oct 26, 2026
…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 #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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant