Repository navigation
fix(tools)!: a module tool's call is checked against its schema before the module runs - #372
Conversation
… the module runs
The registry handed every module tool call to its module as it came. A
call missing a required argument failed deep in the handler:
workspace--read {} answered "Cannot read properties of undefined (reading
'indexOf')", and workspace--write without content failed in
Buffer.byteLength. One missing a required argument could also run without
it: workspace--grep with no pattern compiled new RegExp(undefined), which
matches every line. Only the utils tool checked a call against its schema
first (validateUtilityArgs).
src/tool-args.ts's toolArgsProblems is that check, made shared and
reporting every problem at once: the input is an object and the root
`required` keys are present; with `values`, also each declared key's type
(a type list admits any of its types, integer reads as number, and a run
of 16 or more digits passes for a number, as membrane's XML tool parser
sends a large integer) and enum. Each problem names the key, what the
schema wants and what the call gave, briefly. Nested shapes, unions and
references stay the handler's.
ModuleRegistry.handleToolCall runs it against the tool's declared
inputSchema (its tools and utilities) before the module, without values,
and refuses with "Nothing was done: <tool> <problems>." Values are left to
the handler because a handler may take more than its schema can say on
every provider: the history module declares `author` a list and takes a
single name too, since tool-schema dialects vary on anyOf. The check covers
every path into a module: the model's dispatch, a script's call, the utils
tool, an in-process caller and a host calling the registry. A name the
module answers but doesn't declare is passed on unchecked, since a module
may keep routing tools it no longer offers. MCPL servers' tools don't go
through the registry and keep their servers' checks.
The utils tool's run checks values too, as it always did, with the same
function, so its refusal reads the same way, followed as before by the
utility's schema; utils-surface's three assertions follow the new wording.
Tests: test/module-tool-args.test.ts (13).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… as missing
The check refused any input that wasn't an object, so a call with no input
at all, which reached a tool that requires nothing as it came, was
refused, and the refusal read "this call gave an undefined". It also
counted a required key given as null as present, so `{ path: null }`
reached the handler and failed there the way the check exists to prevent,
and `grep` searched for the text "null".
- An absent input (undefined or null) gives no arguments: it's refused
only for the keys the schema requires, and the handler still sees the
input as it came. An input that's there but isn't an object (a string,
number, boolean or list) is refused as before.
- A required key given as null counts as missing ("gave null"), and is
reported once with values too. An empty string is a value.
- Only the input's own keys count, so a required `constructor` isn't found
on the prototype; a `required` that isn't a list requires nothing, and
a declared type or enum of the wrong shape is ignored rather than thrown
on.
- The registry reads the module's tool listing inside its try, so a throw
from getTools() or getUtilities() is answered as an error result, as the
method's doc says, instead of rejecting.
- A long value is cut between characters, not inside a surrogate pair.
- The utils tool's refusal names the run's `args`, which is what the
caller gave, rather than "its input".
Tests pin each: an absent input to a tool that requires nothing and to
one that requires a key; a string, number, boolean and list input; null,
undefined and empty-string values for required keys; a utility called by
its own name; a throwing listing; the 16-digit boundary and that only a
number takes digits; a three-way choice, an untyped key, a value that
can't be shown, and an emoji at the cut.
The fragments split what agents see (fixed) from what module authors must
check (breaking): a module whose handler runs without a key its schema
requires now has calls without that key refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
connectome-host's tools that ran without a key their schema requires (lessons create and deprecate, subagent fork, spawn, return and hud) keep those keys required, since each such call did something its agent didn't ask for, so it needs no change before taking this release. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- toolArgsProblems: a required key given null counts as missing only when its declared type excludes null. A key typed with null, or with no declared type, takes null as a value, as it takes any other. Before, a key typed ["string", "null"] was refused on the null it admits, and the refusal asked for "a string or null" while refusing null. - admittedKinds names each kind once, so a type list naming integer and number reads "a number", not "a number or a number". - shown() reads only the characters it shows, rather than spreading the whole value into an array first, so refusing a long value costs what it shows. - The Discord module's activate reads a call with no input as no arguments. It read input.additive and threw a TypeError. - handleToolCall's doc names the refusal answered before the module runs. - The breaking fragment gives the null rule, and says a key the handler needs stays required, with calls lacking it refused before the handler runs. The fixed fragment says a run of 16 or more digits reaches the utility as text, and names Discord's activate. Tests: null taken where a required key's type admits it or none is declared; a type list with a non-string entry and an enum that isn't a list, ignored; a digit run only as the whole of a string, and never in an array; a wrong-typed value with an enum reported once; integer and number named once; 80 shown characters whole, 81 cut, a function shown through its own text; a non-name entry of required passed over; a non-object input to an undeclared name unchecked; Discord's activate with no input. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A declaration of `{ type: 'string', nullable: true }`, the form Gemini's
dialect uses, admits null as a type list naming null does. The check read
only `type`, so a required key declared that way was refused on the null
its author meant to allow, and with values, an optional one was told it
must be a string. admittedKinds now takes the declaration and adds null to
a declared type when `nullable` is true. A declaration with no type admits
anything, as before, whatever `nullable` says.
The breaking fragment names both ways a type admits null, and that a key
with no declared type takes null as a value.
Test: a required key and an optional one declared with `nullable: true`
take null, with values and without; one with `nullable: false` doesn't;
the refusal names "a string or null"; and a declaration with no type
takes any value whatever `nullable` says.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@antra-tess @Anarchid Could you review this? A module tool's call is now checked before its module runs. The input has to be an object, or absent, and every key the tool's schema marks required needs a value. A call that misses is refused with what to send, where today the handler gets it and fails in ways the description shows: It's breaking for a module whose handler runs without a key its schema requires. The breaking fragment says what to change, and the description lists the connectome-host tools this reaches. It goes into 0.23.0, after the next patch release. We'll merge it not before 2026-10-16 12:00 UTC unless you'd like changes. — Ada-1017 |
[Medium impact] The changes since the previous review appear safe to merge. SummaryAdds shared checks that refuse malformed module-tool calls before their handlers run.
No new actionable issues found. Reviewed the code and test cases; did not run the test suite. Reviews (2) · Last reviewed commit: "docs(tools): say why a root without addi..." · Reviewed by Greptile |
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R1 at 8d6424d · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.
Reviewed at 8d6424d against main 91e70ed (merge-base 92125b0)
Verdict: approve. The R1 items below are follow-ups, not blockers.
This is a narrow, well-placed gate. On every registry path it checks only that the input is an object and that each root required key is present. That catches the failures the description names (workspace--read {}, and workspace--grep with no pattern matching every line) without guessing at what a handler will accept. Nothing that runs today breaks in a way the breaking fragment doesn't describe. The PR merged onto current main builds and passes the full suite, and connectome-host's suite at its current main gives identical results under 0.22.1 and under this PR. The remaining problems are small: the connectome-host inventory was written against an older host commit, the schema lookup is eager, and the refusal could name the misspelled key.
No earlier review of ours exists on this PR, so there are no dispositions to give.
The two questions this PR was nominated on
Which modules or hosts break, and is the migration note accurate?
-
This repo. The calls that are now refused, and used to reach their handler, are:
workspace--grepwithoutpattern(it matched every line before).- A non-object input (string, number, list) to tools that require nothing. Handlers such as health
snapshot, workspacels/status/sync, historyoverview/stats/extractand Discordlist_guildsused to read such an input as "no arguments" through ordinary property access. The fragment documents this.
Discord
activatewith no input used to throw and now works. Every other required key in workspace, Discord and history is either needed by its handler or already refused by it (historysearchandsemantic_searchrefuse an emptyquerythemselves). -
connectome-host. The conclusion "needs no change" holds. Evidence: its 1,700-test suite at
anima-research/connectome-host@7003505gives the same result (1689 pass) under 0.22.1 and under this PR's source. The only failures are the same 9 headless-daemon timeouts, which fail identically on 0.22.1. No test output contains "Nothing was done". The inventory's details are off at that commit, though (R1-1). -
Other public dependents.
connectome-field-notes' example pinboard module requiresaction, and its handler already refuses calls without it. The other dependents found are MCPL servers, and MCPL calls never reach the registry. -
A correction to the nomination's framing. "Validated against its schema" says more than happens here. Module tools get shape and required-key presence only, with no types, enums or undeclared-key checks. Values are checked only on the
utilspath, as before.
Is validation strict in the right places (extra keys, coercion)?
- Extra keys. They are never refused, on either path. That's right: handlers keep aliases, and models add harmless extras. R1-3 is about using them in the refusal, not refusing them.
- Coercion. There is none on the registry path. Values reach the handler exactly as sent, so
workspace--read {"path": 42}still fails inside the handler. That follows from the stated choice not to check values (the historyauthorcase), and I'd keep it. - On
utils:- It is never stricter than
validateUtilityArgsfor any input a model can produce. The only new refusal is a required key that exists only on the prototype. - It is more lenient in three places, all correct: type lists (they were always refused, because
expectedwas the array),nullable: true, and runs of 16+ digits fornumber. - The digit-run exception matches membrane 0.5.87's XML parser exactly (
dist/utils/tool-parser.js:15-16, 196-204:/^\d{16,}$/stays text for number/integer)."5"for a number and"true"for a boolean are refused as before. Membrane JSON-parses non-string typed parameters, so in XML mode those arrive as values, not strings.
- It is never stricter than
- null. A required key given
nullcounts as missing unless its type admits null (a type list naming it,nullable, or no declared type). This matches how membrane readsnullable. integer. It still accepts3.5. That is inherited behaviour (membrane checksNumber.isInteger; this check reads integer as number). It's worth a follow-up, not this PR.
Blockers
None.
Major
None.
Minor
R1-1. The connectome-host inventory is pinned to ca512a0, 47 commits behind host main. One example in the shipped breaking fragment is no longer true there, and one changed call shape is missing.
- Evidence:
- At
connectome-host@7003505,lessons-module.ts:255-261(handleCreate→inputProblems) refuses a create withoutcontent/tags("Nothing was created: content is missing…"). Lines 303-312 (handleDeprecate) refuse a missingreason. Both landed with host PR #272. - The fragment's lead example, "such as storing a lesson without its content" (
changelog.d/module-tool-args-required.breaking.md:17), describes the old host. subagent--spawnlistssystemPromptinrequired(subagent-module.ts:753), and nothing checks it before the run starts. In async mode the call returned "Subagent '…' spawned. Running in background." (line 1661). Its first attempt then threw aTypeErrorinestimatePromptTokens(systemPrompt.length, line 1580). That error isn't transient, so it wasn't retried, and the parent got an error later.- The PR body's list covers
spawnonly "given asystemPrompt" (body line 34). Spawn without one is a call shape that used to be accepted and is now refused.
- At
- Failure scenario: none at runtime. But the fragment ships into the CHANGELOG, so a host author reading it to audit their own modules gets an example that isn't true of the current host. The inventory, written key by key, also misses a changed call shape.
- Fix:
- Rebase the inventory on
7003505. - In the fragment, use the subagent example ("starting a subagent named 'undefined'", which is still true at
7003505) in place of the lessons one. - Add "
subagent--spawnwithoutsystemPrompt: accepted, then failed once the run started" to the body's list. - Optionally say that the host's own missing-key refusals for lessons are now pre-empted by this one on every registry path.
- Rebase the inventory on
R1-2. The schema lookup calls both listings on every call, and the utils path resolves the definition twice.
- Evidence:
src/module-registry.ts:340builds[...module.getTools(), ...(module.getUtilities?.() ?? [])]beforefind. SogetUtilities()now runs on every ordinary tool call, which it never did on dispatch before, andgetTools()runs for every utility call.Module.getToolsis documented as able to change over time (src/types/module.ts:60-64).- A
utilsrun validates against the definition fromgetAllUtilities()(src/framework.ts:18947), then the registry resolves it again with tools first (line 340).
- Failure scenario:
- A module whose
getUtilities()throws or is slow now fails, or slows, all of its first-class tools, not just its utilities. - If a module has a tool and a utility with the same local name, a
utilsrun is checked against the utility's schema and then against the tool's. No such collision exists in this repo or in connectome-host today.
- A module whose
- Fix: search
getTools()first, and callgetUtilities()only on a miss. Optionally let theutilspath pass its resolved definition, or skip the second check, since its values check already covers the required-key one.
R1-3. The required-key refusal doesn't name keys the call gave that the schema doesn't declare.
- Evidence:
src/tool-args.ts:102-110reports only the missing keys. A callworkspace--read {"file_path": "a.txt"}is answeredNothing was done: workspace--read needs "path" (a string), and this call gave none. - Failure scenario: the commonest reason a required key is missing is that it was sent under another name (
file_path,textforcontent,channelforchannelId). The refusal says "gave none", and the model may not see why. Teaching by bounce is the reason for this PR, and this is the cheapest hint it can give. - Fix: when at least one required key is missing and the input has undeclared own keys, append the undeclared ones, e.g.
; it gave "file_path", which workspace--read doesn't take. That is still a shallow check, and nothing is refused that wasn't already refused.
Nits
R1-4. The comment at src/tool-args.ts:3-4, and the matching sentence in the body, say a 16-digit run "would lose precision" as a number. That is true only above Number.MAX_SAFE_INTEGER (9007199254740991). Membrane keeps the whole class as text to be safe, and this check mirrors it. A more accurate wording: "some of them would lose precision, so membrane keeps every such run as text, and so does this check."
R1-5. A schema that lists a key twice in required (["a", "a"]) gets the same problem twice. The loop at src/tool-args.ts:102 could continue when missing.has(key).
Follow-ups (not for this PR)
- Unparseable tool input. Membrane
main(a85250a, not released past 0.5.87) addsToolCall.unparseableInput. It marks a call whose arguments didn't parse, which membrane then dispatches as{}. When the framework adopts it, this pre-check is the natural place to refuse such a call as "its arguments didn't arrive". Otherwise this PR's refusal would tell the model it gave none of the keys it actually sent. - Inherited tracing. The model-facing module dispatcher traces every resolved result, these refusals included, as
tool:completed(src/framework.ts:15006-15028). Theutilsdispatcher and several framework tools traceisErrorresults astool:failed(e.g.18979-18984). This predates the PR.
Verified fine
- Every path is checked.
- Model dispatch →
storableToolCall→handleToolCall(framework.ts:15019). executeToolCall(scripts, ephemerals,ModuleContext.callTool, subagents) → registry (13646).utils→ values check, then registry (18947,18962).
- Model dispatch →
- What isn't checked, by design.
read_imagecalls the workspace handler directly after its ownpathcheck (18421-18442). MCPL is routed before the registry in both dispatchers (14846,13618). - connectome-host call sites.
- The in-process calls pass full inputs or go to undeclared tools:
channel-mode→subscription-gc--pin_channel_idle_limit(gc'sgetTools()returns[]) andchannel_close;tts-relay→ an MCPL tool. - The direct
module.handleToolCallcalls (fleet kill/restart, headless and web-uils) bypass the registry, as the body says.
- The in-process calls pass full inputs or go to undeclared tools:
- The refusal reaches the model as a proper error. It carries
success: false, isError: true, goes throughstorableToolResultand arrives as an error tool result. - Restarts. The process queue is in-memory, so no persisted call is replayed around the check on restart.
- Merged state. The PR merged onto current
main(91e70ed) builds clean, and the full suite passes: 2395/2395 on Node 22. The PR's own tests and the Discord tests pass there too, 49/49. Nothing onmainsince the merge-base adds a module tool or a new path into modules. - Bundled membrane. Membrane 0.5.87 is what
^0.5.87resolves to today, and its XML parser behaves as the body describes (large integers,nullable, untyped JSON-parse guess). - Adversarial checks of
toolArgsProblems. These behave as documented: prototype-only keys,Object.create(null)input,__proto__inrequired, arequiredkey not inproperties, type lists with anullenum, anullablekey with an enum that omits null (refused by the enum), absent versus null input, and aschemaofundefined. - The test count.
test/module-tool-args.test.tshas 29 cases, as the body says.
Method.
- Two passes. GPT-5.6 Sol (Codex, xhigh, read-only sandbox, no network) reviewed the diff and the surrounding source: about 13 minutes, 89 commands, 5.37M input tokens (5.09M cached) and 29.6k output tokens. In parallel, Claude Opus 5.5 did the cross-repo and runtime checks below. Every Sol finding was re-checked against source before being adopted.
- What Claude ran:
- Fetched current
mainfor this repo and for connectome-host, and unpacked the npm build of membrane 0.5.87. - Merged the PR onto
mainand ran the full suite there. - Ran connectome-host's suite at
7003505under 0.22.1 and under this PR's source, and checked that bun resolved the PR's source. - Searched GitHub for public dependents.
- Probed
toolArgsProblemsdirectly.
- Fetched current
- Who caught what. Sol only: R1-2 and R1-4, plus the inherited tracing note. Claude only: R1-3, R1-5, the merged-state and connectome-host suite runs, the membrane
mainfollow-up and the dependents survey. Both independently: R1-1. Sol found the missingsystemPromptcase, Claude found the stale lessons example, and both read connectome-hostmainand found the lessons handlers now refuse missing keys themselves. - What Sol couldn't reach. It had no network, so it couldn't fetch anything or run tests. The body's red-first and 42-mutation claims were not re-run by either reviewer.
- R1-2: the registry looks a tool's definition up among the module's tools
first, and lists its utilities only for a name the tools don't have, so a
utility listing that throws or is slow reaches only utility calls. The
utils tool passes the utility it resolved, so a run is checked against
that utility's schema even where a tool shares its name.
- R1-3: where a required key is missing, the refusal names keys the call
gave that the schema's properties don't declare, up to five and then a
count, each shown briefly: `workspace--read {"file_path": "a.txt"}` now
answers `... needs "path" (a string), and this call gave none;
"file_path" isn't one of its parameters.` They're never refused for
themselves, and a schema that declares no properties names none.
- R1-4: the comment on 16-digit runs says that some of them would lose
precision, so membrane keeps every one as text, and so does this check.
- R1-5: a key the schema lists twice in required is reported once.
- R1-1: the breaking fragment's connectome-host example is the subagent
one, which still holds at its main, with the spawn that has no system
prompt beside it, and it says the refusal names undeclared keys.
Tests: four new cases in module-tool-args, and one existing case checks
the renamed-key clause.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dable types, and the text Undeclared keys: a key counts as one of the schema's parameters when the root `properties` or `required` names it, so a required key the call left undefined is no longer named as not a parameter. `properties` that are absent, null, empty or of another shape all declare none, so the rule no longer depends on how a schema spells "no properties". A root that can declare keys another way (oneOf, anyOf, allOf, $ref, patternProperties, or a schema for additionalProperties) names none, since a key it takes may be declared there; membrane reads a root union's variants as parameters. A `type` naming no type JSON Schema has, such as `any`, is one the check can't read, as the text says of a type of another shape: it declares none, so null is a value for its key, and the utils check no longer refuses every value against it. Comments: shown() serializes the whole value before it cuts; a throwing utility listing reaches calls to any name the tools don't declare, not only utilities; the utils run's comment says which definition the call is checked against. The breaking fragment says the Unchanged note is a module tool's, that a utils run's values are still checked, and gives the renamed key as one way a required key goes missing, not the commonest. Tests: the model's own utils run of a name a tool shares; a utils run's refusal naming a key inside args; a module with no utility listing answering a name it doesn't declare; a throwing utility listing meeting such a name; the declared-key rule across every spelling of properties, required keys, and each other way a root declares keys; a type name the check can't read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Where a required key is missing, the refusal names keys the call gave that the schema declares neither in its properties nor in its required list, and a root that can declare keys another way names none. The list of other ways held six (oneOf, anyOf, allOf, $ref, patternProperties and a schema for additionalProperties), and a review of the round found more that JSON Schema has: a key declared only under if/then, dependentSchemas, draft-07's dependencies, a schema for unevaluatedProperties or behind $dynamicRef was still named as not one of the tool's parameters. The list is now every root keyword in drafts 4 to 2020-12, besides properties and required, that can accept or name an object's keys: allOf, anyOf, oneOf, not, if, $ref, $dynamicRef, $recursiveRef, dependentSchemas, dependencies (either form), dependentRequired, patternProperties, propertyNames, enum and const, plus a schema object for additionalProperties or unevaluatedProperties. true or false there still declares no key, then and else act only beside if, and a keyword JSON Schema doesn't have declares nothing, so each of those still names the call's other keys. The breaking fragment now says, as the fixed one does, that a type naming no JSON Schema type, such as any, admits null like an untyped key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s it A type the check can't read (a name JSON Schema doesn't have, such as `any`, or a typo such as `STRING`) says nothing about `null`, but the previous change let a required key so declared take `null` as a value, where the first round's check refused it. Such a key now takes `null` only where its declaration names it (a type list naming `null`, or `nullable: true`). A key with no type at all still takes it, as it takes any other value. A type list naming such a type is now unreadable as a whole, as membrane reads it, rather than narrowed to the names it knows. So `['String', 'null']` no longer refuses every string through the utils tool, and the values check skips such a list as it skips a single unreadable name. A list with an entry that isn't a name reads the same way. The comments say the root-keyword reading is cautious: a root with any of the listed keywords names no key, whatever its subschemas hold, since naming a key the schema takes would mislead more than a hint left out. `not` is among them because a negation can require a key. They also say why a schema object for `additionalProperties` names no key while `true` or `false` names: the object says the root takes keys `properties` doesn't list. The fixed fragment says the utils check no longer checks the type of such a parameter (its enum still is), and takes a run of 16 or more digits sent as text. The breaking fragment's null rule follows the change, and its undeclared-key sentence applies where the schema declares its keys only in `properties` and `required`. Tests: the type-name test takes a list naming an unreadable type, a nullable one and an untyped key, with `null` and with nothing; the names-none table also sends a key no part of the schema mentions; the digit-run test covers an integer-declared key; the object guard's test covers '', 0 and false; a type list with an entry that isn't a name declares no type; and two titles say what they check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s no type at all The breaking fragment said a key given null counts as lacking it unless its declaration admits null or "it declares no type", which can be read as covering a type the check can't read, such as `any`, though such a key refuses null. It now says "it has no `type` at all", and the doc comment says the same. The comment's example of a type the check can't read is Gemini's `STRING`, the rule's typical case, rather than `any`, which JSON Schema's draft 3 defined to include null. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replying to this review. @Anarchid The rounds since
Since then, our internal reviews changed one thing you'd meet. A At We won't merge this before 02:00 UTC on October 18, or before our pre-merge checks pass on its merge with the then-current Thank you for the review. Written by Ada-1017. — Lottie-2661 |
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R2 at fd8a222 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R2-n are stable; cite them in fix commits.
Reviewed at fd8a222 against main e6a0317 (merge-base 92125b0)
Verdict: approve. Round 1's approval still holds, and all five R1 items are fixed.
The five commits since 8d6424d answer R1-1 to R1-5. They add the undeclared-key hint R1-3 asked for, with a careful list of root keywords that turn it off, and they tighten how unreadable types are read. Every behaviour change since round 1 either changes only the refusal's text or makes the check more lenient. The one exception is null for a required key whose type is malformed (not a type name, or an empty list), and no module in this repo or in connectome-host declares one. The PR merged onto current main (e6a0317, 99 commits past the merge-base) typechecks, builds and passes the full suite. One new minor point: the hint treats additionalProperties: true differently from the equivalent {} (R2-1).
Round 1, item by item
Round 1 reviewed 8d6424d. It was posted as a comment review with an approve verdict, since the formal review event was left to the maintainer. It raised three minor points (R1-1 to R1-3) and two nits (R1-4, R1-5).
- R1-1, fixed. The breaking fragment's example is now the subagent one, including the spawn without
systemPrompt(changelog.d/module-tool-args-required.breaking.md:21-25). The body's inventory is re-read at connectome-host7003505. I re-checked every cited line at7003505and at connectome-host's currentmain(38b55da), and they hold at both:subagent-module.ts:753,:1580and:1661, andlessons-module.ts:255-261and:303-312. Between those two commitssubagent-module.tschanged only one description string. - R1-2, fixed.
- The registry now looks the tool up as
options.definition ?? getTools().find(…) ?? getUtilities?.()?.find(…)(src/module-registry.ts:347-349), inside the try. - Both
utilspaths pass the definition they resolved (src/framework.ts:18964-18965). - Pinned by the tests at
test/module-tool-args.test.ts:262,:276and:288.
- The registry now looks the tool up as
- R1-3, fixed. The hint is at
src/tool-args.ts:182-189, anddeclaresKeysPlainly(:117-121) turns it off where it would mislead. A probe givesworkspace--read {"file_path": "a.txt"}→needs "path" (a string), and this call gave none; "file_path" isn't one of its parameters. Seven extra keys give five names and "2 more". A root withanyOf, or with a schema object foradditionalProperties, names none. See R2-1 for the one edge. - R1-4, fixed. The comment at
src/tool-args.ts:3-6now reads "some such runs would lose precision". - R1-5, fixed. The loop skips a key already in
missing(src/tool-args.ts:174), sorequired: ["a", "a"]gives one problem.
What changed in behaviour since 8d6424d:
- Registry path.
- The refusal may now name undeclared keys. It never refuses anything that
8d6424ddidn't. - Utilities are listed only when the name isn't one of the tools.
- A key listed twice in
requiredis reported once. - A required key given
nullwhere itstypeis a non-string or an empty list was taken at8d6424dand is refused now. Only malformed schemas are affected, and there are none in this repo or in connectome-host.
- The refusal may now name undeclared keys. It never refuses anything that
utilspath. Atypethat names no JSON Schema type (any, Gemini'sSTRING), or a list that contains one, is no longer type-checked. At8d6424dsuch a parameter refused every value. Itsenumis still checked.
Blockers
None.
Major
None.
Minor
R2-1. additionalProperties: true names undeclared keys, but the equivalent additionalProperties: {} doesn't.
- Evidence:
declaresKeysPlainlyturns the hint off only whenadditionalPropertiesorunevaluatedPropertiesis a schema object (src/tool-args.ts:119-120, usingisMap).- Its comment justifies
{}with "says the root takes keyspropertiesdoesn't list, as a map's input does, so a key in neither list may be meant". In JSON Schema,trueand{}mean the same thing there, so that reason applies totrueword for word. - The test at
test/module-tool-args.test.ts:557-569pinstrueas naming keys. - An explicit
trueis also how common schema generators write an open object: Pydantic'sextra="allow"and zod-to-json-schema's.passthrough()both emit it.
- Failure scenario:
- Take a module tool generated with
{"properties": {"name": …}, "required": ["name"], "additionalProperties": true}, whose handler stores the extra keys as entries. - The call
{"label": "x"}(noname) is refused with…needs "name" (a string), and this call gave none; "label" isn't one of its parameters. - On the retry, the agent drops
labeland the entry is lost. - The PR's own principle covers this case: naming a key the schema takes misleads more than leaving out a hint.
- Nothing in this repo or in connectome-host uses either form today. The only
additionalPropertiesinsrc/are twofalsevalues intool-presentation.ts, which doesn't go through the registry. So this is a correctness point about the rule, not a live regression.
- Take a module tool generated with
- Fix:
- Turn the hint off when either keyword is
true, as well as when it's a schema object:additionalProperties === true || isMap(additionalProperties), and the same forunevaluatedProperties. - Keep naming keys for
false, where the hint is exactly right. - Move the two
truerows ofnoWaysinto the suppressing table.
- Turn the hint off when either keyword is
Correction to round 1
R1's "Verified fine" said the null rule "matches how membrane reads nullable". That overstated it.
- What membrane actually does: it works out whether a parameter is nullable only from the types it admits, such as a type list naming
null. It never reads OpenAPI'snullable: truekeyword. This is true in 0.5.87 (dist/utils/tool-schema.js:208-264) and still on membrane'smain. - Effect: in XML tool mode,
{type: "string", nullable: true}reads as a plain string, so a model'snullarrives as the text"null". - Why nothing changes here: this PR's text makes no such claim about membrane. Its own handling of
nullable: trueis the lenient one, and on the registry path the text"null"is a value anyway. Any fix belongs in membrane.
Not this PR's (already tracked)
- Refusals traced as
tool:completed. A refusal on the model's module-dispatch path is traced astool:completed, as every resolved error result there already was. This is #388 and predates the PR. - Fractional
integervalues inutils. A fraction for anintegerparameter of a utility is #386.
Verified fine
- Merged state.
- The PR merges cleanly onto
mainate6a0317. tsc --noEmitand the build are clean.- Full suite: 2417/2417 on Node 22.21.0 with
--test-force-exit, using membrane 0.5.87 from a fresh install. - That count includes
module-tool-args(39 cases, as the body says),utils-surfaceandunconvertible-tool-result.
- The PR merges cleanly onto
- Nothing on
mainsince the merge-base changes the picture.- There are no new module tools, required-key changes or paths into modules.
types/module.tschanged only a doc comment, and the Discord change is the typing keepalive.- The dispatch sites are the same shape:
executeToolCall, the model's module dispatch,read_imageandutils. - The MCP server's
handleToolCallis its own JSON-RPC handler, moved intomcp-server-core.ts, and it doesn't reach the registry.
- connectome-host.
- None of the constructs the rules added since round 1 act on (unreadable or uppercase types, type lists,
nullable, rootanyOf/oneOf/$ref,additionalProperties) appear in any tool schema at itsmain. - No test there asserts the old
utilswording. - Since round 1, the only behaviour changes for host tools are the refusal text and when utilities are listed. So round 1's run of the host suite under this PR still applies (identical results to 0.22.1). It was not re-run.
- The host depends on
^0.22.1, so it takes this change when it bumps that range.
- None of the constructs the rules added since round 1 act on (unreadable or uppercase types, type lists,
- More calls this now refuses that used to do harm, beyond those in the body:
workspace--editwithoutnewStringreplaced the match with the textundefined(content.replace(oldString, undefined),src/modules/workspace/index.ts:2268-2270).- Discord
set_reply_contextwithoutchannelIdsaved anundefinedchannel and returned success (src/modules/discord/index.ts:1122-1139). - Both strengthen the case for the change, and neither needs a migration.
- Membrane claims in the body hold for 0.5.87:
- A type list naming any unknown entry is unreadable as a whole (
tool-schema.js:148-160). - An unknown
typesuch asanygives the parameter no type in the prompt (:246-264). - The mergeable variants of a root
oneOf/anyOf/allOfare read as parameters (readToolSchema). - Runs of 16 or more digits stay text for number and integer parameters (
tool-parser.js:196-204).
- A type list naming any unknown entry is unreadable as a whole (
- Adversarial probes of
toolArgsProblemsbehave as documented (35 cases).- Null with
nullable, with an uppercase type, with a list containingany, and with notypeat all. - Optional nulls on
utils, a nullable enum withoutnull, and 15 versus 16 digits forinteger. - Inputs of
'',0, a list andnull; prototype-only keys; an own__proto__. - Keys of 200 characters and of astral characters, cut at 80 characters without splitting a surrogate pair.
- A
requiredthat isn't a list,propertiesgiven as a list, and a root withthenbut noif, withminProperties, or withenum. - A throwing getter throws out of the function, and the registry's try turns it into an error result.
- Null with
- Release mechanics. By the release-hold rule on
main(19cd94a), this fragment is the only pending breaking entry, so the next release has to open 0.23.0. Any further 0.22.x patch needs to go out before this merges. Open #220 edits the context aroundvalidateUtilityArgs, which this PR removes, so it will need a rebase after this lands.
Method.
- Two passes. GPT-5.6 Sol (Codex, xhigh, read-only sandbox, no network) reviewed
main...headand the round-1 delta, with connectome-host refs and the unpacked membrane 0.5.87 build to hand: about 11 minutes, 220 commands, 6.79M input tokens (6.45M cached) and 42.8k output tokens. In parallel, Claude Opus 5.5 did the cross-repo and runtime checks below. Every Sol finding was re-checked against source before being adopted. - What Claude ran:
- Fetched current
mainfor this repo and for connectome-host, and the membranemainsource. - Merged the PR onto
mainand ran the full suite. - Probed
toolArgsProblemsfrom the merged build. - Probed membrane's
readToolSchemadirectly. - Checked the release-hold rule, open PRs that touch the same code, and the host's schemas and tests.
- Fetched current
- Who caught what.
- Both independently: R1-1 to R1-5 fixed, and the membrane
nullablecorrection to round 1. - Sol only: R2-1, the extra harmful calls under "Verified fine" (workspace
edit, Discordset_reply_context), and tracing, which turned out to be #388. - Claude only: the merged-state suite, the change in null handling for malformed
types since8d6424d, the release-hold consequence, the rebase note for #220, and the host-schema and host-test sweep.
- Both independently: R1-1 to R1-5 fixed, and the membrane
- What Sol couldn't reach. It had no network and couldn't run the integration tests (its read-only sandbox refuses temporary directories). It ran the 23
toolArgsProblems/Discord cases, all passing. The body's red-first and mutation claims were not re-run by either reviewer, and connectome-host's suite was not re-run this round.
…R2-1)
From the maintainer's round-2 review.
R2-1: the hint that names keys a call gave outside the schema named
them for a root whose additionalProperties or unevaluatedProperties is
true, and named none where it is {}. In JSON Schema the two mean the
same there, and schema generators write an open object as true
(Pydantic's extra="allow", zod-to-json-schema's passthrough()). A root
that says it takes keys its properties don't list now names none for
either. false still names them, since it says the root takes no others.
A test also pins that a type the check can't read keeps its enum
checked, alone or in a type list, as the fixed fragment says.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…keys Left out, additionalProperties is JSON Schema's default, which means the same as true there, but says nothing of what the author meant: most schemas leave it out without welcoming other keys. So a root without it names undeclared keys, as before. A comment-only change, from the reader's review of the R2-1 round. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replying to this review. @Anarchid R2-1 is fixed in
On your correction to round 1, about membrane and On the release: yes, this fragment makes the next release 0.23.0, so any 0.22.x patch goes out before this merges. #220 edits around Thank you for the second round. Written by Ada-1017. — Lottie-2661 |
XML tool mode reads each parameter's declaration through readToolSchema,
and both the parameter parser and the tool instructions follow that
reading. It decided whether a parameter admits null from the kinds its
`type` names, and never read OpenAPI 3.0's `nullable: true`, the spelling
OpenAPI-converted schemas use. So `{type: "string", nullable: true}` read
as a plain string. A written null reached the tool as the text "null",
and the instructions didn't say the parameter admits null. An integer or
object parameter so declared got its null with a type-mismatch warning.
- `nullable: true` beside a `type` now adds null to what the type admits,
as a type list naming "null" does. Only the boolean true counts, and
only beside a `type`, the one place OpenAPI 3.0.3 gives it effect.
Without one, the reader reads the node from its other keywords as
before. It doesn't consult a sibling `enum` or `const`, as for any
`type` in this reading, so `{type: "string", nullable: true, enum: [...]}`
admits null as `{type: ["string", "null"], enum: [...]}` already did.
- A declaration with several types that admits null, such as
`["string", "number", "null"]`, `nullable: true` beside such a list, or
an `anyOf` of several types with a null branch, now carries the null
mark in the instructions. It has no one type, so it keeps the legacy
guess, which already read a written null as null.
A declaration with a type name this reader doesn't know, such as Gemini's
upper-case `["STRING", "null"]`, is left as it was: no type and no null
mark, and the legacy guess. Marking those as well would have meant reading
null apart from the other kinds throughout the module, for parameters that
already parse a written null as null. agent-framework's tool-argument
check (anima-research/agent-framework#372) reads `nullable: true` beside a
type the same way.
The tests pair both spellings, cover integer, object and `$ref`
declarations, a nullable type with an enum and in an allOf, `nullable` as
false, as the text "true" and without a type (an `allOf` of a `$ref`
among them), lists of several types, and declarations membrane can't
type.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R3 at 4275ef7 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R3-n are stable; cite them in fix commits.
Reviewed at 4275ef7 against main e670363 (merge-base 92125b0)
Nightly dual review · round R3 at 4275ef7 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged, with every claim re-checked by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R3-n are stable; cite them in fix commits.
Verdict: approve. R2-1 is fixed and pinned, and the red check isn't this PR's.
15f6764 fixes R2-1 as asked, and the fix is pinned: reverting either keyword's half fails the test row for that keyword. The one failing CI job is a timing flake in the tool-result guard's #281 tests. #407 fixed it on main about 16 hours after this PR's CI ran. This PR merges cleanly onto today's main, 38 merges past the main round 2 read. The merge passes the full suite, and nothing that landed since round 2 changes the picture. Two small wording points remain (R3-1, R3-2), and neither blocks.
Earlier findings
- R2-1, fixed and pinned.
declaresKeysPlainlynow askstakesOtherKeys(value), which isvalue === true || isMap(value), for bothadditionalPropertiesandunevaluatedProperties(src/tool-args.ts:120-127).falsestill names keys.- The test moved both
truerows into the table of roots that name none, added a{}row, and kept bothfalserows innoWays(test/module-tool-args.test.ts:549-570). - Mutation check on the merged build: with
takesOtherKeyscut back toisMap, the file fails at theadditionalProperties truerow. With only theunevaluatedPropertiescall reverted, it fails at theunevaluatedProperties truerow. Restored, it passes 39/39.
- The comment
4275ef7adds is accurate. Leaving the keyword out does mean JSON Schema's open default. Still naming keys there is a deliberate choice, and a defensible one, since the hint never refuses anything. One comment above it wasn't updated with it (R3-1). - The new
enumtest does what it says. It covers an unreadabletypealone (any) and in a list (['STRING', 'null']), withvalues: true. Valid members pass, and both bad values are reported (test/module-tool-args.test.ts:588-597). As the description says, it pins whatfd8a222already did. - R1-1 to R1-5 are still fixed. The delta since
fd8a222touches onlydeclaresKeysPlainlyand its tests, and none of the code those fixes live in.
Why CI is red
- Only one of the five jobs failed: Build & Test (ubuntu-latest, Node 20.20.2), with 1 failure in 2,450 tests. The failing test is
#281: batches behind one stalled write cost one deadline: no write starts until it settles(tool-result-guard). It failed ononce it has settled, the next original is written: 1 !== 2, and its log shows the third batch "used its deadline before all its originals were written". - #407 fixed exactly this test on
main("test(guard): hold the clock still in three batch-deadline tests", merged Oct 17 19:47 UTC). It names this test and this failure: under load, the batch'sDate.now()deadline runs out before its write starts. - This PR's run predates #407. It checked out
refs/pull/372/mergeat19515d5, which is4275ef7merged intomainat2482625(Oct 17 03:48 UTC). That commit doesn't contain #407. The PR touches no guard code. - What to do. Re-running the failed job reuses the same merge commit,
19515d5, so it may pass or may flake again. Any push to the branch (updating it frommainis enough) makes CI test a merge that includes #407. Merged ontoe670363here, the full suite passes: 2,899/2,899 withnpm test(--test-force-exit) on Node 22.21.0, from a fresh install with membrane 0.5.87 and context-manager 0.14.1.tsc --noEmitand the build are clean. Node 20 wasn't available for this run.
Does the approval hold? Yes. What main changed since round 2
Round 2 read main at e6a0317. Since then 38 merges have landed, and none of them weakens the case.
- #456 gave the workspace module its own input check (
inputProblem, merged treesrc/modules/workspace/index.ts:3518, called at:1385-1393). Calls through the registry now meet this PR's check first. The workspace's own required-field branch is reached only by callers that skip the registry: the framework'sread_imageand the workspace's own tests, which call the handler directly (test/workspace-file-tools.test.ts:24-25). So nothing conflicts, and those tests pass in the merged suite.- On
nullfor a required string the two checks agree: both refuse it. - This PR is still the only check for the Discord, history and health modules, for every connectome-host module, and for third-party modules. Discord's
set_reply_contextwithoutchannelIdstill stores anundefinedchannel and returns success onmain(src/modules/discord/index.ts:363,:1123-1140).
- On
- #367 (refusals are error results). The registry's refusal already sets
isError: true(mergedsrc/module-registry.ts:355), as #367 asks of every refusal. src/modules/unknown-tool.ts(new). Its comment says the registry hands everymodule--nameto its module, undeclared names included. That agrees with this PR's rule that a name a module answers without declaring it goes on unchecked.- The schemas haven't moved. All 19
requiredlists in the workspace, discord, history and health modules are identical between the merge-base andmain. The dispatch sites have the same shape:executeToolCall, the model's module dispatch,read_imageandutils. Bothutilsbranches pass the definition they resolved (mergedsrc/framework.ts:20649-20650). - The release order is already settled. 0.22.2 is out (
fe2898b), so the "patch first" condition from round 2 is met. This fragment is still the only pending*.breaking.md, so the next release is 0.23.0.
Blockers
None.
Major
None.
Minor
R3-1. A doc comment still says the two keywords count "only as a schema object". (Sol found this; verified.)
- Evidence: the comment on
OTHER_KEY_DECLARERS(src/tool-args.ts:95-96) saysadditionalPropertiesandunevaluatedPropertiesare read bydeclaresKeysPlainlyand "count only as a schema object". Since15f6764,takesOtherKeysalso countstrue(:126-127), and the test pins that (test/module-tool-args.test.ts:552-553).15f6764updated the comment ondeclaresKeysPlainlybut not this one. - Failure scenario: a later reader who trusts the comment over the code "restores" the
isMap-only check, and R2-1 comes back. The test would catch it, but the comment would be arguing for the regression. - Fix: "…which count when
trueor a schema object".
R3-2 (nit). In 0.23.0's notes, this fragment's examples overlap with #456's.
- Evidence: the fixed fragment's examples are all workspace tools (
changelog.d/module-tool-args-checked.fixed.md:3-6:workspace--read,workspace--grep).main's pendingchangelog.d/fix-workspace-file-tools.fixed.md(second bullet) says that every workspace tool now checks its own input, required fields included. #456 merged after 0.22.2, so both entries ship in the same release. - Why it matters: each statement is true against 0.22.2. Read together, though, they credit the same fix twice and imply different refusal texts. Because the registry answers first, the workspace's
"…" is requiredwording is seen only by direct handler callers. - Fix (optional): pick an example that only this change covers, such as Discord
set_reply_contextwithoutchannelId, which stored anundefinedchannel and reported success. Or add half a sentence saying that for workspace tools this check answers before the module's own.
Verified fine
- Merged state: clean merge onto
e670363, typecheck and build clean, 2,899/2,899 (above). - Edge values of the two keywords (probe on the merged build):
null, a string, a number or a list foradditionalPropertiesstill names keys. These are invalid schemas, and naming is the harmless direction.additionalProperties: falsebesideunevaluatedProperties: truenames none. JSON Schema would reject the extra keys there, but dropping the hint is the cautious direction this PR chose.
- connectome-host
main(ac2e8bf, 137 commits past the hostmainthat round 2 read):- It depends on
@animalabs/agent-framework ^0.22.2, so it takes this only with a deliberate bump to^0.23.0. - Since round 2 there are no
requiredchanges, new module files or new tool names insrc/modules. - Its three in-process
callToolsites are all outside this check:channel_closeis a framework tool.subscription-gc--pin_channel_idle_limitis undeclared: that module'sgetTools()returns[], so the call goes on unchecked, as the description says.mcpl--…--edit_messageis an MCPL tool and never reaches the registry.
- The subagent tools in the description's inventory keep the same schemas (
src/modules/subagent-module.ts:763-847at hostmain).
- It depends on
- #220 now merges onto this PR plus
mainwithout textual conflicts, and the combined tree has no remaining reference tovalidateUtilityArgs. That combination wasn't typechecked. - Membrane 0.5.87's handling of 16-digit runs still matches the comment (
dist/utils/tool-parser.js:196-204).
Method.
- Two passes. GPT-5.6 Sol (Codex, xhigh, read-only sandbox, no network) reviewed
main...headand the delta since round 2. It had the PR merged onto currentmain, connectome-host'smainand the unpacked membrane 0.5.87 build to hand: about 6 minutes, 114 commands, 5.16M input tokens (4.94M cached) and 23.9k output tokens. In parallel, Claude Opus 5.5 did the CI, cross-repo and runtime checks. Every Sol finding was re-checked against source before being adopted. - What Claude ran:
- Pulled the failing job's log and identified the merge commit it tested.
- Traced the failure to #407.
- Merged the PR onto current
main; ran the typecheck, the build and the full suite. - Ran the mutation checks of R2-1's fix and probed the keyword edge cases.
- Swept
mainsince round 2 for schema, dispatch and refusal changes, and connectome-host'smainfor schema changes and in-process calls. - Checked #220's combination with this PR and the release state.
- Who caught what.
- Both independently: R2-1 fixed and pinned, the merge combining correctly, and #456's workspace check sitting behind the registry's.
- Sol only: R3-1.
- Claude only: the cause of the CI failure and what to do about it, the merged full-suite run, the mutation checks, R3-2, the release state, and the #220 combination.
- What Sol couldn't reach. With no network it couldn't see CI. Its read-only sandbox refused the temporary directories the integration tests need, so it ran the compiled
toolArgsProblemsand Discord cases plus a direct registry probe, all passing. Neither reviewer re-ran the description's red-first and removal claims beyond R2-1's. Node 20 wasn't available locally, and connectome-host's suite wasn't run against this PR this round.
Brings in anima-research#407 (the guard's batch-deadline tests hold the clock), so CI tests a merge that has it; the one red job at 4275ef7 was anima-research#281's deadline test, which anima-research#407 fixed on main. No conflicts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed entry's example is one only this change covers (R3-1, R3-2) R3-1: OTHER_KEY_DECLARERS' comment said additionalProperties and unevaluatedProperties count only as a schema object; since 15f6764 takesOtherKeys counts true as well, so the comment now says so. R3-2: the fixed fragment's examples were workspace tools, and main's pending fix-workspace-file-tools.fixed.md says every workspace tool now checks its own input, required fields included, so the two entries would credit one fix twice. The example is now discord--set_reply_context, which declares channelId as required and, on main e670363, answers a call without it by leaving the reply context with no channel and success (src/modules/discord/index.ts handleSetReplyContext). The refusal text quoted is what toolArgsProblems gives for that tool's schema, composed as module-registry.ts does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replying to this review.
At Written by Ada-1017. — Lottie-2661 |
Two conflicts, each resolved by keeping both sides: - src/module-registry.ts, the imports: main's logValue beside this branch's toolArgsProblems. - src/framework.ts, the utils tool's run action: this branch's comment on where the call is checked, over main's ToolCall, which carries the caller's turn. anima-research#220's permission refusal, agentToolPermissionFailure, merged on its own. At each of its dispatch points it comes before any routing, so a call the agent may not make is refused before this branch's argument check runs in ModuleRegistry.handleToolCall. In the run action it stands just ahead of the check that replaces validateUtilityArgs, so a utility the agent may not call never answers with its schema. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and anima-research#372 main aae86b7 brings anima-research#234, anima-research#528, anima-research#529, anima-research#524, anima-research#538, anima-research#539, anima-research#372 and anima-research#210. One conflict, a comment in src/framework.ts's utils run: anima-research#372 rewrote its first lines (the registry checks the call against the utility resolved here; the model's path takes the result's stored form), and this branch added a paragraph on where the result goes, before the resultMaxChars line. The resolution keeps anima-research#372's lines, then this branch's paragraph and line. The code below it merged as git made it: the utility call carries resultMaxChars, and the registry takes anima-research#372's { definition: def }. anima-research#538 landed first and meets this branch in a test without a textual conflict: handleScriptToolCall now returns a ScriptToolResult, { text, isError }, where it returned the string. The script case of tool-call-result-max-chars.test.ts reads .text, and checks isError is false, as anima-research#538's own tests read it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The edit planner's branch, which this one is stacked on, answered the review of its whole change and that review's run, and merged main at aae86b7 into its head along the way, as 11354ae. Its head is now 39d0151. That brings anima-research#538 and anima-research#372, among others. This branch's changes apply to it as they stand: the merge has no conflict, and its diff from 39d0151 is this branch's diff from 8b03920, 10 files, +1042 -142.
main has taken anima-research#234, anima-research#528, anima-research#529, anima-research#524, anima-research#538, the 0.22.3 merge-back (anima-research#539), anima-research#372, anima-research#210 and anima-research#541 since 8d7694f. - changelog.d/403-truncation-image-slots.fixed.md: main deleted it when 0.22.3 took it into the changelog, where this branch had changed one clause of it, to say a spill file goes only to a writable mount without an include. Taken as deleted: 0.22.3's entry stays as released, and this change's own fragment (mount-include.added.md) already says where the framework keeps its files and that a setup whose only writable mount has an include is treated as having none. git merged the rest on its own. Against main, every file the merge changes differs exactly as this branch did against 8d7694f, so no change of main's is undone. Of the files both sides changed, src/framework.ts, src/types/framework.ts and src/types/trace.ts take this branch's spill texts beside main's changes elsewhere in them, and test/workspace-three-way.test.ts takes the nested-directory tests beside anima-research#528's re-attach tests. No use of firstWritableMountName or text about a writable workspace was added on main since 8d7694f. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main has taken anima-research#234, anima-research#528, anima-research#529, anima-research#524, anima-research#538, the 0.22.3 merge-back (anima-research#539), anima-research#372, anima-research#210 and anima-research#541 since 8d7694f. - changelog.d/403-truncation-image-slots.fixed.md: main deleted it when 0.22.3 took it into the changelog, where this branch had changed one clause of it, to say a spill file goes only to a writable mount without an include. Taken as deleted: 0.22.3's entry stays as released, and this change's own fragment (mount-include.added.md) already says where the framework keeps its files and that a setup whose only writable mount has an include is treated as having none. git merged the rest on its own. Against main, every file the merge changes differs exactly as this branch did against 8d7694f, so no change of main's is undone. Of the files both sides changed, src/framework.ts, src/types/framework.ts and src/types/trace.ts take this branch's spill texts beside main's changes elsewhere in them, and test/workspace-three-way.test.ts takes the nested-directory tests beside anima-research#528's re-attach tests. No use of firstWritableMountName or text about a writable workspace was added on main since 8d7694f. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…his branch's content arrays main moved from ce1a073 by 82 commits (anima-research#538, anima-research#539's 0.22.3 merge-back, anima-research#524, anima-research#529, anima-research#528, anima-research#372, anima-research#210 and anima-research#541). Two conflicts: - src/framework.ts, the script path's text result: anima-research#538 returns `{ text, isError: false }` where this branch passes the result's `dataIsContent` to toolResultDataToHistoryString. Both are kept. - test/script-structured-references.test.ts, its imports: anima-research#538's `ScriptToolResult` beside this branch's `referenceRegistry`. Read beside the conflicts, as they stand after the merge: anima-research#538's scriptStructuredResult, which still takes the content array the dispatch gave the call, where an MCPL peer's blocks were mapped through referenceBlockForScript, and the reference registry's address sites in start(), stop(), setMcplServerConfig and a server's removal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le (#363) A ToolCall membrane marks with unparseableInput carries an input that isn't the model's arguments, and main ran it as given (a probe on 0a4a2c6 ran a module tool with {} for a cut-off text, '', an array, null and an object's JSON from a broken-order stream). markedInputRefusal (tool-args.ts) decides: a marker whose text is JSON null reads as no arguments and the call runs; any other is refused in #372's form, 'Nothing was done: <tool> <problem>.': - '' (a stream that ended before the call's block did): cut off before any arrived; - text that isn't JSON: shown as #372 shows a value, cut at 80; - an array, number, string or boolean: #372's not-an-object words, now one helper (notAnObject) for both; - an object's JSON (a stream that broke the API's block order, whose arguments may be another call's): call it again. It's read wherever a call is decided: - dispatchToolCall, after the allowedTools check, through refusedResult, so RFC-007's lifecycle forgets the call; - roundCallOf, so a refused marked skip_reply keeps its round private and silences nothing after it, as an allowedTools refusal does; - scriptMayRoute, so a refused marked code_execution leaves no round waiting on a script that never starts; - executeToolCallFrom, for every origin, as a failure. Agent.extractToolCallsAndSpeech keeps the marker on runInference's calls, and ToolCall declares it. MockMembrane carries it from a tool_use block to the tool-calls event and the response's toolCalls. The tool-calls case's comment that every call there has valid input is false on membrane 0.7 and now says what the marker means. Tests (test/tool-input-unparseable.test.ts) pin each refusal's text and that the tool didn't run, the bounded start, null and unmarked calls running, the skip_reply and code_execution rounds, executeToolCall and runInference. Against main's sources 12 of the 13 fail; the null/unmarked one passes there, as the rule leaves it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TL;DR: a module tool's call is now checked before the module runs: its input must be an object, or absent, and every key its schema marks required must have a value. A miss is refused with what to send, and the module never sees the call. This is breaking for a module whose handler runs without a key its schema requires, since such calls are now refused. The
utilstool keeps its fuller check, of each value's type and enum too, and its refusal now reads the same way.What was wrong
The registry hands every module tool call to its module as it comes. When this change began, on
main(92125b07):workspace--read {}answeredTypeError: Cannot read properties of undefined (reading 'indexOf').workspace--writewithoutcontentansweredTypeError [ERR_INVALID_ARG_TYPE]: The "string" argument must be of type string or an instance of Buffer or ArrayBuffer. Received undefined.workspace--grepwithoutpatternsucceeded and matched every line, sincenew RegExp(undefined)is the empty pattern.Since then, #456 has given the workspace module a check of its own input, which, by our reading of
main(e6703634), refuses those three calls before their handlers run. Other modules still get every call unchecked. For example,discord--set_reply_contextwithoutchannelIdleaves the reply context with no channel and answers success.Only the
utilstool checked a call against its schema first (validateUtilityArgs).Changes
src/tool-args.ts,toolArgsProblems(input, schema, { values, field }): that check, shared, and reporting every problem at once.undefinedornull) gives no arguments, so it's refused only for the keys the schema requires, and the handler still sees it as it came.requiredkey needs a value. One left out, or given asundefined, is missing, and so is one given asnull, unless its declaration admitsnull, by a type list naming it or OpenAPI'snullable: true. A key with no declared type takesnullas a value, as it takes any other. One whose type the check can't read (below), such as Gemini'sSTRING, takesnullonly where the declaration names it, since such a type says nothing aboutnull. An empty string is a value. Only the input's own keys count, and a key the schema lists twice is reported once.propertiesnor in itsrequiredlist are named too, up to five and then a count, since a key sent under another name is one way a required key goes missing. They're never refused for themselves. A schema whose root can declare keys another way names none, whatever its subschemas hold, since a key it takes may be declared there: membrane reads a root union's variants as parameters, and naming a key the schema takes would mislead more than a hint left out. Those are the root keywords that JSON Schema's drafts 4 to 2020-12 have for accepting or naming an object's keys, besidespropertiesandrequired:allOf,anyOf,oneOf,not,if,$ref,$dynamicRef,$recursiveRef,dependentSchemas,dependencies,dependentRequired,patternProperties,propertyNames,enumandconst, and a schema object foradditionalPropertiesorunevaluatedProperties,{}included, ortrue, which means the same there: each says the root takes keyspropertiesdoesn't list.falsethere says it takes none, so a root with it names keys. Left out, the keyword is JSON Schema's default, which says nothing of what the author meant, so a root without it names keys too.thenandelseact only besideif, and a keyword JSON Schema doesn't have declares nothing.values, each declared key's value also has a type its declaration admits (a type list admits any of its types, andintegerreads asnumber) and is one of itsenumvalues. A run of 16 or more digits sent as text passes for a number: some such runs would lose precision as a JavaScript number, which is why membrane's XML tool parser keeps them as text for a tool's own number parameters, and a caller can send one as text for the same reason. It converts nothing: a run sent as text reaches the handler as text, and a number stays a number. Through theutilstool, membrane parses the run'sargsas one object, so a large integer the model writes there arrives as a number.requiredthat isn't a list or an entry of it that isn't a name, atypeorenumof another shape, or atypenaming no type JSON Schema has, such asany, or a type list naming one) is ignored, not thrown on. Such atypedeclares nothing, as membrane reads it, so theutilstool no longer refuses every value against it, and still checks itsenum.ModuleRegistry.handleToolCallruns it against the tool's declared schema before the module runs, and refuses withNothing was done: <tool> <problems>.It looks among the module's tools first, and lists the module's utilities only for a name the tools don't have, so a utility listing that throws or is slow reaches only calls to such names: a utility called by its own name, and a name the module answers without declaring it, which is then answered with the listing's error, as a call meeting a throwing tool listing is. Theutilstool passes the utility it resolved, so a run is checked against that utility's schema even where a tool shares its name.utilstool,ModuleContext.callTool, and a host calling the registry. The framework's ownread_imagetool calls the workspace module's handler itself, after its own check ofpath, the one keyread_imagerequires. Code that holds a module object can call its handler directly.authora list, and takes a single name too, since tool-schema dialects vary onanyOf. A type check here would refuse calls that work.src/modules/subscription-gc-module.tsat7003505) declares no tools and still answers its former idle-limit tools. Its channel-mode module calls one of them,pin_channel_idle_limit, throughctx.callTool(src/modules/channel-mode-module.ts).getTools()gives an error result, as the method's doc says, not a rejection.utilstool uses the same function with values, as its check always did. Its refusal now reads the same way, calling the run's arguments itsargswhere they aren't an object, and is followed as before by the utility's schema. It now admits any type of a declared type list, where it refused every value of such a parameter, and it takes 16 or more digits sent as text for a number, passing them to the utility as text.What callers will notice
An agent:
workspace--read {}now answersNothing was done: workspace--read needs "path" (a string), and this call gave none., and a call missing several required keys names them all. Where a key was sent under another name, the refusal names it:workspace--read {"file_path": "a.txt"}answersNothing was done: workspace--read needs "path" (a string), and this call gave none; "file_path" isn't one of its parameters.A call with no input to a tool that requires nothing reaches its handler as before. The Discord module'sactivate, which threw aTypeErroron such a call, now reads it as no arguments.A module author, breaking: your handler no longer sees a call that lacks a key your schema marks required, or whose input is there but isn't an object. If your handler runs without such a key, calls that leave it out are now refused. If the key is optional, drop it from
requiredand give its default in the property's description. If it's required, keep it, and the refusal tells the agent what to send. Values, and keys your schema doesn't declare, still reach you as they came.connectome-host needs no change. Reading its handlers at
7003505, these tools run without a key their schema requires, and each keeps the key required, since what each such call does isn't what its agent asked for:subagent--forkandsubagent--spawnwithoutnameortask: neither handler checks them, so the run starts anyway, with anundefinedtask, or as "Subagent 'undefined'" when the name is missing;subagent--spawnwithoutsystemPrompt, which its schema requires (src/modules/subagent-module.ts:753): nothing checks it before the run starts. In the default async mode the call answers "Subagent '…' spawned. Running in background." (:1661), and the run's first attempt readssystemPrompt.length(:1580);subagent--returnwithoutresulthands the parent the subagent's captured speech instead;subagent--hudwithoutenabledsets the HUD's state toundefined.lessons--createwithoutcontentortags, andlessons--deprecatewithoutreason, ran without them at the commit this list was first read at, and the lessons module refuses them itself at7003505(src/modules/lessons-module.ts:255-261,:303-312). This check now answers those calls first, in its own words.MCPL servers' tools don't go through the registry, and keep their servers' own checks.
What each part is for
What this change is for: a module tool's call that can't work, because a key the handler needs is missing or the input isn't an object, is answered before the module runs, with what to send. Each part below is weighed by what it buys, what it costs to keep, and whether something simpler would do, leaving it out included.
ModuleRegistry.handleToolCall. Every call to a module tool passes through it, so one check covers the model's calls, scripts, theutilstool,ModuleContext.callTooland hosts. A check in each module would repeat it in every module, outside authors' included.nullfor a required key counts as missing, unless the key's declaration admits it, since a model'snullfor a key it doesn't have reaches the handler as a missing key would.nullable: truecounts as admitting it. No tool schema in agent-framework or connectome-host uses it, but without that reading a module whose author wrote it would have a model'snullrefused in native tool mode, where the author said the key takes one. It costs a condition in each of two functions.propertieskeeps the hint from naming a key the schema takes. It's a constant and one function, with no state, and a keyword missing from it could only make the hint name a key, never refuse one.utilstool passes the utility it resolved (R1-2). The first keeps a failing utility listing away from ordinary tool calls. The second keeps every call through the registry checked against the schema its caller resolved. The other option, lettingutilsskip the registry's check, would leave a way past the check that any caller could use.utilstool and module tools, with values checked only forutils, as its old check did. The two surfaces then answer the same call the same way. Rewritten, the value check reads a type list as any of its types, and a type JSON Schema doesn't have as no type. The old check compared a value's kind with thetypefield as a whole, so it refused every value of such a parameter. It also takes a run of 16 or more digits sent as text for a number, as membrane's XML tool parser sends one.activatereads no input as no arguments. It's a one-line change, so thatactivate, which threw aTypeErroron an absent input, agrees with the rule above.Cut before this pass: a stricter reading of
integerforutils, which is #386, as its own change.Where less sure:
nullable: truerests on schemas outside these two repositories. And the Discord fix rides here, though itsTypeErroris older than this change.Evidence
At this head,
6b5a558d(tree2e917311): a merge ofmaince1a0735, which brings #220. Its two conflicts keep both sides: an import insrc/module-registry.ts, and theutilstool's run action insrc/framework.ts, where this change's comment sits overmain'sToolCall. This change's own diff is as it was. By our reading, #220's permission refusal,agentToolPermissionFailure, comes before any routing at each of its dispatch points, so a call the agent may not make is refused before this check runs. In theutilstool's run action it stands just ahead of the check, so a utility the agent may not call never answers with its schema.workspace-path-spelling's tests for a disk that folds names. Typecheck and build are clean. CI runs it with--test-force-exiton Node 20 and 24, on Ubuntu and macOS.module-tool-args39/39,utils-surface4/4 (mainadded its fourth test, in fix: text cut for an agent to a fixed length keeps surrogate pairs whole #497) andunconvertible-tool-result10/10, and fix(tools): enforce registered agent allowlists before dispatch #220'sagent-allowed-tools68/68.toolArgsProblemsgives forset_reply_context's schema, composed as the registry composes it. Its "before" ran onmaince1a0735(Linux, Node 24.22.0):set_reply_contextwith{}answered{"success":true}and left the reply context'schannelIdundefined, both with and without a reply context set before.The earlier rounds' runs below were made at the heads each paragraph names. This change's own code is as it was at
15f6764a, apart from two comments insrc/tool-args.ts.On Linux. Each run installed fresh, with no lockfile, getting context-manager 0.14.1 and membrane 0.5.87, and built from an empty
dist. The runs at9db567c3and before were made before a message-only rewording of five of this PR's commits, which added theCo-Authored-Bytrailer CONTRIBUTING asks for. Each commit named here has the tree its runs used.At
1664bb82(tree928a7847), a merge ofmaine6703634(56a7a6c3, no conflicts, leaving this change's own diff as it was), then the round-3 text points (R3-1, R3-2), which change a comment and the fixed fragment:--test-force-exit, 2,899/2,899. Node 24.22.0 with--test-force-exitcounted 2,891/2,891: it cuthistory-module-realstoreshort (20 of 28), the cut test: npm test makes each test file's stdout blocking, so --test-force-exit can't drop its last results #464 fixed fornpm test, which these per-file runs don't go through. Typecheck and build are clean.module-tool-args39/39,utils-surface3/3 andunconvertible-tool-result10/10, in each of those three runs.toolArgsProblemsgives forset_reply_context's schema, composed as the registry composes it. Its "before" ran onmaine6703634(Linux, Node 24.22.0):set_reply_contextwith{}answered{"success":true}and left the reply context'schannelIdundefined, both with and without a reply context set before.At
15f6764a, answering the maintainer's round-2 review (4275ef75adds only a comment on why a root without the keyword still names keys). A root whoseadditionalPropertiesorunevaluatedPropertiesistruenames no undeclared keys, as one with{}names none, and a test pins that a type the check can't read keeps itsenumchecked:--test-force-exit:module-tool-args39/39,utils-surface3/3 andunconvertible-tool-result10/10, on both. Typecheck and build are clean.fd8a222d'ssrc/tool-args.tsand that round's tests, the hint test fails at itsadditionalProperties: truerow. Theenumassertions pin whatfd8a222dalready did.trueread as taking other keys, and a type the check can't read keeping itsenumchecked.At
9db567c3, the third review round, after an internal review ofde6c1356(fd8a222d, the head the maintainer's round-2 review read, changes only a comment and the breaking fragment's wording after it). A type the check can't read takesnullonly where its declaration names it, a type list naming one is unreadable as a whole, and the texts follow:--test-force-exit:module-tool-args39/39,utils-surface3/3 andunconvertible-tool-result10/10, on both. Typecheck and build are clean.fd8a222d, each file run on its own: Node 24.22.0 unforced, 2,268/2,268 in 145 files. With--test-force-exit, the flag CI'snpm testpasses, Node 20.20.2 also counted 2,268/2,268, and Node 24.22.0 counted 2,252/2,252: it cutagent-runtime-settingsshort (39 of 55), which passes whole unforced. Typecheck and build are clean.de6c1356'ssrc/tool-args.tsand that round's tests, the two tests whose expectations this round changed fail: the type-name test and the test of a type list with an entry that isn't a name. The round's other new cases, an integer-declared digit run and an input of'',0orfalse, pin whatde6c1356already did.nulltaken only where the declaration names it, and each of the three ways a declaration admits it:nullable: true, a type list naming it, and no type at all. The older ones are the absent-input check taking onlyundefinedandnull, andintegerread asnumber.At
de6c1356, the second review round (e5f5a610), and a reader's finding after it that the root check didn't know every keyword by which a root can declare keys:--test-force-exit:module-tool-args39/39,utils-surface3/3 andunconvertible-tool-result10/10, on both. Typecheck and build are clean. The full suite runs again at the head this merges with.utilsrun of a name a tool shares, dispatched as the model's call is; autilsrun's refusal naming a key insideargsthat the utility doesn't declare; a module with no utility listing answering a name it doesn't declare; a throwing utility listing meeting such a name; the declared-key rule forpropertiesabsent,null, empty, a string or a list, and for a required key the call left undefined; each keyword by which a root can declare keys another way,additionalPropertiesandunevaluatedPropertiesastrueorfalse,thenandelsewithoutif, and a keyword JSON Schema doesn't have; and atypenaming no JSON Schema type.f31373d6'ssrc/tool-args.ts,src/module-registry.tsandsrc/framework.ts, the three new tests of the rule and of unreadable types fail. The other three new tests pin whatf31373d6already did, on paths no test reached. Withe5f5a610'ssrc/tool-args.ts, the root-keyword test fails atnot, the first of its rows whose keyworde5f5a610didn't read.propertiesread only as a map; the root check as a whole; each of its 15 keywords and its two schema-object checks, each failing at its own row; a required key counted as declared; the definition the model'sutilspath passes;storableToolCallpassing it on; and the optional call of a module's utility listing.At
f31373d6, the first review round's head:--test-force-exit, the flag CI'snpm testpasses, Node 20.20.2 also counted 2,262/2,262, and Node 24.21.0 counted 2,240/2,240: it cut three files short,agent-runtime-settings(46 of 55),history-module-overview(27 of 28) andhistory-module-realstore(16 of 28), each of which passes whole unforced.8d6424de's sources, 5 of the then 33 cases failed: the round's four new ones, and the one that also checks that a key sent under another name is named.8d6424de.src/tool-args.ts,src/module-registry.tsandsrc/framework.ts(dee1dd6e), 8 of the then 24 cases in the two files failed, in a run at the second commit. With the third commit's sources (8066274d), 3 of the then 31 failed, in a run at the fourth: null for a required key whose type admits it or that declares none, a type list naming a kind twice, and Discord'sactivatewith no input. With the fourth commit's (e9d4f0a5), 1 of the then 32 failed:nullable: true.Not covered
7003505for the list above, not run.anyto includenull, so a required key typedanywhose author meant it to takenullnow refuses it. The check readsanyas no type it can read, as membrane does, and a type list namingnullbeside it, ornullable: true, says so.Review
Anarchid's review of
8d6424dapproved the change, with three minor points and two nits.f31373d6answers each.e5f5a610andde6c1356settle what R1-3's naming counts as declared, after an internal review off31373d6and a reader's review ofe5f5a610, and9db567c3answers an internal review ofde6c1356:7003505. It addssubagent--spawnwithoutsystemPrompt, and says the lessons module now refuses its two calls itself, with this check answering them first. The breaking fragment's example is the subagent one, with that spawn beside it.utilstool passes the utility it resolved.propertiesnor in itsrequiredlist. A schema whose root can declare keys another way names none.requiredis reported once.integerparameter of a utility is utils value check accepts fractional values for integer parameters #386, as its own change.Anarchid's round-2 review of
fd8a222approved it, with one minor point.15f6764aanswers it, and4275ef75adds a comment on why a root without the keyword still names keys:additionalPropertiesorunevaluatedPropertiesistruenames no undeclared keys, as one whose value is{}names none.falsestill names them.Anarchid's round-3 review of
4275ef7approved it, with one minor point and one nit.1664bb82answers both, after56a7a6c3mergesmain:OTHER_KEY_DECLARERSsays the two keywords count whentrueor a schema object, astakesOtherKeysreads them.discord--set_reply_contextwithoutchannelId, a call only this change refuses, since fix(workspace): the file tools store exactly what they're given or refuse, and say when a stored change didn't reach disk #456's entry covers the workspace tools.That merge brings #407, which fixed the one CI job that failed at
4275ef7, a timing test in the tool-result guard that this change doesn't touch.#220 merged first, so
6b5a558dresolves it here, as our reply to round 2 said it would. #220's permission refusal stays ahead of this change's check, which replacesvalidateUtilityArgs(Evidence).Naomi-2422 (Claude Opus 5.5) reviewed the change as a whole and each round since, through
1664bb82.de6c1356answers her finding ate5f5a610,fd8a222dtakes her two wording points at9db567c3,4275ef75takes the comment she suggested at15f6764a, and1664bb82takes her point on its commit message. Rupert-2634 (Claude Opus 5.5) made the merge at6b5a558d, and Duncan-2410 (Claude Opus 5.5) read it.Written by Ada-1017.