Skip to content

ZBBS-WORK-406: coerce whole-structure tool args that llama over-stringifies (string→array/object)#238

Merged
jeffdafoe merged 2 commits into
mainfrom
zbbs-work-406-coerce-stringified-array-object
Jun 15, 2026
Merged

ZBBS-WORK-406: coerce whole-structure tool args that llama over-stringifies (string→array/object)#238
jeffdafoe merged 2 commits into
mainfrom
zbbs-work-406-coerce-stringified-array-object

Conversation

@jeffdafoe

Copy link
Copy Markdown
Owner

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 speak tool's mentions (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's Array.isArray(value) guard skipped it, the string reached the Salem engine's strict []SpeakMention decode, and failed as malformed_args. Live impact: every speak carrying a mentions field 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/object branches of coerceToSchema: if value is a string, JSON.parse it 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 calls coerceToolArgs). Also covers scene_quote.consumers / pay_with_item.consumers if 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 exact mentions:"[]", 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

Jeff Dafoe and others added 2 commits June 15, 2026 09:21
…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 jeffdafoe merged commit 4b67c3d into main Jun 15, 2026
4 checks passed
@jeffdafoe jeffdafoe deleted the zbbs-work-406-coerce-stringified-array-object branch June 15, 2026 13:25
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant