Skip to content

Tool-call input boundary: type the raw/parsed distinction, drop the unused StreamingToolCallTracker #185

Description

@cunninghamcard-bit

Follow-up to #162 / #165. #165 landed the Core-owned parse_tool_call + optional ToolCallRepair (the AI SDK parseToolCall / repairToolCall shape). Two pieces of the original three-layer plan (wire → tracker → core) are still open, and one of them is the structural root of the P1 findings in the #165 review.

1. One input type, two meanings

StreamPart::ToolCall.input and ContentPart::ToolCall.input are both serde_json::Value. The doc comment on stream_part.rs states the contract literally: "Serialized argument text in a Value::String from do_stream; parsed input after stream_text." do_stream and stream_text share the same StreamResult/StreamPart, so the raw/parsed distinction lives in a comment, not in the type.

Consequences today:

  • 17 provider sites wrap the raw argument text as Value::String(...) (openai/model.rs:774, google/model.rs:438, xai/model.rs:506, replay.rs:863, …).
  • Core undoes it in exactly one place, parse_tool_call::raw_tool_input, with the rule "a string passes through, anything else re-serializes" — which is why a valid JSON string primitive and a malformed argument blob were indistinguishable (the "hello" → hello finding), and why null had to be special-cased.

The AI SDK keeps these apart by type: provider-side LanguageModelV4ToolCall.input: string, public-side TypedToolCall is parsed.

Proposal

Introduce one enum and use it in both StreamPart::ToolCall and ContentPart::ToolCall:

#[derive(Serialize, Deserialize)]
#[serde(untagged)]
pub enum ToolInput {
    Raw(String),    // provider output, unparsed
    Parsed(Value),  // after Core parse/repair
}
  • Providers may only construct Raw; Core's parse_tool_call consumes Raw and emits Parsed; raw_tool_input is deleted.
  • Wire shape is unchanged: bindings only ever observe Parsed (via stream_text / generate_text), which serializes as the value itself.
  • Scope: 2 type definitions, 17 mechanical provider edits (Value::String(x) → ToolInput::Raw(x)), 1 Core site, plus the openai_output renderer which currently branches on Value::String.

The alternative — a separate provider-facing stream part type as in the AI SDK — is cleaner at the API surface but changes every do_stream signature plus moa/replay/openai_output; defer it until the provider trait has another reason to move.

2. StreamingToolCallTracker is dead code

aimux-stream/src/streaming_tool_call_tracker.rs (429 lines) has zero users in aimux-providers and aimux-core; the only references are its own tests and internal docs. Providers use aimux-stream for SseStream / SseError only. Each OpenAI-compatible provider keeps its own accumulator, and replay.rs carries a third (ToolCallAccumulator).

Proposal

Order

Two independent PRs: (a) tracker deletion first, pure removal; (b) ToolInput enum. Neither touches the C ABI or the binding wire format.

Refs: #162, #165, #167, RFC-0016.

Activity

  1. eric8810 commented on Oct 3, 2026

    @eric8810
    Contributor

    补充当前边界和验收:非流式 GenerateContent::ToolCall.input 与 RawToolCall.input 已是原始 String;StreamPart 仍复用 Value。#192 新增 host-side repair,类型调整也需覆盖原始参数恢复、repair 后结果回填及绑定 wire 回归。

    Raw(String) / Parsed(Value) 若采用 untagged 反序列化,parsed JSON string 与 Raw 无法仅靠 wire 区分;需要先明确反序列化边界,并补字符串原语往返测试,再确定实现。

    #204 对 tracker 提出了不同处理方式,与本 issue 的删除提案需要先对齐;这里不据此接受任何一方的架构方案。

  2. added 2 commits that reference this issue on Oct 5, 2026
  3. cunninghamcard-bit commented on Oct 10, 2026

    @cunninghamcard-bit
    ContributorAuthor

    Proposing to close this as superseded by the RFC-0036 stack (#219 → #214 → #220 → #225 → #227). Both halves are covered there, with a different resolution from the one proposed here:

    1. Raw vs parsed tool input. The stack takes the "separate provider-facing type" route this issue deferred. Provider output is StreamPart::ToolCall(RawToolCall { input: String, .. }); Core's parse_tool_call produces TextStreamPart::ToolCall(ToolCall { input: Value, .. }), and raw_tool_input is gone. The distinction lives in the types, so the untagged Raw(String) / Parsed(Value) deserialization ambiguity raised above does not arise, and no ToolInput enum is introduced.
    2. StreamingToolCallTracker. The premise "zero users" no longer holds: in the stack it is a port of @ai-sdk/provider-utils's tracker in aimux-provider-utils, used by the OpenAI, OpenAI-compatible, Groq, DeepSeek and Mistral chat models (the same set upstream drives it from), replacing their per-provider accumulators. feat(provider-utils)!: align StreamingToolCallTracker with the AI SDK and move it next to its users #204 carried that change and is closed because rfc-0036/integration already contains it. The index-only accumulator in Core's replay.rs is still there; as this issue says, it goes away with transport-level replay (Complete recording and replay (RFC-0023): protocol-generic replay, one record format, all bindings #167), so it is out of scope here.

    #227 also updates ROADMAP §1, the 0.6.0 table and §4 to say this. @eric8810 — you asked that #185 and #204 align before either direction is accepted; does this resolution work for you? If so I'll close the issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions