feat(ffi): repair_tool_call for every binding via handles in opts_json - #186
Draft
cunninghamcard-bit wants to merge 19 commits into
Draft
cunninghamcard-bit wants to merge 19 commits into
cunninghamcard-bit wants to merge 19 commits into
Conversation
… by handle
`opts_json` is now the whole `GenerateTextOptions` in the AI SDK shape. The
two fields Core marks `serde(skip)` because they hold live objects are passed
as handles and resolved in `parse_opts_arg`:
{"tools": [...], "abort_signal": <handle>, "repair_tool_call": <handle>}
- `aimux_tool_call_repair_new(fn, user_data)` registers a host function with
the AI SDK `repairToolCall` contract: it receives {tool_call, error,
input_schema, tools, messages, instructions} as JSON and returns the
repaired RawToolCall JSON or NULL. It runs synchronously on the calling
thread inside the re-entrancy guard, like the stream callbacks.
- `aimux_string_new` lets a host hand a string back to aimux; aimux frees it.
- Every existing entry point honors the handle fields with no signature
change; the explicit `abort_handle` of the *_with_abort variants still wins.
`NewToolCallRepair(fn)` registers a Go `ToolCallRepairFunc` through `aimux_tool_call_repair_new` and marshals as its handle, so it sits in `GenerateTextOptions.RepairToolCall` and reaches Core via opts_json like any other option. The function receives the AI SDK repairToolCall context and returns the repaired RawToolCall or nil; Go errors and panics stay on the Go side (`Err()`) and leave the original validation error on the tool call.
…a ThreadsafeFunction
…dle (unverified locally: no dart toolchain)
… hook Every Python entry point drives the shared tokio runtime with block_on; a hook that called back into aimux nested block_on, which tokio rejects with a panic that PyO3 then resumed on the way out of the enclosing call. Route all 17 block_on sites through one helper that raises RuntimeError when the current thread is already inside the runtime — the C ABI's re-entrancy rule, expressed as an ordinary exception that core records as ToolCallRepair.
…t handle 0 as none
- aimux_tool_call_repair_drop clears a shared flag that in-flight clones check
before calling the host, so releasing user_data after drop is safe; the old
'in-flight calls keep their clone' wording described a use-after-free.
- The host may reply {"error": "<message>"}; Core records it as
ToolCallRepair { original_error, cause }, giving every binding the same
semantics as a Rust closure that returns Err.
- 0 in opts_json means no handle, matching aimux_tool_call_repair_new(NULL).
- opts_json is parsed once into a two-field HandleFields struct plus once into
GenerateTextOptions instead of a full Value tree; a non-integer handle is
InvalidArgument (5) like every other schema violation.
- The RawToolCall / context / reply wire code moves into aimux-core
(RawToolCall derives serde; ToolCallRepairContext::to_wire_json;
parse_repair_reply) so the Node and Python bridges can drop their copies.
…report errors through the envelope
…r; errors through the envelope (unverified locally: no dart toolchain)
…a blocking thread The calling thread held the GIL for the whole block_on while stream_text's hook needed it on a runtime worker: with one worker that is a deadlock. Every GIL-holding block_on now runs under allow_threads and the hook runs in spawn_blocking, which also lets the hook call back into aimux (tokio's blocking pool is not an EnterRuntime context), so the re-entrancy guard goes. Also: SkipJsonSchema on the pydantic field so model_json_schema() works again, and the wire code moves to core's to_wire_json / parse_repair_reply.
- Go: recover in the exported trampoline itself, so a Close racing an
in-flight invocation (cgo.Handle.Value panics on a deleted handle)
yields a NULL reply instead of a Go panic unwinding through Rust.
- Python: a hook that returns anything but a dict or None is a TypeError,
recorded as the ToolCallRepair cause; before, a returned str dumped to a
JSON string literal and failed with a message about the wire shape.
- Kotlin: the context's messages are raw JsonElements, as in Go and Java,
so a message shape the codec does not model cannot fail the repair
before the user function runs.
- c.md: host exceptions become the {"error"} envelope (or NULL), matching
the header and every binding; the text said NULL only.
- Tests: repair on the aimux_stream_text path (FFI) and StreamText (Go),
a Go re-entrancy test, and a malformed reply ({}) in Node and Python.
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.
Gives every binding the AI SDK
repairToolCallhook that #165 landed for Rust callers only.Problem
GenerateTextOptions.repair_tool_callis#[serde(skip)], so the C ABI'sopts_jsoncould not carry it and the seven bindings only ever saw the result of repair (ToolCallRepair, C code 17) — none could supply a repair function. The same was true ofabort_signal, which is whyaimux_stream_text_with_abortexists as a separate entry point.Design
opts_jsonis the wholeGenerateTextOptionsobject, the shape the AI SDK uses. Fields that hold live objects travel as handles and are resolved inparse_opts_argbefore the rest deserializes:{"tools": [...], "abort_signal": 7, "repair_tool_call": 42}aimux_tool_call_repair_new(fn, user_data) -> handleregisters a host function with therepairToolCallcontract: it receives{tool_call, error, input_schema, tools, messages, instructions}as JSON and returns the repairedRawToolCallJSON,{"error": "<message>"}to record a failure, orNULLto keep the original error; strings come from the newaimux_string_newand aimux frees them. Core parses and validates a returned call from scratch and records an error reply asToolCallRepair { original_error, cause }— exactly what a Rust closure that returnsErrgets, so all seven bindings have one error semantics.parse_tool_callis untouched.aimux_*call, inside the existing re-entrancy guard: anaimux_*call from inside it fails withAIMUX_E_FFI_REENTRANT_CALL(204) instead of deadlocking — same rule as the stream callbacks, covered by a test.aimux_tool_call_repair_dropdisarms clones held by in-flight calls (they behave as if the function returnedNULL), so the host may releaseuser_dataafter it returns. Handle 0 means "none", matchingaimux_tool_call_repair_new(NULL).abort_handleof the*_with_abortvariants still wins when both are given. Future callbacks are one moreaimux_*_newconstructor plus a JSON field.RawToolCall/ context / reply wire code lives once, in aimux-core (RawToolCallderives serde,ToolCallRepairContext::to_wire_json,parse_repair_reply); the C, Node and Python bridges all use it.Each binding exposes a
ToolCallRepairbuilt from a native function and sets it on itsGenerateTextOptions(repairToolCall/repair_tool_call), where it serializes as the handle. Registered objects stay alive untilclose()(Java/Kotlin static registry, SwiftpassRetained, Gocgo.Handle, DartNativeCallable+@pragma('vm:isolate-unsendable')so anIsolate.runhand-off fails fast instead of crashing). Node and Python link Core directly and bridge the function natively (napiThreadsafeFunction; PyO3 on aspawn_blockingthread with the calling thread's GIL released) — in both, the hook may itself call aimux.Verification
abort_signalvia JSON / same on theaimux_stream_textpathStreamText; nested aimux call refused as re-entrant{error}is aToolCallRepairerrorTypeErrorrecorded as the repair causeDocs:
docs/api/{c,go,java,kotlin,swift,flutter,node,python}.md.Follow-ups tracked separately in #185 (
ToolInputraw/parsed enum, unusedStreamingToolCallTracker).