Skip to content

E.3: ToolResult split by source; core marks provider results providerExecuted:true (phase 1E; stacked on #27) - #28

Open
cunninghamcard-bit wants to merge 9 commits into
exp/restartfrom
exp/e3-toolresult-enum
Open

cunninghamcard-bit wants to merge 9 commits into
exp/restartfrom
exp/e3-toolresult-enum

Conversation

@cunninghamcard-bit

@cunninghamcard-bit cunninghamcard-bit commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Phase 1E unit E.3 (A7: "E.3 建模定稿" plus "E.3 补充: core 转换时置 providerExecuted:true"). Stacked on #27 (E.3a) → #26 → #25 → #24. The E.3 commits are 132c301 and the follow-up 0f4aa9e.

Model (aimux-core/src/tool.rs)

  • ToolResult keeps its common fields as public fields: toolCallId, toolName, result, isError, preliminary, providerMetadata. isError and preliminary stay independent, as A7 requires. The source becomes pub source: ToolResultSource:
    • Client { dynamic: bool, provider_executed: Option<bool> }: dynamic is always written as a boolean (AI SDK execute-tool-call.ts:169,199). provider_executed is plain data, written as given. There is no hidden "input had false" marker. Client results built by core never set it.

    • Provider { dynamic: Option<bool>, mark: ProviderExecutedMark }: dynamic is passed through (stream-language-model-call.ts:774,789). ProviderExecutedMark has two variants:

      • Unmarked: V4 provider-layer output; providerExecuted is not written.
      • Marked: core output; "providerExecuted": true is written.

      The marker is transitional. Provider converters and core share ToolResult today, so this enum is what tells the two layers apart. Follow-up unit E.3b will switch the provider side to feat(provider): V4 type crate aimux-provider (phase 1 unit 1.6) #16's V4 tool-result type once feat(provider): V4 type crate aimux-provider (phase 1 unit 1.6) #16 merges (the 3.3 split), and the marker goes away. feat(provider): V4 type crate aimux-provider (phase 1 unit 1.6) #16 (b837aac3) is not an ancestor of this branch (git merge-base --is-ancestor exits 1), so this PR is not stacked on it.

  • Wire. The private ToolResultWire is the E.3a struct, unchanged, used through #[serde(from/into)] and #[ts(export, as = "ToolResultWire")]. ToolResult.ts has zero diff and gen_ts_types.py --check passes. rg 'pub dynamic:' aimux-core/src/tool.rs hits only RawToolCall, the V4-style DTO that A7 leaves alone.
  • Wire to model:
    • providerExecuted: true becomes Provider { mark: Marked }.
    • absent or false becomes Client { provider_executed: None | Some(false) }.
  • Provider layer. The 29 provider converter literals now build ToolResultSource::provider(dynamic), which is unmarked. Their JSON is identical to before: no providerExecuted, and dynamic as given. This matches @ai-sdk/provider@4.0.21, whose language-model-v4-tool-result.ts has no providerExecuted field.
  • Core conversion points set providerExecuted: true through ToolResult::into_core(), which passes dynamic through:
    • aimux-core/src/generate.rs:794: provider content becomes step content (AI SDK convert-language-model-content.ts:113,129,149,165).
    • aimux-core/src/stream_part.rs:396: into_text_stream_part (AI SDK stream-language-model-call.ts:772,788).
  • Accessors: is_dynamic(), dynamic() (the value as written on the wire), is_provider_executed(), provider_executed(), into_core().

Changed assertions

providerExecuted-related changes: none. No existing assertion about a provider-executed result had to change. openai_upstream_e2e_test::responses_web_search_stream passes with its assertions untouched.

D25 normalization changes (two, unrelated to providerExecuted): client rows now gain "dynamic":false, as A7 D25 requires (execute-tool-call.ts:169,199).

  1. aimux-core/tests/enum_state_wire_test.rs tool_result_samples_match_baseline, now tool_result_samples_match_normalized. Samples are compared with normalized/tool_result.jsonl, which was committed in E.3a and is unchanged here. The client rows without dynamic gain "dynamic":false. Provider-executed rows are byte-identical, including the one without dynamic.
  2. aimux-core/tests/enum_state_wire_test.rs tool_result_provider_executed_is_passed_through. The expected output of the two client inputs (absent, and "providerExecuted":false) gains "dynamic":false. The provider rows are unchanged.

Not an assertion change: aimux-providers/tests/data_loss_regression_test.rs:266 destructures through tool_result.dynamic() instead of the removed field. The asserted values are the same.

New tests

  • enum_state_wire_test:
    • tool_result_absent_and_false_dynamic_are_the_same_variant
    • provider_layer_result_is_marked_by_core_conversion: V4 JSON is unmarked, and into_core adds "providerExecuted":true.
  • aimux-core/tests/provider_executed_marking_test.rs (new):
    • generate_marks_provider_tool_result: a mock model returns a V4 tool result, and generate_text step content has providerExecuted: true with dynamic absent.
    • text_stream_marks_provider_tool_result: into_text_stream_part marks the result, while the StreamPart's own JSON stays unmarked.

Per-provider evidence (new aimux-providers/tests/provider_executed_core_conversion_test.rs, 6 tests)

Each test runs the provider's real model through generate_text (step content, generate.rs:794) and stream_text (into_text_stream_part, stream_part.rs:396). It asserts that every tool-result / tool-error part has "providerExecuted": true, and that at least one exists.

Provider generate input stream input E.3a sites exercised
anthropic cassette anthropic/test_pause_turn_web_search_vcr_1.json (web_search) cassettes anthropic/test_anthropic_code_execution_tool_stream.json and anthropic/test_anthropic_web_fetch_tool_stream.json stream.rs parse_anthropic_content and stream_parts_for_result_block
google cassette gemini/test_code_execution.json cassette gemini/test_code_execution_stream.json google/model.rs generate + stream code-execution results
vertex the same gemini cassettes replayed against Vertex (same wire) same vertex/model.rs generate + stream
openai responses cassette openai/test_openai_responses_model_web_search_tool_1.json the responses_web_search_stream upstream events (the recorded *_stream_1 cassette holds a non-streaming body) provider_events.rs web_search pair; streaming web_search result
xai tool_results_preserve_actions_and_sources body (web_search) synthetic file_search_call SSE xai/responses/mod.rs generate web_search + stream file_search
huggingface should_handle_mcp_tools_with_annotations body (mcp_call) should_stream_tool_calls events (function_call with output) huggingface/responses.rs:1042 (generate) and the stream output site

Not covered here: the image_generation result paths (openai responses_convert.rs partial image; xai image_generation_result) and the remaining anthropic result kinds (tool_search, advisor, mcp, bash/text-editor). They go through the same two core conversion points but have no direct test.

Also new: enum_state_wire_test::client_tool_result_provider_executed_is_plain_data. Client samples with providerExecuted absent and false (rows already in tool_result.jsonl) round-trip byte for byte as Client { provider_executed: None | Some(false) }.

Fixtures / cassettes with a diff

None. git diff --stat exp/e3a-toolresult-provider-executed -- fixtures/ contract-tests/ aimux-providers/tests/cassettes/ bindings/ is empty, and no test rewrote a fixture. That includes fixtures/v4/enum-state/, because E.3a already committed the E.3 normalized form. The only intended JSON change, "providerExecuted":true on provider results after core conversion, appears in no committed fixture or cassette.

Results (local linux x64; new test names confirmed in the log)

R.0 E.3a (#27) E.3
cargo test --workspace 2871 / 0 / 7 2883 / 0 / 7 2894 / 0 / 7 (with the follow-up)
fmt / clippy (ci.yml flags) — ok ok
boundary script 0 0 0
gen_ts_types --check — ok ok, zero .ts diff
Node ava (node 22) 87 pass, 1 fail same 87 pass, same 1 fail
Python pytest 93 pass, 5 fail not re-run not run (no venv found)

New code has no untagged or flatten. Hand-written bindings are untouched. Go/Java/Kotlin/Swift/Flutter were not run.

fixtures/v4/enum-state/: JSONL samples for ToolCall, ToolResult, RawToolCall
and StreamPart/TextStreamPart tool-input-start (absent / explicit false /
true, providerExecuted true/false/absent, invalid+error, D26 rejected inputs,
RFC-0042:84-91 vectors as RawToolCall input), normalized/ per A7 D25, and
baseline/ recorded at 8455db9. aimux-core/tests/enum_state_wire_test.rs
compares serialize(deserialize(line)) byte for byte with the baseline and
checks normalized/ is a round-trip fixed point. A7 copied verbatim into
docs/amendments/.
New aimux_core::tool::RawArguments newtype, serde(transparent) and
#[ts(as = "String")] on the field, so the wire and RawToolCall.ts are
unchanged. Not an enum (arcships#200:2724). Provides new/as_str/into_string/push_str,
From<String|&str>, Into<String>, Deref<str>, AsRef<str>, Display and
PartialEq with str/String/serde_json::Value (parity with String). Call sites
in core, provider-utils, providers and tests converted with .into() /
.to_string(); no behaviour change. Enum-state samples still match the
8455db9 baseline byte for byte.
ToolCall = Static(StaticToolCall) | Dynamic(DynamicToolCall { validity:
Valid | Invalid { error } }), serialized through the private ToolCallWire
(the old struct) with serde(try_from/into) and #[ts(as = "ToolCallWire")];
ToolCall.ts is unchanged. D25: static writes no dynamic, dynamic writes
true, invalid only as true with its error, providerExecuted passed through.
D26: invalid without dynamic:true or without error fails conversion.
Accessors (tool_call_id/tool_name/input/provider_executed/provider_metadata/
is_dynamic/is_invalid/error); parsed_tool_call_arguments and
response_tool_call_input take &ToolCall and match. Consumers converted.
tool_call.jsonl gains the RFC-0042:84-93 core values (string "Tokyo", string
"{\"x\":1}" kept a string, number, bool, null, last-wins {"a":2}, 100.0)
and empty text / {"a": as invalid dynamic calls with an error (never {}).
tool_call_semantic.jsonl holds the non-canonical spellings (duplicate key,
1e2), compared by JSON semantics. check_normalized asserts line counts before
zipping; baseline_equals_samples is skipped in bless mode. Baseline re-blessed
at 8455db9 (existing lines unchanged; new lines appended).
Adds providerExecuted?: boolean to ToolResult only (no enum change), per
arcships#200:2292 / AI SDK generate-text/tool-result.ts:17,32. Absent/false/true
round-trip byte for byte. Provider converters construct V4 tool results,
which carry no providerExecuted in @ai-sdk/provider 4.0.21
(language-model-v4-tool-result.ts), so they write None; every other path
moves the whole ToolResult and passes the field through. Samples: the
providerExecuted x dynamic rows (incl. provider-executed without dynamic)
and the D25 normalized form for E.3.
ToolResult.source: ToolResultSource = Client { dynamic: bool, .. } |
Provider { dynamic: Option<bool>, marked }. JSON goes through the private
ToolResultWire (the E.3a struct) with serde(from/into) and
#[ts(as = "ToolResultWire")]; ToolResult.ts unchanged. Client results
always write dynamic (execute-tool-call.ts:169,199) and never gain
providerExecuted; provider results pass dynamic through. Provider converters
build unmarked V4 results (no providerExecuted, as @ai-sdk/provider 4.0.21);
core sets providerExecuted:true at generate.rs (step content) and
stream_part.rs (into_text_stream_part) via ToolResult::into_core, as
convert-language-model-content.ts:113 / stream-language-model-call.ts:772.
…data; per-provider core-conversion tests

- Provider { dynamic, mark: ProviderExecutedMark::{Unmarked, Marked} }
  replaces the bool; documented as transitional until E.3b moves the
  provider side to #16's V4 tool-result type.
- Client { dynamic, provider_executed: Option<bool> }: plain data, no
  hidden 'input had false' marker. Test: absent / false client samples
  round-trip byte for byte.
- provider_executed_core_conversion_test: anthropic, google, vertex,
  openai responses, xai, huggingface each yield providerExecuted:true
  through generate_text and stream_text (cassettes / upstream-port bodies).
JSON and TS unchanged.
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