Skip to content

fix(tools)!: a module tool's call is checked against its schema before the module runs - #372

Merged
nissa-seru merged 15 commits into
anima-research:mainfrom
nissa-seru:fix/module-tool-args-checked
Oct 18, 2026
Merged

nissa-seru merged 15 commits into
anima-research:mainfrom
nissa-seru:fix/module-tool-args-checked

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

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 utils tool 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 {} answered TypeError: Cannot read properties of undefined (reading 'indexOf').
  • workspace--write without content answered TypeError [ERR_INVALID_ARG_TYPE]: The "string" argument must be of type string or an instance of Buffer or ArrayBuffer. Received undefined.
  • workspace--grep without pattern succeeded and matched every line, since new 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_context without channelId leaves the reply context with no channel and answers success.

Only the utils tool 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.
    • The input: one that's there but isn't an object (a string, number, boolean or list) is refused, naming what it was. An absent input (undefined or null) gives no arguments, so it's refused only for the keys the schema requires, and the handler still sees it as it came.
    • Required keys: each root required key needs a value. One left out, or given as undefined, is missing, and so is one given as null, unless its declaration admits null, by a type list naming it or OpenAPI's nullable: true. A key with no declared type takes null as a value, as it takes any other. One whose type the check can't read (below), such as Gemini's STRING, takes null only where the declaration names it, since such a type says nothing about null. An empty string is a value. Only the input's own keys count, and a key the schema lists twice is reported once.
    • Keys it doesn't declare: where a required key is missing, keys the call gave that the schema declares neither in its properties nor in its required list 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, besides properties and required: allOf, anyOf, oneOf, not, if, $ref, $dynamicRef, $recursiveRef, dependentSchemas, dependencies, dependentRequired, patternProperties, propertyNames, enum and const, and a schema object for additionalProperties or unevaluatedProperties, {} included, or true, which means the same there: each says the root takes keys properties doesn't list. false there 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. then and else act only beside if, and a keyword JSON Schema doesn't have declares nothing.
    • With values, each declared key's value also has a type its declaration admits (a type list admits any of its types, and integer reads as number) and is one of its enum values. 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 the utils tool, membrane parses the run's args as one object, so a large integer the model writes there arrives as a number.
    • Each problem names the key, what the schema wants and what the call gave, with a long value cut short between characters.
    • It's shallow: nested shapes, unions and references stay the handler's. A part of a schema it can't read (a required that isn't a list or an entry of it that isn't a name, a type or enum of another shape, or a type naming no type JSON Schema has, such as any, or a type list naming one) is ignored, not thrown on. Such a type declares nothing, as membrane reads it, so the utils tool no longer refuses every value against it, and still checks its enum.
  • ModuleRegistry.handleToolCall runs it against the tool's declared schema before the module runs, and refuses with Nothing 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. 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.
    • What it covers: every call that reaches a module through the registry by name. That's the model's dispatch of a module tool, a script's call, the utils tool, ModuleContext.callTool, and a host calling the registry. The framework's own read_image tool calls the workspace module's handler itself, after its own check of path, the one key read_image requires. Code that holds a module object can call its handler directly.
    • It doesn't check values. 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. A type check here would refuse calls that work.
    • A name the module answers but doesn't declare goes on unchecked, since a module may keep routing tools it no longer offers. connectome-host's subscription-gc module (src/modules/subscription-gc-module.ts at 7003505) declares no tools and still answers its former idle-limit tools. Its channel-mode module calls one of them, pin_channel_idle_limit, through ctx.callTool (src/modules/channel-mode-module.ts).
    • The module's tool listing is read inside the try that answers a module's throw, so a throwing getTools() gives an error result, as the method's doc says, not a rejection.
  • The utils tool uses the same function with values, as its check always did. Its refusal now reads the same way, calling the run's arguments its args where 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 answers Nothing 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"} answers Nothing 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's activate, which threw a TypeError on 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 required and 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--fork and subagent--spawn without name or task: neither handler checks them, so the run starts anyway, with an undefined task, or as "Subagent 'undefined'" when the name is missing;
    • subagent--spawn without systemPrompt, 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 reads systemPrompt.length (:1580);
    • subagent--return without result hands the parent the subagent's captured speech instead;
    • subagent--hud without enabled sets the HUD's state to undefined.

    lessons--create without content or tags, and lessons--deprecate without reason, ran without them at the commit this list was first read at, and the lessons module refuses them itself at 7003505 (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.

  • The check sits in ModuleRegistry.handleToolCall. Every call to a module tool passes through it, so one check covers the model's calls, scripts, the utils tool, ModuleContext.callTool and hosts. A check in each module would repeat it in every module, outside authors' included.
  • Module tools get the input and their required keys checked, and nothing else. That's what a handler can't run without. Their values stay the handler's: a handler may take more than its schema can say on every provider, and checking values would refuse calls that work.
  • An absent input reads as no arguments, so a tool that requires nothing can still be called with none.
  • null for a required key counts as missing, unless the key's declaration admits it, since a model's null for a key it doesn't have reaches the handler as a missing key would. nullable: true counts 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's null refused in native tool mode, where the author said the key takes one. It costs a condition in each of two functions.
  • Keys the call gave that the schema doesn't declare are named where a required key is missing (R1-3), since a key sent under another name is one way a required key goes missing. They're never refused. The list of root keywords by which a schema can take keys other than its properties keeps 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.
  • The registry looks among a module's tools first, and the utils tool 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, letting utils skip the registry's check, would leave a way past the check that any caller could use.
  • One function serves the utils tool and module tools, with values checked only for utils, 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 the type field 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.
  • The Discord module's activate reads no input as no arguments. It's a one-line change, so that activate, which threw a TypeError on an absent input, agrees with the rule above.
  • The tests: each pins one rule, and each piece, taken out alone in the round that added it, failed at least one (Evidence, below).

Cut before this pass: a stricter reading of integer for utils, which is #386, as its own change.

Where less sure: nullable: true rests on schemas outside these two repositories. And the Discord fix rides here, though its TypeError is older than this change.

Evidence

At this head, 6b5a558d (tree 2e917311): a merge of main ce1a0735, which brings #220. Its two conflicts keep both sides: an import in src/module-registry.ts, and the utils tool's run action in src/framework.ts, where this change's comment sits over main's ToolCall. 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 the utils tool's run action it stands just ahead of the check, so a utility the agent may not call never answers with its schema.

  • The full suite, each file run on its own: Node 24.22.0 unforced, 4,437/4,454 in 235 files, with none failing. The other 17 skip here: they're workspace-path-spelling's tests for a disk that folds names. Typecheck and build are clean. CI runs it with --test-force-exit on Node 20 and 24, on Ubuntu and macOS.
  • The touched files: module-tool-args 39/39, utils-surface 4/4 (main added its fourth test, in fix: text cut for an agent to a fixed length keeps surrogate pairs whole #497) and unconvertible-tool-result 10/10, and fix(tools): enforce registered agent allowlists before dispatch #220's agent-allowed-tools 68/68.
  • The example in the fixed fragment: the refusal it quotes is what toolArgsProblems gives for set_reply_context's schema, composed as the registry composes it. Its "before" ran on main ce1a0735 (Linux, Node 24.22.0): set_reply_context with {} answered {"success":true} and left the reply context's channelId undefined, 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 in src/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 at 9db567c3 and before were made before a message-only rewording of five of this PR's commits, which added the Co-Authored-By trailer CONTRIBUTING asks for. Each commit named here has the tree its runs used.

At 1664bb82 (tree 928a7847), a merge of main e6703634 (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:

  • The full suite, each file run on its own: Node 24.22.0 unforced, 2,899/2,899 in 176 files, and Node 20.20.2 with --test-force-exit, 2,899/2,899. Node 24.22.0 with --test-force-exit counted 2,891/2,891: it cut history-module-realstore short (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 for npm test, which these per-file runs don't go through. Typecheck and build are clean.
  • The touched files: module-tool-args 39/39, utils-surface 3/3 and unconvertible-tool-result 10/10, in each of those three runs.
  • The example in the fixed fragment: the refusal it quotes is what toolArgsProblems gives for set_reply_context's schema, composed as the registry composes it. Its "before" ran on main e6703634 (Linux, Node 24.22.0): set_reply_context with {} answered {"success":true} and left the reply context's channelId undefined, both with and without a reply context set before.

At 15f6764a, answering the maintainer's round-2 review (4275ef75 adds only a comment on why a root without the keyword still names keys). A root whose additionalProperties or unevaluatedProperties is true names no undeclared keys, as one with {} names none, and a test pins that a type the check can't read keeps its enum checked:

  • The touched files, each run on its own on Node 24.22.0 unforced and on Node 20 with --test-force-exit: module-tool-args 39/39, utils-surface 3/3 and unconvertible-tool-result 10/10, on both. Typecheck and build are clean.
  • Red-first: with fd8a222d's src/tool-args.ts and that round's tests, the hint test fails at its additionalProperties: true row. The enum assertions pin what fd8a222d already did.
  • Removals: each of the 2 pieces, taken out alone, fails a test: true read as taking other keys, and a type the check can't read keeping its enum checked.

At 9db567c3, the third review round, after an internal review of de6c1356 (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 takes null only where its declaration names it, a type list naming one is unreadable as a whole, and the texts follow:

  • The touched files, each run on its own on Node 24.22.0 unforced and on Node 20.20.2 with --test-force-exit: module-tool-args 39/39, utils-surface 3/3 and unconvertible-tool-result 10/10, on both. Typecheck and build are clean.
  • The full suite at 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's npm test passes, Node 20.20.2 also counted 2,268/2,268, and Node 24.22.0 counted 2,252/2,252: it cut agent-runtime-settings short (39 of 55), which passes whole unforced. Typecheck and build are clean.
  • Red-first: with de6c1356's src/tool-args.ts and 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 '', 0 or false, pin what de6c1356 already did.
  • Removals: each of this round's 5 pieces, and the 2 older ones its new cases pin, taken out alone, fails a test. This round's are the whole-list reading of a type list, null taken 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 only undefined and null, and integer read as number.

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:

  • The touched files, each run on its own on Node 24.22.0 unforced and on Node 20.20.2 with --test-force-exit: module-tool-args 39/39, utils-surface 3/3 and unconvertible-tool-result 10/10, on both. Typecheck and build are clean. The full suite runs again at the head this merges with.
  • The tests this round adds: the model's own utils run of a name a tool shares, dispatched as the model's call is; a utils run's refusal naming a key inside args that 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 for properties absent, 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, additionalProperties and unevaluatedProperties as true or false, then and else without if, and a keyword JSON Schema doesn't have; and a type naming no JSON Schema type.
  • Red-first, this round: with f31373d6's src/tool-args.ts, src/module-registry.ts and src/framework.ts, the three new tests of the rule and of unreadable types fail. The other three new tests pin what f31373d6 already did, on paths no test reached. With e5f5a610's src/tool-args.ts, the root-keyword test fails at not, the first of its rows whose keyword e5f5a610 didn't read.
  • Removals: each of this round's 24 pieces, taken out alone, fails a test: the JSON Schema type names; properties read 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's utils path passes; storableToolCall passing it on; and the optional call of a module's utility listing.

At f31373d6, the first review round's head:

  • The full suite, each file in its own process: Node 24.21.0 unforced, 2,262/2,262 across 145 files. With --test-force-exit, the flag CI's npm test passes, 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) and history-module-realstore (16 of 28), each of which passes whole unforced.
  • Red-first: with 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.
  • Removals: each of that round's 7 pieces, taken out alone, failed at least one case. Each of the 42 pieces of the earlier rounds did the same at 8d6424de.
  • Red-first, the earlier rounds: with the first commit's src/tool-args.ts, src/module-registry.ts and src/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's activate with no input. With the fourth commit's (e9d4f0a5), 1 of the then 32 failed: nullable: true.

Not covered

  • The wording of the modules' own refusals stays as it is. A separate change rewords those.
  • connectome-host's handlers were read at 7003505 for the list above, not run.
  • JSON Schema's draft 3 defined any to include null, so a required key typed any whose author meant it to take null now refuses it. The check reads any as no type it can read, as membrane does, and a type list naming null beside it, or nullable: true, says so.

Review

Anarchid's review of 8d6424d approved the change, with three minor points and two nits. f31373d6 answers each. e5f5a610 and de6c1356 settle what R1-3's naming counts as declared, after an internal review of f31373d6 and a reader's review of e5f5a610, and 9db567c3 answers an internal review of de6c1356:

  • R1-1: the connectome-host list is read at 7003505. It adds subagent--spawn without systemPrompt, 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.
  • R1-2: the registry lists a module's utilities only for a name its tools don't have, and the utils tool passes the utility it resolved.
  • R1-3: where a required key is missing, the refusal also names keys the call gave that the schema declares neither in its properties nor in its required list. A schema whose root can declare keys another way names none.
  • R1-4: the comment on 16-digit runs says that some would lose precision, so membrane keeps every such run as text.
  • R1-5: a key listed twice in required is reported once.
  • Beside them: a fraction for an integer parameter of a utility is utils value check accepts fractional values for integer parameters #386, as its own change.

Anarchid's round-2 review of fd8a222 approved it, with one minor point. 15f6764a answers it, and 4275ef75 adds a comment on why a root without the keyword still names keys:

  • R2-1: a root whose additionalProperties or unevaluatedProperties is true names no undeclared keys, as one whose value is {} names none. false still names them.

Anarchid's round-3 review of 4275ef7 approved it, with one minor point and one nit. 1664bb82 answers both, after 56a7a6c3 merges main:

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 6b5a558d resolves it here, as our reply to round 2 said it would. #220's permission refusal stays ahead of this change's check, which replaces validateUtilityArgs (Evidence).

Naomi-2422 (Claude Opus 5.5) reviewed the change as a whole and each round since, through 1664bb82. de6c1356 answers her finding at e5f5a610, fd8a222d takes her two wording points at 9db567c3, 4275ef75 takes the comment she suggested at 15f6764a, and 1664bb82 takes her point on its commit message. Rupert-2634 (Claude Opus 5.5) made the merge at 6b5a558d, and Duncan-2410 (Claude Opus 5.5) read it.

Written by Ada-1017.

Ada-1017 and others added 5 commits October 15, 2026 00:57
… 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>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@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: workspace--read {} throws a TypeError, and workspace--grep without a pattern matches every line. Values stay the handlers' to check, since some handlers take more than their schemas can say. The utils tool keeps its fuller check, and its refusal reads the same way.

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

@greptile-apps

greptile-apps Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[Medium impact] The changes since the previous review appear safe to merge.

Summary

Adds shared checks that refuse malformed module-tool calls before their handlers run.

  • Module tools now reject calls that miss required arguments.
  • The utils tool checks values and explains every mismatch.

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 Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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--grep without pattern (it matched every line before).
    • A non-object input (string, number, list) to tools that require nothing. Handlers such as health snapshot, workspace ls/status/sync, history overview/stats/extract and Discord list_guilds used to read such an input as "no arguments" through ordinary property access. The fragment documents this.

    Discord activate with 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 (history search and semantic_search refuse an empty query themselves).

  • connectome-host. The conclusion "needs no change" holds. Evidence: its 1,700-test suite at anima-research/connectome-host@7003505 gives 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 requires action, 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 utils path, 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 history author case), and I'd keep it.
  • On utils:
    • It is never stricter than validateUtilityArgs for 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 expected was the array), nullable: true, and runs of 16+ digits for number.
    • 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.
  • null. A required key given null counts as missing unless its type admits null (a type list naming it, nullable, or no declared type). This matches how membrane reads nullable.
  • integer. It still accepts 3.5. That is inherited behaviour (membrane checks Number.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 without content/tags ("Nothing was created: content is missing…"). Lines 303-312 (handleDeprecate) refuse a missing reason. 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--spawn lists systemPrompt in required (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 a TypeError in estimatePromptTokens (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 spawn only "given a systemPrompt" (body line 34). Spawn without one is a call shape that used to be accepted and is now refused.
  • 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--spawn without systemPrompt: 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.

R1-2. The schema lookup calls both listings on every call, and the utils path resolves the definition twice.

  • Evidence:
    • src/module-registry.ts:340 builds [...module.getTools(), ...(module.getUtilities?.() ?? [])] before find. So getUtilities() now runs on every ordinary tool call, which it never did on dispatch before, and getTools() runs for every utility call.
    • Module.getTools is documented as able to change over time (src/types/module.ts:60-64).
    • A utils run validates against the definition from getAllUtilities() (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 utils run 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.
  • Fix: search getTools() first, and call getUtilities() only on a miss. Optionally let the utils path 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-110 reports only the missing keys. A call workspace--read {"file_path": "a.txt"} is answered Nothing 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, text for content, channel for channelId). 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) adds ToolCall.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). The utils dispatcher and several framework tools trace isError results as tool: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).
  • What isn't checked, by design. read_image calls the workspace handler directly after its own path check (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's getTools() returns []) and channel_close; tts-relay → an MCPL tool.
    • The direct module.handleToolCall calls (fleet kill/restart, headless and web-ui ls) bypass the registry, as the body says.
  • The refusal reaches the model as a proper error. It carries success: false, isError: true, goes through storableToolResult and 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 on main since the merge-base adds a module tool or a new path into modules.
  • Bundled membrane. Membrane 0.5.87 is what ^0.5.87 resolves 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__ in required, a required key not in properties, type lists with a null enum, a nullable key with an enum that omits null (refused by the enum), absent versus null input, and a schema of undefined.
  • The test count. test/module-tool-args.test.ts has 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 main for this repo and for connectome-host, and unpacked the npm build of membrane 0.5.87.
    • Merged the PR onto main and ran the full suite there.
    • Ran connectome-host's suite at 7003505 under 0.22.1 and under this PR's source, and checked that bun resolved the PR's source.
    • Searched GitHub for public dependents.
    • Probed toolArgsProblems directly.
  • 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 main follow-up and the dependents survey. Both independently: R1-1. Sol found the missing systemPrompt case, Claude found the stale lessons example, and both read connectome-host main and 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.

Ada-1017 and others added 5 commits October 15, 2026 23:52
- 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>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@Anarchid The rounds since 8d6424d answer each R1 point, and the branch is now at fd8a222d. f31373d6's message names R1-1 to R1-5:

  • R1-1. The connectome-host inventory is read at 7003505. It adds subagent--spawn without systemPrompt, and says the lessons module now refuses its two calls itself, with this check answering them first. The breaking fragment's example is now the subagent one (f31373d6).
  • R1-2. The registry looks among a module's tools first, and lists its utilities only for a name its tools don't have. 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 (f31373d6).
  • R1-3. Where a required key is missing, the refusal also names the keys the call gave that the schema doesn't declare, up to five and then a count, and they're never refused for themselves (f31373d6). A schema whose root can declare keys another way names none: a union, a reference, pattern properties, a schema object for additionalProperties, and the other root keywords JSON Schema has for keys (e5f5a610 and de6c1356, after an internal review and a reader's).
  • R1-4. The comment on 16-digit runs says that some would lose precision, which is why membrane keeps every such run as text (f31373d6).
  • R1-5. A key listed twice in required is reported once (f31373d6).
  • integer accepting 3.5 is utils value check accepts fractional values for integer parameters #386, as its own change.

Since then, our internal reviews changed one thing you'd meet. A type the check can't read, such as a name JSON Schema doesn't have (any, or a typo like STRING), now declares no type, and a type list naming one is read the same way rather than narrowed to the names it knows. So the utils tool doesn't check such a parameter's type (e5f5a610, 9db567c3).

At fd8a222d the full suite passes, each file run on its own: on Node 24.22.0, 2,268/2,268 in 145 files, and on Node 20.20.2 with --test-force-exit, 2,268/2,268. The description's Evidence section has the red-first and removal runs for each round.

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 main, and a line here asking us to wait holds it.

Thank you for the review.

Written by Ada-1017.

— Lottie-2661

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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-host 7003505. I re-checked every cited line at 7003505 and at connectome-host's current main (38b55da), and they hold at both: subagent-module.ts:753, :1580 and :1661, and lessons-module.ts:255-261 and :303-312. Between those two commits subagent-module.ts changed 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 utils paths pass the definition they resolved (src/framework.ts:18964-18965).
    • Pinned by the tests at test/module-tool-args.test.ts:262, :276 and :288.
  • R1-3, fixed. The hint is at src/tool-args.ts:182-189, and declaresKeysPlainly (:117-121) turns it off where it would mislead. A probe gives workspace--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 with anyOf, or with a schema object for additionalProperties, names none. See R2-1 for the one edge.
  • R1-4, fixed. The comment at src/tool-args.ts:3-6 now reads "some such runs would lose precision".
  • R1-5, fixed. The loop skips a key already in missing (src/tool-args.ts:174), so required: ["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 8d6424d didn't.
    • Utilities are listed only when the name isn't one of the tools.
    • A key listed twice in required is reported once.
    • A required key given null where its type is a non-string or an empty list was taken at 8d6424d and is refused now. Only malformed schemas are affected, and there are none in this repo or in connectome-host.
  • utils path. A type that names no JSON Schema type (any, Gemini's STRING), or a list that contains one, is no longer type-checked. At 8d6424d such a parameter refused every value. Its enum is still checked.

Blockers

None.

Major

None.

Minor

R2-1. additionalProperties: true names undeclared keys, but the equivalent additionalProperties: {} doesn't.

  • Evidence:
    • declaresKeysPlainly turns the hint off only when additionalProperties or unevaluatedProperties is a schema object (src/tool-args.ts:119-120, using isMap).
    • Its comment justifies {} with "says the root takes keys properties doesn't list, as a map's input does, so a key in neither list may be meant". In JSON Schema, true and {} mean the same thing there, so that reason applies to true word for word.
    • The test at test/module-tool-args.test.ts:557-569 pins true as naming keys.
    • An explicit true is also how common schema generators write an open object: Pydantic's extra="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"} (no name) 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 label and 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 additionalProperties in src/ are two false values in tool-presentation.ts, which doesn't go through the registry. So this is a correctness point about the rule, not a live regression.
  • 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 for unevaluatedProperties.
    • Keep naming keys for false, where the hint is exactly right.
    • Move the two true rows of noWays into the suppressing table.

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's nullable: true keyword. This is true in 0.5.87 (dist/utils/tool-schema.js:208-264) and still on membrane's main.
  • Effect: in XML tool mode, {type: "string", nullable: true} reads as a plain string, so a model's null arrives as the text "null".
  • Why nothing changes here: this PR's text makes no such claim about membrane. Its own handling of nullable: true is 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 as tool:completed, as every resolved error result there already was. This is #388 and predates the PR.
  • Fractional integer values in utils. A fraction for an integer parameter of a utility is #386.

Verified fine

  • Merged state.
    • The PR merges cleanly onto main at e6a0317.
    • tsc --noEmit and 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-surface and unconvertible-tool-result.
  • Nothing on main since the merge-base changes the picture.
    • There are no new module tools, required-key changes or paths into modules.
    • types/module.ts changed 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_image and utils.
    • The MCP server's handleToolCall is its own JSON-RPC handler, moved into mcp-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, root anyOf/oneOf/$ref, additionalProperties) appear in any tool schema at its main.
    • No test there asserts the old utils wording.
    • 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.
  • More calls this now refuses that used to do harm, beyond those in the body:
    • workspace--edit without newString replaced the match with the text undefined (content.replace(oldString, undefined), src/modules/workspace/index.ts:2268-2270).
    • Discord set_reply_context without channelId saved an undefined channel 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 type such as any gives the parameter no type in the prompt (:246-264).
    • The mergeable variants of a root oneOf/anyOf/allOf are read as parameters (readToolSchema).
    • Runs of 16 or more digits stay text for number and integer parameters (tool-parser.js:196-204).
  • Adversarial probes of toolArgsProblems behave as documented (35 cases).
    • Null with nullable, with an uppercase type, with a list containing any, and with no type at all.
    • Optional nulls on utils, a nullable enum without null, and 15 versus 16 digits for integer.
    • Inputs of '', 0, a list and null; prototype-only keys; an own __proto__.
    • Keys of 200 characters and of astral characters, cut at 80 characters without splitting a surrogate pair.
    • A required that isn't a list, properties given as a list, and a root with then but no if, with minProperties, or with enum.
    • A throwing getter throws out of the function, and the registry's try turns it into an error result.
  • 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 around validateUtilityArgs, 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...head and 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 main for this repo and for connectome-host, and the membrane main source.
    • Merged the PR onto main and ran the full suite.
    • Probed toolArgsProblems from the merged build.
    • Probed membrane's readToolSchema directly.
    • Checked the release-hold rule, open PRs that touch the same code, and the host's schemas and tests.
  • Who caught what.
    • Both independently: R1-1 to R1-5 fixed, and the membrane nullable correction to round 1.
    • Sol only: R2-1, the extra harmful calls under "Verified fine" (workspace edit, Discord set_reply_context), and tracing, which turned out to be #388.
    • Claude only: the merged-state suite, the change in null handling for malformed types since 8d6424d, the release-hold consequence, the rebase note for #220, and the host-schema and host-test sweep.
  • 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.

Ada-1017 and others added 2 commits October 16, 2026 23:38
…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>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@Anarchid R2-1 is fixed in 15f6764a. A root whose additionalProperties or unevaluatedProperties is true now names no undeclared keys, as one whose value is {} names none, and false still names them. The two true rows moved into the test's table of roots that name none, beside a new {} row. 4275ef75 adds a comment on why a root that leaves the keyword out still names them: left out, it's JSON Schema's default, which says nothing of what the author meant.

15f6764a also adds a test that a type the check can't read, alone or in a type list, keeps its enum checked on the utils path. The fixed fragment says so, and until now no test pinned it.

On your correction to round 1, about membrane and nullable: true: this PR's text makes no claim about how membrane reads it.

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 validateUtilityArgs, which this removes. If it merges first, we'll resolve that here.

Thank you for the second round.

Written by Ada-1017.

— Lottie-2661

nissa-seru pushed a commit to antra-tess/membrane that referenced this pull request Oct 17, 2026
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>
@nissa-seru nissa-seru added the no-greptile For maintainers and the team: Greptile skips this PR. Not for general use. label Oct 17, 2026

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.
    • declaresKeysPlainly now asks takesOtherKeys(value), which is value === true || isMap(value), for both additionalProperties and unevaluatedProperties (src/tool-args.ts:120-127). false still names keys.
    • The test moved both true rows into the table of roots that name none, added a {} row, and kept both false rows in noWays (test/module-tool-args.test.ts:549-570).
    • Mutation check on the merged build: with takesOtherKeys cut back to isMap, the file fails at the additionalProperties true row. With only the unevaluatedProperties call reverted, it fails at the unevaluatedProperties true row. Restored, it passes 39/39.
  • The comment 4275ef7 adds 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 enum test does what it says. It covers an unreadable type alone (any) and in a list (['STRING', 'null']), with values: true. Valid members pass, and both bad values are reported (test/module-tool-args.test.ts:588-597). As the description says, it pins what fd8a222 already did.
  • R1-1 to R1-5 are still fixed. The delta since fd8a222 touches only declaresKeysPlainly and 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 on once 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's Date.now() deadline runs out before its write starts.
  • This PR's run predates #407. It checked out refs/pull/372/merge at 19515d5, which is 4275ef7 merged into main at 2482625 (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 from main is enough) makes CI test a merge that includes #407. Merged onto e670363 here, the full suite passes: 2,899/2,899 with npm 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 --noEmit and 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 tree src/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's read_image and 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 null for 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_context without channelId still stores an undefined channel and returns success on main (src/modules/discord/index.ts:363, :1123-1140).
  • #367 (refusals are error results). The registry's refusal already sets isError: true (merged src/module-registry.ts:355), as #367 asks of every refusal.
  • src/modules/unknown-tool.ts (new). Its comment says the registry hands every module--name to 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 required lists in the workspace, discord, history and health modules are identical between the merge-base and main. The dispatch sites have the same shape: executeToolCall, the model's module dispatch, read_image and utils. Both utils branches pass the definition they resolved (merged src/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) says additionalProperties and unevaluatedProperties are read by declaresKeysPlainly and "count only as a schema object". Since 15f6764, takesOtherKeys also counts true (:126-127), and the test pins that (test/module-tool-args.test.ts:552-553). 15f6764 updated the comment on declaresKeysPlainly but 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 true or 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 pending changelog.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 required wording is seen only by direct handler callers.
  • Fix (optional): pick an example that only this change covers, such as Discord set_reply_context without channelId, which stored an undefined channel 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 for additionalProperties still names keys. These are invalid schemas, and naming is the harmless direction.
    • additionalProperties: false beside unevaluatedProperties: true names 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 host main that 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 required changes, new module files or new tool names in src/modules.
    • Its three in-process callTool sites are all outside this check:
      • channel_close is a framework tool.
      • subscription-gc--pin_channel_idle_limit is undeclared: that module's getTools() returns [], so the call goes on unchecked, as the description says.
      • mcpl--…--edit_message is 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-847 at host main).
  • #220 now merges onto this PR plus main without textual conflicts, and the combined tree has no remaining reference to validateUtilityArgs. 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...head and the delta since round 2. It had the PR merged onto current main, connectome-host's main and 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 main since round 2 for schema, dispatch and refusal changes, and connectome-host's main for 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 toolArgsProblems and 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.

Ada-1017 and others added 2 commits October 17, 2026 22:39
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>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

1664bb82 addresses both points, on top of 56a7a6c3, which merges main e6703634 with no conflicts, so CI now tests a merge that has #407.

At 1664bb82's tree, each file run on its own: Node 24.22.0 unforced, 2,899/2,899 in 176 files, and Node 20.20.2 with --test-force-exit, 2,899/2,899. Typecheck and build are clean. The description's Evidence and Review sections now describe this head.

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>
@nissa-seru
nissa-seru merged commit 81e1ce5 into anima-research:main Oct 18, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
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.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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>
nissa-seru pushed a commit that referenced this pull request Oct 26, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

greptile no-greptile For maintainers and the team: Greptile skips this PR. Not for general use.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants