Skip to content

feat(params)!: recover an array param the model encoded as a JSON string - #479

Draft
drunkel wants to merge 1 commit into
mainfrom
params-recover-encoded-array
Draft

drunkel wants to merge 1 commit into
mainfrom
params-recover-encoded-array

Conversation

@drunkel

@drunkel drunkel commented Oct 7, 2026 •

Copy link
Copy Markdown

The problem

A model asked for an array param sometimes writes the array as a JSON string:

// asked for
{ "parts": [ { "id": 41, "html": "<p>x</p>" } ] }

// sometimes sent
{ "parts": "[{ \"id\": 41, \"html\": \"<p>x</p>\" }]" }

The data is correct — only the container is wrong. Params#validate rejects it with
parts must be a array before the tool body runs, so the whole turn is lost to a shape
mistake.

Why this belongs in riffer

Riffer already holds the principle this violates. coerce_value accepts a whole number for a
Float:

# JSON Schema "number" accepts integers; callers shouldn't get a type that
# depends on whether the model wrote a decimal point.
return value.to_f if type == Float

An array written as a string is the same thing, and it matters more: an unrecognised
Integer is harmless, an unrecognised String drops the call. There's also precedent for
lenient recovery of model output in the wrong wrapper — Agent::StructuredOutput::Parser
(#473) already recovers objects from code fences and prose. Params::Recovery mirrors it.

The change

Riffer::Params::Recovery.call runs immediately before the type check:

value = Riffer::Params::Recovery.call(param, value)

unless param.valid_type?(value)
  errors << "#{param.name}: expected #{param.type_name}, got #{json_type_name(value)}"
  next
end

Conservative by design. Only a value that decodes to an array is recovered, and the
decoded value is then validated normally — an item with a missing or wrong-typed field fails
exactly as before. A payload that decodes to an object, prose, or a truncated fragment is
returned untouched and still rejected: the intent there is ambiguous, and a tool acting on a
guess is worse than a tool reporting the error.

Parse failures are rescued rather than raised — partly so validation stays a validation
error, partly because JSON::ParserError embeds its input in the message and callers may be
passing sensitive content through params.

Proof

Both tests were written first and run red against main:

# Params#validate
-"rows[0].label must be a string"
+"rows must be a array"          ← call discarded, item never validated
3 failures, 1 error

# Riffer::Tools::Runtime, end to end
Expected: true   # tool ran
  Actual: false  # call discarded
1 failures

Green after the change. Coverage added in test/riffer/params/recovery_test.rb (the recovery
rules, including every shape deliberately not recovered), params_test.rb (validate-level),
and tools/runtime_test.rb (a real tool call, dispatched from the raw argument string).

Breaking change

A wrong-type error now names the received type too:

before after
parts must be a array parts: expected array, got string
price must be a number price: expected number, got boolean

Callers asserting on the old wording need updating; 4 tests here did. This also fixes the
ungrammatical a array / a object, which is how the gap surfaced.

Checks

  • rake test — 2632 runs, 4083 assertions, 0 failures
  • rubocop — 269 files, no offenses
  • bin/typecheck — no type errors (needed a manual sig for extend self, as Parser does)
  • rake rbs:generate — sigs regenerated
  • docs/TOOLS.md updated alongside the existing Float paragraph

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Array parameters can now accept JSON-encoded arrays, with decoded items still subject to normal validation.
    • Validation errors identify both the expected and received types.
  • Documentation
    • Clarified how JSON-encoded array parameters are handled, including cases that are rejected.

A model asked for an array param sometimes writes the array as a JSON string —
`"[{\"id\":1}]"` instead of `[{"id":1}]`. The data is correct; only the container
is wrong. Validation rejected it outright before the tool body ran, so the whole
turn was lost to a shape mistake.

Riffer already holds the principle this violates. coerce_value accepts a whole
number for a Float because "JSON Schema 'number' accepts integers; callers
shouldn't get a type that depends on whether the model wrote a decimal point".
The same is true of an array written as a string, and it matters more: an
unrecognised Integer is harmless, an unrecognised String drops the call.

Riffer::Params::Recovery decodes that one shape ahead of the type check, mirroring
Agent::StructuredOutput::Parser, which already recovers output wrapped in code
fences or prose. Only a value that decodes to an array is recovered; the decoded
value is then validated normally, so an item with a missing or wrong-typed field
fails as before. A payload that decodes to an object, prose, or a truncated
fragment is returned untouched and still rejected — the intent is ambiguous there,
and a tool acting on a guess is worse than a tool reporting the error. Parse
failures are rescued rather than raised, both because validation must stay a
validation error and because the parser embeds its input in the message.

BREAKING CHANGE: a wrong-type validation error now names the type received as well
as the one expected — "parts: expected array, got string" rather than "parts must
be a array". Callers asserting on the old wording need updating. The old message
named only the expectation, so a model given it had nothing to correct and would
sometimes repeat the same mistake; it also read ungrammatically for array and
object, which is how the gap surfaced.
@drunkel
drunkel requested a review from a team as a code owner October 7, 2026 18:17
@drunkel
drunkel requested a review from sterlingwes October 7, 2026 18:17
@coderabbitai

coderabbitai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Central YAML (base), Organization UI (inherited)
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: fa09d810-71e3-47a6-9ac1-a65773c38720
📥 Commits

Reviewing files that changed from the base of the PR and between 59a6d40 and 28d2ed7.

⛔ Files ignored due to path filters (2)
  • sig/generated/riffer/params.rbs is excluded by !**/generated/**
  • sig/generated/riffer/params/recovery.rbs is excluded by !**/generated/**
📒 Files selected for processing (7)
  • docs/TOOLS.md
  • lib/riffer/params.rb
  • lib/riffer/params/recovery.rb
  • sig/manual/riffer/params/recovery.rbs
  • test/riffer/params/recovery_test.rb
  • test/riffer/params_test.rb
  • test/riffer/tools/runtime_test.rb

Included review availability: This review used your included allowance. 3 included reviews remain after this review. Your included PR review attempts over the past 7 days set your current allowance at 5 reviews per hour.

📜 Recent review details
⏰ Context from checks skipped due to timeout. (9)
  • GitHub Check: test 4.0
  • GitHub Check: test 3.4
  • GitHub Check: test 3.3
  • GitHub Check: test-no-otel
  • GitHub Check: lint
  • GitHub Check: docs
  • GitHub Check: rbs
  • GitHub Check: Cycode: Secrets
  • GitHub Check: Cycode: SAST
🔇 Additional comments (7)
lib/riffer/params/recovery.rb (1)

25-30: LGTM!

Also applies to: 38-42

sig/manual/riffer/params/recovery.rbs (1)

1-5: LGTM!

test/riffer/params/recovery_test.rb (1)

5-65: LGTM!

lib/riffer/params.rb (1)

81-81: LGTM!

Also applies to: 84-86, 234-249

test/riffer/params_test.rb (1)

107-107: LGTM!

Also applies to: 385-385, 393-393, 401-401, 451-479

test/riffer/tools/runtime_test.rb (1)

56-87: LGTM!

docs/TOOLS.md (1)

120-123: LGTM!


📝 Walkthrough

Walkthrough

Array parameters now accept JSON-encoded strings when decoding produces an array. The decoded value goes through normal type validation. Type errors report both expected and received types.

Changes

Array parameter recovery

Layer / File(s) Summary
Decode JSON array values
lib/riffer/params/recovery.rb, sig/manual/riffer/params/recovery.rbs, test/riffer/params/recovery_test.rb
Recovery.call parses string values for Array parameters and returns decoded arrays with symbolized object keys. Tests cover decoded arrays and inputs left unchanged.
Validate recovered values
lib/riffer/params.rb, test/riffer/params_test.rb, test/riffer/tools/runtime_test.rb, docs/TOOLS.md
Params#validate recovers non-nil values before type validation. Type errors report expected and received types. Tests cover nested validation and tool execution with a JSON-encoded array.

Priority: ⬇️ Low

Estimated code review effort: 2 (Simple) | ~12 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant NotesTool
  participant Params
  participant Recovery
  NotesTool->>Params: Validate parts parameter
  Params->>Recovery: Recover JSON string
  Recovery-->>Params: Return decoded array or original value
  Params->>Params: Validate array and nested items
  Params-->>NotesTool: Return validated parts or validation error
Loading

Merge Risk: ⚪ Minimal · up to 28d2e

Array parameters now accept JSON-encoded array strings, and type errors report both the expected and received types. The change is limited in scope and tested. Callers that match the old "must be a" wording will need updating, as the PR states.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 9 functions across 5 files. (2 skipped: 2… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary change: recovering array parameters encoded as JSON strings.
Description check ✅ Passed The description includes all required sections: Problem, Solution, and Proof. It explains the motivation, conservative recovery behavior, breaking error-message change, tests, and verification results…
Full details: Docstring Coverage

Explanation

Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 9 functions across 5 files. (2 skipped: 2 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@drunkel
drunkel marked this pull request as draft October 7, 2026 18:18
@bottrall
bottrall requested review from bottrall and removed request for bottrall and sterlingwes October 7, 2026 18:19

This branch has not been deployed

No deployments
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