ZBBS-WORK-406: coerce whole-structure tool args that llama over-stringifies (string→array/object)#238
Merged
Conversation
…gifies (string->array/object)
coerce.js (HOME-342) un-stringifies over-stringified SCALAR tool args from
OpenRouter-fronted models, but its array/object branches only fired when the
structure itself already arrived as a real array/object. llama-3.3-70b (every
stateful Salem NPC) also over-stringifies the WHOLE structure: speak's
`mentions` arrives as "[]" / "[{\"item\":...}]" (a JSON string), the array
branch's Array.isArray(value) guard skipped it, and the string reached the
Salem engine's strict []SpeakMention decode and failed as malformed_args.
Net effect: every speak carrying a mentions field fails, so the llama NPCs
can't reliably talk -- observed live on Hannah Boggs 2026-06-15 (a whole turn
of ~20 failed speaks).
Extend coerceToSchema's array/object branches: if value is a string, JSON.parse
it and adopt only when it yields the matching structure, else leave it for
downstream to reject (conservative, matches the module's existing design). The
nested recursion then coerces inner scalars as before. Stays OpenRouter-scoped
(only that provider calls coerceToolArgs). Also covers scene_quote.consumers /
pay_with_item.consumers if llama stringifies one of those wholesale.
Verified with a standalone script against the real coerce.js: Hannah's exact
mentions:"[]" case, nested stringified price, real-array no-regression,
non-JSON / non-array left untouched, scalar coercion intact, stringified
object recovery, and no __proto__ pollution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ype keys - object branch: `parsed === null` instead of `!parsed` (clearer; behavior- equivalent, since null is the only falsy value with typeof === 'object'). - harden the object property loop: unconditionally skip __proto__/constructor/ prototype keys before the hasOwnProperty guard, so a bad/hostile schema can't drive an assign-through. The existing guard already blocked a model-supplied __proto__; this also covers a schema-declared one. code_review's nullable-union note (["array","null"]) needs no change: the union already resolves to the non-null type via singleNonNullType at the top of coerceToSchema. Added a regression case proving it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jeffdafoe
added a commit
that referenced
this pull request
Jun 16, 2026
…gifies (string→array/object) (#238) * ZBBS-WORK-406: coerce whole-structure tool args that llama over-stringifies (string->array/object) coerce.js (HOME-342) un-stringifies over-stringified SCALAR tool args from OpenRouter-fronted models, but its array/object branches only fired when the structure itself already arrived as a real array/object. llama-3.3-70b (every stateful Salem NPC) also over-stringifies the WHOLE structure: speak's `mentions` arrives as "[]" / "[{\"item\":...}]" (a JSON string), the array branch's Array.isArray(value) guard skipped it, and the string reached the Salem engine's strict []SpeakMention decode and failed as malformed_args. Net effect: every speak carrying a mentions field fails, so the llama NPCs can't reliably talk -- observed live on Hannah Boggs 2026-06-15 (a whole turn of ~20 failed speaks). Extend coerceToSchema's array/object branches: if value is a string, JSON.parse it and adopt only when it yields the matching structure, else leave it for downstream to reject (conservative, matches the module's existing design). The nested recursion then coerces inner scalars as before. Stays OpenRouter-scoped (only that provider calls coerceToolArgs). Also covers scene_quote.consumers / pay_with_item.consumers if llama stringifies one of those wholesale. Verified with a standalone script against the real coerce.js: Hannah's exact mentions:"[]" case, nested stringified price, real-array no-regression, non-JSON / non-array left untouched, scalar coercion intact, stringified object recovery, and no __proto__ pollution. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ZBBS-WORK-406: code_review fixes -- explicit null check + skip prototype keys - object branch: `parsed === null` instead of `!parsed` (clearer; behavior- equivalent, since null is the only falsy value with typeof === 'object'). - harden the object property loop: unconditionally skip __proto__/constructor/ prototype keys before the hasOwnProperty guard, so a bad/hostile schema can't drive an assign-through. The existing guard already blocked a model-supplied __proto__; this also covers a schema-declared one. code_review's nullable-union note (["array","null"]) needs no change: the union already resolves to the non-null type via singleNonNullType at the top of coerceToSchema. Added a regression case proving it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
OpenRouter-fronted models (esp.
meta-llama/llama-3.3-70b-instruct, which every stateful Salem NPC runs) over-stringify function-call arguments.coerce.js(ZBBS-HOME-342) already normalizes over-stringified scalars against the offered tool schema, but its array/object branches only fired when the structure itself already arrived as a real array/object.llama also over-stringifies the whole structure. The
speaktool'smentions(schema: array of{item, price:int}) arrives as a JSON string:{"to":"none","text":"...","mentions":"[]"}{"to":"Ezekiel Crane","text":"...","mentions":"[{\"item\":\"porridge\",\"price\":3}]"}coerceToSchema'sArray.isArray(value)guard skipped it, the string reached the Salem engine's strict[]SpeakMentiondecode, and failed asmalformed_args. Live impact: everyspeakcarrying amentionsfield fails → the llama NPCs (all stateful Salem NPCs) can't reliably talk. Observed on Hannah Boggs 2026-06-15 — a whole turn of ~20 consecutive failed speaks.Fix
Extend the
array/objectbranches ofcoerceToSchema: ifvalueis a string,JSON.parseit and adopt it only when it yields the matching structure; otherwise leave the original string for the engine to reject precisely. Existing nested recursion coerces inner scalars as before. Conservative (matches the module's design), stays OpenRouter-scoped (only that provider callscoerceToolArgs). Also coversscene_quote.consumers/pay_with_item.consumersif llama stringifies one of those wholesale.Verification
No test runner in this repo (per the HOME-342 pattern: review + live). Verified with a standalone script against the real
coerce.js— 8 cases all pass: Hannah's exactmentions:"[]", nested stringified price, real-array no-regression, non-JSON / non-array-string left untouched, scalar coercion intact, stringified-object recovery, and no__proto__pollution.— Work
🤖 Generated with Claude Code