Skip to content

Parse and validate tool inputs at the Core boundary - #165

Open
cunninghamcard-bit wants to merge 20 commits into
arcships:masterfrom
cunninghamcard-bit:codex/tool-input-parse-repair
Open

Parse and validate tool inputs at the Core boundary#165
cunninghamcard-bit wants to merge 20 commits into
arcships:masterfrom
cunninghamcard-bit:codex/tool-input-parse-repair

Conversation

@cunninghamcard-bit

@cunninghamcard-bit cunninghamcard-bit commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Move tool-call input parsing and validation to the Core boundary, following the AI SDK parseToolCall / repairToolCall contract.

  • Providers return the model's raw tool-argument text.
  • generate_text and stream_text parse exact JSON and validate it against the selected function tool's JSON Schema.
  • Invalid calls remain in the model output with invalid: true and a typed error instead of being dropped or silently passing an unparsed string through.
  • An optional Rust repair_tool_call callback gets one attempt; its replacement is parsed and validated again from scratch.

Behavior

  • Unknown tools produce NoSuchTool, including the available tool names when a tool set was supplied.
  • Malformed or schema-invalid arguments produce InvalidToolInput, preserving the original argument text and parsed value when available.
  • A failed repair produces ToolCallRepair, preserving both the original validation error and the repair failure.
  • Provider-executed dynamic calls bypass local tool lookup, matching the AI SDK behavior.
  • do_stream continues to expose raw serialized arguments; the user-facing stream_text result exposes parsed input after the complete tool call is assembled.

This is a breaking behavior change in two places:

  • Callers that relied on invalid provider arguments being accepted as ordinary tool input now receive an invalid: true call with a typed error.
  • GenerateContent::ToolCall.input in GenerateResult.raw.content changes from the provider's parsed JSON value to the raw argument text (a JSON string) for valid calls as well. Deserialization accepts both the legacy value shape and the new string shape, so persisted results still load; serialization always emits the string. Documented in docs/api/gaps.md §9.

Public contract and bindings

  • Adds the three tool-contract variants above, bringing AiMuxError to 15 wire variants. The C ABI assigns three distinct new codes: AIMUX_E_NO_SUCH_TOOL = 15, AIMUX_E_INVALID_TOOL_INPUT = 16, AIMUX_E_TOOL_CALL_REPAIR = 17, mirrored in every binding and its docs.
  • Carries provider_metadata, invalid, and error through the top-level and streaming tool-call surfaces in Node, Python, Go, Java, Kotlin, Swift, and Flutter.
  • Keeps raw provider content verbatim while parsed input remains available on the top-level tool calls.
  • Hardens the golden error contract with a compile-time exhaustive match so a future variant addition cannot bypass the wire-tag test.

repair_tool_call is serde-skipped and cannot cross the JSON/FFI boundary, so only Rust callers can install the callback. Other bindings still receive the invalid call and typed failure as data and can implement an application-level repair loop. This limitation is recorded in docs/api/gaps.md.

Scope

This PR is based directly on master and is independent of #164. It contains no operation-retry, timeout-pipeline, or recoverable-stream changes.

Verification

  • cargo fmt --all -- --check
  • Clippy with warnings denied for Core, provider-utils, providers, and FFI
  • Full aimux-core and aimux-providers test suites
  • Focused tool-input, error-contract, provider-utils, and FFI mapping tests
  • ts-rs generated-type drift check and Node typed-wrapper typecheck
  • Node and Python binding Rust checks
  • Targeted Flutter/Dart, Go, Java, and Swift binding checks

@cunninghamcard-bit
cunninghamcard-bit force-pushed the codex/tool-input-parse-repair branch 6 times, most recently from 17420c6 to c4d46a7 Compare August 24, 2026 05:49
@cunninghamcard-bit
cunninghamcard-bit force-pushed the codex/tool-input-parse-repair branch 2 times, most recently from c61092d to cdb2497 Compare August 25, 2026 01:27
@cunninghamcard-bit
cunninghamcard-bit marked this pull request as ready for review August 25, 2026 01:42
Providers deliver the model's raw tool-argument text; Core parses it as
exact JSON and validates it against the function tool's JSON Schema,
following the AI SDK parseToolCall contract. Invalid calls are returned
as tool calls marked invalid with a typed error (NoSuchTool /
InvalidToolInput / ToolCallRepair) instead of silently passing raw
strings through. The optional repair_tool_call callback gets one attempt
and its replacement is revalidated from scratch. Provider-executed
dynamic calls bypass the tool-set lookup, as in the AI SDK.

The C ABI reports the new variants under the existing Tool error code;
Node types carry them fully.
The golden variant-set test only asserted the wire tags of the variants it
already knew, so an added variant sailed past a test named "exactly
thirteen". Extend the list to the three tool-contract variants (the dead
legacy catch-all Tool is gone), snapshot their exact JSON payloads, and add
a compile-time exhaustive match, so any future addition fails the build of
this test instead of relying on the runtime assertion.
@cunninghamcard-bit
cunninghamcard-bit force-pushed the codex/tool-input-parse-repair branch 2 times, most recently from cd589d5 to 716d40a Compare August 25, 2026 01:49
CI's clippy is newer than the workspace MSRV allows us to follow:
as_chunks::<N>() is stable only since Rust 1.88 while the MSRV is 1.85,
so trace/hash.rs and openai/embedding.rs keep chunks_exact behind
unknown_lints; ws.rs's tungstenite error is consumed by its single
caller, so boxing it buys nothing. Same attributes and wording as
arcships#163/arcships#164, so whichever lands second is a no-op in these files.
@cunninghamcard-bit
cunninghamcard-bit force-pushed the codex/tool-input-parse-repair branch from 716d40a to deddae6 Compare August 25, 2026 01:49
@cunninghamcard-bit
cunninghamcard-bit marked this pull request as draft August 25, 2026 03:50
… end

Port of the follow-up round built in the stacked worktree, rebased onto the
master-based branch:

- generate: assemble response messages in stream order (text/reasoning
  segment flushing, provider_executed on replayed tool calls), matching
  toResponseMessages; response_tool_call_input replaces the narrower
  response_message_input (JS 'typeof x === "object"' also admits arrays).
- openai outputs: buffer raw tool arguments across repair so invalid
  streams replay verbatim.
- anthropic (direct + vertex): collapse bash/text-editor code-execution
  variants into the caller's single code_execution tool; the wire name
  survives in the normalized input 'type'. Google/xAI/HuggingFace
  conversions aligned the same way.
- bindings: thought_signature + provider_executed + arbitrary-JSON
  provider_metadata on ToolCall across Go/Java/Kotlin/Swift/Flutter/
  Node/Python; wire-format fixtures extended.

Deliberately not ported (coupled to the retry PR's machinery, restored
when that branch rebases onto this one): timeout/abort racing of the
repair callback and its six tests, recoverable stream-frame handling,
anthropic_stream_error.
Mirror the AI SDK's file split: to-response-messages logic moves out of
generate.rs into response_messages.rs as a ResponseMessageBuilder that owns
all assembly state (parts, text/reasoning buffers, provider options, the
reasoning aggregate). Both the streaming consume loop and the non-streaming
content loop shrink to per-part method calls; the twenty-odd inline flush
sequences collapse into the builder's flush discipline.

Also aggregate the remaining provider duplication: the streamed tool-input
finalizer (empty input -> "{}" + code-execution wire-name re-wrap) that was
inlined in both the Anthropic and Vertex-Anthropic stream loops becomes
anthropic::stream::finalize_streamed_tool_input. raw_tool_input moves next
to the rest of the parse contract in tool.rs.
tool.rs mixed the tool type definitions with the parse contract. Match the
AI SDK's split: parse_tool_call.rs now owns RawToolCall, the repair callback
and its context, parse_tool_call, JSON parsing with the prototype-pollution
guard, schema validation, and raw_tool_input; tool.rs keeps only the types
(Tool/FunctionTool/ProviderTool/ToolCall/ToolResult/ToolChoice).

No compatibility re-exports: importers move to aimux_core::parse_tool_call.
GenerateContent::ToolCall.input was a serde_json::Value that only ever
carried the provider's raw argument text wrapped in Value::String — the
contract lived in a doc comment, so nothing stopped a provider from parsing
the arguments itself and handing Core a structured value. Typing the field
as String makes 'providers never parse tool input' a compile error to
violate, and drops the unwrap dance on the Core side.

The wire format is unchanged: Value::String(text) and String serialize
identically, so no binding or fixture moves.

StreamPart::ToolCall.input stays a Value: unlike GenerateContent it is
dual-use — providers emit raw text, Core replaces it in-flight with the
parsed value, and the user-facing stream sees the parsed one. Separating
those two directions means splitting the stream-part type the way the AI SDK
splits LanguageModelV2StreamPart from TextStreamPart, which is its own PR.
…rse-repair

# Conflicts:
#	aimux-provider-utils/src/ws.rs
- anthropic/stream.rs: clippy 1.98's manual_unwrap_or fires on the
  wire-name match. Its suggested `unwrap_or` is not equivalent (an
  unrecognized Some(name) must collapse to code_execution, not pass
  through), so express the same rule with filter + unwrap_or.
- kotlin Types.kt: the merge added providerMetadata to ToolCall twice,
  which kotlinx.serialization rejects as a duplicate serial name.

@eric8810 eric8810 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The Core-owned parsing direction is necessary and broadly matches the intended upstream behavior, but this version still has two correctness regressions and one understated compatibility break that should be resolved before merge. In particular, invalid primitive JSON arguments are not preserved exactly in OpenAI-compatible output, and Cohere streaming still terminates on malformed arguments instead of delegating parsing to Core. I ran the focused Core/provider suites (63 tests passed) and added a temporary regression test that reproduces the primitive-string corruption (\"hello\" is emitted as hello). Please also update the PR description: the wire enum has 15 variants, and the C ABI assigns three distinct new error codes (15/16/17).

Comment thread aimux-core/src/openai_output.rs Outdated
match input {
// Core uses a string carrier to retain malformed input on invalid
// calls; emitting it verbatim avoids adding a second JSON layer.
Value::String(raw) if invalid == Some(true) => raw.clone(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Preserve the exact raw arguments instead of overloading Value::String. A syntactically valid primitive such as \"hello\" becomes Value::String(\"hello\") when its schema expects an object. This branch then emits hello, which is no longer valid JSON; Value::Null is likewise rewritten to {} below. The temporary regression test reproduces this. Please keep the provider's raw argument text as a separate field/representation so valid JSON strings, malformed text, and null remain distinguishable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in d079814 / 1bc4866 / b76361b: invalid calls render the raw text carried by InvalidToolInput.tool_input (also unwrapped through ToolCallRepair.original_error), valid calls render compact JSON of the parsed value, null stays null, blank text renders as {}. One table-driven end-to-end test covers the three error shapes. Known gap, documented in code: NoSuchTool with a bare JSON-string argument still renders unquoted, since that variant carries no raw text (same as the AI SDK).

Comment thread aimux-providers/src/cohere/model.rs Outdated
provider_metadata: None,
});
let text = if trimmed.is_empty() { "{}" } else { trimmed };
match serde_json::from_str::<Value>(text) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Let Core parse malformed Cohere streaming arguments. This provider-side parse turns malformed tool input into a terminal stream error, so the call never reaches the new Core boundary as a retained invalid: true tool call. It also differs from the non-streaming Cohere path. Please emit the original accumulated text in StreamPart::ToolCall and let Core own parsing, validation, and repair as this PR promises.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 61e0076: tool-call-end forwards the accumulated text verbatim (blank → {}) and no longer ends the stream on a parse failure; Core owns parsing, validation and repair on both paths. malformed_streamed_tool_call_arguments_do_not_error_the_stream asserts the stream completes with an invalid: true call. No other provider parses provider-side.

Comment thread aimux-core/src/result.rs
/// The model's raw argument text, exactly as the provider delivered
/// it (possibly malformed). Providers never parse it — `generate_text`
/// owns parsing, schema validation, and repair.
input: String,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Account for the public raw-result shape change. This changes GenerateResult.raw.content for valid calls too: callers that previously received a JSON object now receive JSON-encoded text, and persisted object-shaped results no longer deserialize into this type. The PR currently describes the break as affecting callers that relied on invalid provider arguments, which understates the scope. Please either provide a compatibility path or explicitly document the full migration and cover it in cross-binding serialization tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed the break was broader than stated. Fixed in 177aeed / 2ec1f5e: GenerateContent::ToolCall.input deserializes from both the legacy JSON value and the new string (legacy values re-serialize to compact text) and always serializes as a string; one test loads both shapes. docs/api/gaps.md §9 now documents the wire shape before and after, and the PR description is corrected.

parsed_tool_call_arguments() used Value::String plus the invalid flag as a
carrier for the provider's raw argument text, but a legitimately parsed JSON
string (e.g. "hello") and malformed text wrapped as a string fallback both
serialize to the identical Value::String, so a valid quoted string got
re-emitted unquoted (invalid JSON), and Value::Null was unconditionally
rewritten to "{}" even for valid calls.

AiMuxError::InvalidToolInput already carries tool_input: the byte-for-byte
provider text, set from RawToolCall.input on both the JSON-parse-failure and
schema-validation-failure paths. Use it directly for invalid calls instead of
re-deriving anything from the parsed Value, and drop the Null special case so
a valid call's null round-trips as null instead of {}. Threads the resolved
AiMuxError into the four call sites (streaming StreamPart::ToolCall and the
non-streaming generate_text_as_openai path) that build OpenAI-compatible
tool_calls[].function.arguments.
…self

Cohere's tool-call-end handler parsed the accumulated arguments and treated
a parse failure as a terminal stream Error, unlike every other streaming
provider (which forwards the raw text unparsed) and unlike Cohere's own
non-streaming path. A malformed streamed call never reached Core as a
retained invalid: true tool call — it aborted the whole stream instead.

Forward the trimmed accumulated text verbatim (empty still defaults to "{}"
to match the TS provider), unparsed, and let Core's parse_tool_call own
JSON parsing, schema validation, and repair, exactly as it already does for
every other provider. Audited every other StreamPart::ToolCall construction
site in aimux-providers (open_responses, xai, google, bedrock, anthropic,
vertex, huggingface, mistral, openai) for the same anti-pattern; none of
them parse provider-side.

should_stream_tool_call_deltas no longer expects compact-reserialized
arguments (interior whitespace from the deltas now survives, since nothing
parses and re-serializes it), and a new test drives a malformed streamed
call through both do_stream() directly (no Error, raw text forwarded) and
stream_text() (Core keeps it as an invalid: true call with a typed
InvalidToolInput error instead of erroring the stream).
…input

GenerateContent::ToolCall.input changed from serde_json::Value to String
(providers now hand Core raw text; Core owns parsing). The PR description
called this a break only for invalid arguments, but it understated the
blast radius: a GenerateResult persisted or replayed from before the
refactor — back when tool input was already-parsed and object/array-shaped
on this field — no longer deserializes at all, valid calls included.

Add a deserialize_with that accepts both shapes: a JSON string loads
unchanged (the current, and only, wire shape this version writes), and any
other JSON value re-serializes to its compact JSON text. Serialization is
untouched (always a plain string). Documents the wire shape and the
compatibility behavior in docs/api/gaps.md §9.

Regression tests cover both directions (a legacy object-shaped payload and
the current string-shaped payload both load to the same value) and every
legacy value shape (array/number/bool/null), plus that serialization never
regresses to the legacy shape.
…r calls

The first tool-input-parse-repair fix on this branch only recovered the
provider's verbatim raw text from AiMuxError::InvalidToolInput, missing two
other invalid-call paths that hit the same Value::String ambiguity:

- ToolCallRepair (a failed repair *callback*, not a re-validation failure)
  wraps the pre-repair error as `original_error` without being unwrapped, so
  parsed_tool_call_arguments fell through to re-deriving text from the
  ambiguous Value.
- NoSuchTool carries no tool_input field at all — like the AI SDK, it fires
  off the tool name alone, before the arguments are looked at — so
  invalid_tool_call's best-effort `serde_json::from_str` still ran and a
  valid quoted string such as "hello" lost its quotes on the way back out.

parse_tool_call::invalid_tool_call now skips the best-effort parse entirely
for NoSuchTool (recursing through ToolCallRepair.original_error), storing the
unparsed raw text in `input: Value::String` instead — the same contract
InvalidToolInput's fallback already had for malformed text, just applied
before any parse attempt. openai_output::parsed_tool_call_arguments gained a
raw_tool_call_text helper that reads InvalidToolInput.tool_input, unwraps one
level of ToolCallRepair, or reads the now-guaranteed-unparsed Value::String
for NoSuchTool. AiMuxError's own shape and wire format are untouched — this
only changes how ToolCall.input and the rendered OpenAI arguments text are
derived from it.
The previous commit stopped invalid_tool_call from parsing the raw text for
NoSuchTool, to keep a bare JSON string (`"hello"`) from losing its quotes on
the OpenAI-compat arguments wire. That traded a rare cosmetic glitch for a
common data loss: response_messages only carries a structured input into the
next turn's transcript (matching to-response-messages.ts, which replaces a
non-object invalid input with `{}`), so a model calling a misspelled tool
with perfectly good `{"city":"Tokyo"}` arguments replayed as `{}` — the
arguments were dropped.

Restore the AI SDK's unconditional best-effort parse (parse-tool-call.ts
catch-all: parsed value when the text is valid JSON, verbatim text
otherwise) for every invalid-call error, NoSuchTool included, and drop
input_was_never_parsed. raw_tool_call_text keeps its NoSuchTool arm: a
string left on `input` is either malformed text kept verbatim or a genuine
JSON string, indistinguishable without a raw-text field on the error, and
only the malformed case occurs in practice — now documented as such.
Blank argument text parses as `{}` and can still fail schema validation
against required properties. On that path parsed_tool_call_arguments
returned the recovered raw text verbatim — the empty string — which is not
valid JSON in an OpenAI `tool_calls[].function.arguments` field.
to_chat_completion already normalized empty text to `{}` for the unparsed
non-streaming path; apply the same rule to the parsed renderer, and make
to_chat_completion's check whitespace-tolerant so the two agree.
gaps.md §9 and the deserializer's own doc comment both claimed the wire
format was unchanged for results written by this version, because providers
had "only ever put the raw text in a Value::String". Neither is true: on
master the providers parsed their own arguments into that field (e.g.
openai/model.rs ran serde_json::from_str, google/model.rs stored the
response object directly), so `input` really did go from
`{"city":"Paris"}` to `"{\"city\":\"Paris\"}"` — as this PR's own
contract-tests/fixtures/wire-format.json change shows.

State the before/after wire shape, that deserialization accepts both, and
that the parsed value still reaches callers on
GenerateTextResult.tool_calls[].input; drop the rest.
Six parsed_tool_call_arguments unit tests and three near-identical
end-to-end stream tests covered one contract with different data: the raw
argument text of an invalid call must reach an OpenAI-compatible client
verbatim. Fold them into one table-driven end-to-end test over the three
distinct error shapes (schema-rejected JSON string, NoSuchTool, failed
repair callback), which also pins the error/`input` pairing parse_tool_call
produces — something the hand-built unit tests could not.

Keeps the two tests pinning separate contracts (a valid `null` input is not
rewritten to `{}`; blank text is). Behaviour unchanged: 286 deleted,
66 added.
Three tests covered the compatibility deserializer: one loading both wire
shapes, one repeating that over every non-string JSON value, and one
asserting serialization stays a string. The second only varied the data; the
third is two lines inside the first. Keep one test that loads both shapes
and checks the round-trip.
The golden error snapshots pinned NoSuchTool-with-available-tools,
InvalidToolInput, and ToolCallRepair twice each, the second copy differing
only in its data — the wire shape is what the snapshot pins. Keep one case
per shape (NoSuchTool stays twice: `skip_serializing_if` makes its payload
vary with `available_tools`).

The provider-utils test asserting three error_variant log reasons only
restated three string constants from a match a compile error already guards.
@cunninghamcard-bit
cunninghamcard-bit marked this pull request as ready for review September 3, 2026 16:55
@cunninghamcard-bit

Copy link
Copy Markdown
Contributor Author

@eric8810 All three findings are addressed (replies on each thread point to the commits) and the description is corrected: 15 wire variants, three distinct C codes 15/16/17, and the full raw.content shape change with a compatibility deserializer. Two further fixes from auditing the diff: unknown-tool calls no longer lose their arguments when replayed into the transcript, and blank invalid arguments render as {} instead of an empty string. Marked ready for review; all 24 checks are green on 2648269.

@eric8810

Copy link
Copy Markdown
Contributor

Review follow-up: all three findings from my Sept 3 review are addressed — raw tool-call arguments are now preserved verbatim (including the 09-07 follow-up for calls that remain invalid), Cohere streaming no longer parses tool-call input locally and delegates to Core, and the description correctly states 15 wire variants with C ABI codes 15/16/17. Thank you.

The remaining blocker is mechanical: #164 was squash-merged to master as d5b8323, and this branch now conflicts. Please rebase onto current master and re-push so CI re-runs. As anticipated in the PR description, the overlap should be mostly textual; the three clippy-#[allow] files (trace/hash.rs, openai/embedding.rs, ws.rs) should resolve as no-ops. Once the rebase lands green I'll dismiss my review and merge.

@eric8810 eric8810 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

you need to do rebase

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.

2 participants