diff --git a/aimux-core/src/generate.rs b/aimux-core/src/generate.rs index 0df13af5d..a708f1800 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -741,7 +741,7 @@ pub(crate) async fn generate_text_from_language_model_prompt( GenerateContent::ToolCall(call) => { let parsed = tool_calls .iter() - .find(|parsed| parsed.tool_call_id == call.tool_call_id) + .find(|parsed| parsed.tool_call_id() == call.tool_call_id) .expect("every tool call was parsed above"); rm.tool_call(parsed); } @@ -776,7 +776,7 @@ pub(crate) async fn generate_text_from_language_model_prompt( GenerateContent::ToolApprovalRequest(approval) => { let call = tool_calls .iter() - .find(|call| call.tool_call_id == approval.tool_call_id) + .find(|call| call.tool_call_id() == approval.tool_call_id) .ok_or_else(|| AiMuxError::ToolCallNotFoundForApproval { tool_call_id: approval.tool_call_id.clone(), approval_id: approval.approval_id.clone(), @@ -789,7 +789,9 @@ pub(crate) async fn generate_text_from_language_model_prompt( signature: None, }); } - GenerateContent::ToolResult(result) => rm.tool_result(result.clone()), + // Provider output becomes step content: mark it provider-executed + // (A7 E.3; convert-language-model-content.ts:113). + GenerateContent::ToolResult(result) => rm.tool_result(result.clone().into_core()), } } @@ -1204,7 +1206,7 @@ pub async fn stream_text( &messages, operation_instructions.as_deref(), ).await; - tool_calls.insert(parsed.tool_call_id.clone(), parsed.clone()); + tool_calls.insert(parsed.tool_call_id().to_string(), parsed.clone()); yield Ok(TextStreamPart::ToolCall(parsed)); } Ok(StreamPart::File(file)) => { @@ -1413,15 +1415,11 @@ pub fn generate_text_result_to_chat_completion( .tool_calls .iter() .map(|tool_call| ChatCompletionToolCall { - id: tool_call.tool_call_id.clone(), + id: tool_call.tool_call_id().to_string(), tool_type: "function".to_string(), function: ChatCompletionFunction { - name: tool_call.tool_name.clone(), - arguments: parsed_tool_call_arguments( - &tool_call.input, - tool_call.invalid, - tool_call.error.as_ref(), - ), + name: tool_call.tool_name().to_string(), + arguments: parsed_tool_call_arguments(tool_call), }, }) .collect(), diff --git a/aimux-core/src/lib.rs b/aimux-core/src/lib.rs index 3f146d382..991d72a29 100644 --- a/aimux-core/src/lib.rs +++ b/aimux-core/src/lib.rs @@ -143,8 +143,9 @@ pub mod prelude { pub use crate::speech_model::{SpeechCallOptions, SpeechModel, SpeechResult, generate_speech}; pub use crate::stream_part::{StreamPart, TextStreamPart}; pub use crate::tool::{ - FunctionTool, FunctionToolInputExample, ProviderTool, RawToolCall, Tool, ToolCall, - ToolResult, + DynamicToolCall, FunctionTool, FunctionToolInputExample, ProviderExecutedMark, + ProviderTool, RawArguments, RawToolCall, StaticToolCall, Tool, ToolCall, ToolCallValidity, + ToolResult, ToolResultSource, }; pub use crate::transcription_model::{ AudioChunk, InputAudioFormat, TranscriptionCallOptions, TranscriptionModel, diff --git a/aimux-core/src/openai_output.rs b/aimux-core/src/openai_output.rs index 9b581eece..5a3109769 100644 --- a/aimux-core/src/openai_output.rs +++ b/aimux-core/src/openai_output.rs @@ -247,7 +247,7 @@ pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletio let arguments = if call.input.trim().is_empty() { "{}".to_string() } else { - call.input.clone() + call.input.to_string() }; tool_calls.push(ChatCompletionToolCall { id: call.tool_call_id.clone(), @@ -702,21 +702,15 @@ impl StreamState { } TextStreamPart::ToolInputEnd { .. } => {} - TextStreamPart::ToolCall(ToolCall { - tool_call_id, - tool_name, - input, - invalid, - error, - .. - }) => { + TextStreamPart::ToolCall(call) => { + let tool_call_id = &call.tool_call_id().to_string(); + let tool_name = &call.tool_name().to_string(); // Complete tool call (e.g. from non-streaming-style providers). // A provider may carry all input on its start frame and emit no // deltas. In that case the final call is the first point where // the OpenAI adapter can forward those arguments. if self.tool_call_opened.contains(tool_call_id) { - let full_arguments = - parsed_tool_call_arguments(input, *invalid, error.as_ref()); + let full_arguments = parsed_tool_call_arguments(call); let missing_arguments = self .tool_calls .get(tool_call_id) @@ -761,7 +755,7 @@ impl StreamState { index, id: tool_call_id.clone(), name: tool_name.clone(), - arguments: parsed_tool_call_arguments(input, *invalid, error.as_ref()), + arguments: parsed_tool_call_arguments(call), }, ); self.tool_call_order.push(tool_call_id.clone()); @@ -1028,13 +1022,8 @@ fn now_unix() -> u64 { /// /// Older errors without raw argument text fall back to serializing the /// parsed input as JSON. -pub(crate) fn parsed_tool_call_arguments( - input: &Value, - invalid: Option, - error: Option<&AiMuxError>, -) -> String { - if invalid == Some(true) - && let Some(error) = error +pub(crate) fn parsed_tool_call_arguments(call: &ToolCall) -> String { + if let Some(error) = call.error() && let Some(raw) = raw_tool_call_text(error) { // Blank text still fails validation against a schema with required @@ -1049,7 +1038,7 @@ pub(crate) fn parsed_tool_call_arguments( } // `Value` Display is compact JSON, the `JSON.stringify` equivalent — // correct for every shape, `null` included. - input.to_string() + call.input().to_string() } /// Generate a short random ID (24 hex chars, similar to OpenAI's chatcmpl IDs). diff --git a/aimux-core/src/parse_tool_call.rs b/aimux-core/src/parse_tool_call.rs index 79be0bacd..31b6439ea 100644 --- a/aimux-core/src/parse_tool_call.rs +++ b/aimux-core/src/parse_tool_call.rs @@ -16,7 +16,7 @@ use ts_rs::TS; use crate::content::ContentPart; use crate::error::AiMuxError; use crate::result::{GenerateContent, TextContent}; -use crate::tool::{RawToolCall, Tool, ToolCall}; +use crate::tool::{DynamicToolCall, RawToolCall, StaticToolCall, Tool, ToolCall, ToolCallValidity}; /// Context supplied to a one-shot tool-call repair callback. #[derive(Debug, Clone)] @@ -111,7 +111,7 @@ pub async fn parse_tool_call( Err(AiMuxError::NoSuchTool { tool_name: tool_call.tool_name.clone(), available_tools: None, - tool_input: Some(tool_call.input.clone()), + tool_input: Some(tool_call.input.to_string()), }) }; return match parsed { @@ -196,7 +196,7 @@ fn parse_and_validate_tool_call( } return Err(AiMuxError::NoSuchTool { tool_name: tool_call.tool_name.clone(), - tool_input: Some(tool_call.input.clone()), + tool_input: Some(tool_call.input.to_string()), available_tools: Some( tools .iter() @@ -216,7 +216,7 @@ fn parse_and_validate_tool_call( let validator = jsonschema::validator_for(&function_tool.input_schema).map_err(|error| { AiMuxError::InvalidToolInput { tool_name: tool_call.tool_name.clone(), - tool_input: tool_call.input.clone(), + tool_input: tool_call.input.to_string(), cause: format!("input schema is invalid: {error}"), } })?; @@ -226,7 +226,7 @@ fn parse_and_validate_tool_call( .validate(&input) .map_err(|error| AiMuxError::InvalidToolInput { tool_name: tool_call.tool_name.clone(), - tool_input: tool_call.input.clone(), + tool_input: tool_call.input.to_string(), cause: format!("Type validation failed: Value: {input}.\nError message: {error}"), })?; Ok((input, None)) @@ -240,7 +240,7 @@ fn parse_json_input(tool_call: &RawToolCall) -> Result { let value: Value = serde_json::from_str(&tool_call.input).map_err(|error| AiMuxError::InvalidToolInput { tool_name: tool_call.tool_name.clone(), - tool_input: tool_call.input.clone(), + tool_input: tool_call.input.to_string(), cause: format!( "JSON parsing failed: Text: {}.\nError message: {error}", tool_call.input @@ -249,7 +249,7 @@ fn parse_json_input(tool_call: &RawToolCall) -> Result { if contains_forbidden_prototype(&value) { return Err(AiMuxError::InvalidToolInput { tool_name: tool_call.tool_name.clone(), - tool_input: tool_call.input.clone(), + tool_input: tool_call.input.to_string(), cause: format!( "JSON parsing failed: Text: {}.\nError message: Object contains forbidden prototype property", tool_call.input @@ -282,15 +282,23 @@ fn contains_forbidden_prototype(value: &Value) -> bool { } fn parsed_tool_call(tool_call: RawToolCall, input: Value, dynamic: Option) -> ToolCall { - ToolCall { - tool_call_id: tool_call.tool_call_id, - tool_name: tool_call.tool_name, - input, - provider_executed: tool_call.provider_executed, - dynamic, - provider_metadata: tool_call.provider_metadata, - invalid: None, - error: None, + if dynamic == Some(true) { + ToolCall::Dynamic(DynamicToolCall { + tool_call_id: tool_call.tool_call_id, + tool_name: tool_call.tool_name, + input, + provider_executed: tool_call.provider_executed, + provider_metadata: tool_call.provider_metadata, + validity: ToolCallValidity::Valid, + }) + } else { + ToolCall::Static(StaticToolCall { + tool_call_id: tool_call.tool_call_id, + tool_name: tool_call.tool_name, + input, + provider_executed: tool_call.provider_executed, + provider_metadata: tool_call.provider_metadata, + }) } } @@ -302,12 +310,15 @@ fn invalid_tool_call(tool_call: RawToolCall, error: AiMuxError) -> ToolCall { // structured input into the next turn's transcript, so an unknown tool // called with perfectly good arguments must still parse here. let input = serde_json::from_str(&tool_call.input) - .unwrap_or_else(|_| Value::String(tool_call.input.clone())); - ToolCall { - invalid: Some(true), - error: Some(error), - ..parsed_tool_call(tool_call, input, Some(true)) - } + .unwrap_or_else(|_| Value::String(tool_call.input.to_string())); + ToolCall::Dynamic(DynamicToolCall { + tool_call_id: tool_call.tool_call_id, + tool_name: tool_call.tool_name, + input, + provider_executed: tool_call.provider_executed, + provider_metadata: tool_call.provider_metadata, + validity: ToolCallValidity::Invalid { error }, + }) } // ───────────────────────────────────────────────────────────────────────────── @@ -378,31 +389,32 @@ pub(crate) fn raw_tool_call_text(error: &AiMuxError) -> Option { /// That only matters for re-deriving the original error, which is read off the /// call instead. fn raw_from_invalid(tool_call: &ToolCall) -> Result<(RawToolCall, AiMuxError), AiMuxError> { - if tool_call.invalid != Some(true) { + if !tool_call.is_invalid() { return Err(AiMuxError::InvalidArgument(format!( "tool call '{}' is not invalid; nothing to repair", - tool_call.tool_call_id + tool_call.tool_call_id() ))); } - let error = tool_call.error.clone().ok_or_else(|| { + let error = tool_call.error().cloned().ok_or_else(|| { AiMuxError::InvalidArgument(format!( "invalid tool call '{}' carries no error", - tool_call.tool_call_id + tool_call.tool_call_id() )) })?; Ok(( RawToolCall { - tool_call_id: tool_call.tool_call_id.clone(), - tool_name: tool_call.tool_name.clone(), + tool_call_id: tool_call.tool_call_id().to_string(), + tool_name: tool_call.tool_name().to_string(), // An invalid call keeps unparsable argument text as a JSON string // (see `invalid_tool_call`); that text is the raw input. - input: raw_tool_call_text(&error).unwrap_or_else(|| match &tool_call.input { + input: (raw_tool_call_text(&error).unwrap_or_else(|| match tool_call.input() { Value::String(text) => text.clone(), input => input.to_string(), - }), - provider_executed: tool_call.provider_executed, - dynamic: tool_call.dynamic, - provider_metadata: tool_call.provider_metadata.clone(), + })) + .into(), + provider_executed: tool_call.provider_executed(), + dynamic: Some(true), + provider_metadata: tool_call.provider_metadata().cloned(), }, error, )) @@ -572,16 +584,15 @@ pub fn apply_tool_call_repair_to_result( .map_err(|error| AiMuxError::InvalidArgument(format!("result.toolCalls: {error}")))?; let repaired = apply_tool_call_repair(&original, tools, reply)?; - if repaired.tool_call_id != tool_call_id + if repaired.tool_call_id() != tool_call_id && calls.iter().enumerate().any(|(index, call)| { index != target_index - && call.get("toolCallId").and_then(Value::as_str) - == Some(repaired.tool_call_id.as_str()) + && call.get("toolCallId").and_then(Value::as_str) == Some(repaired.tool_call_id()) }) { return Err(AiMuxError::InvalidArgument(format!( "result: repaired tool call id '{}' already exists", - repaired.tool_call_id + repaired.tool_call_id() ))); } calls[target_index] = serde_json::to_value(&repaired) @@ -589,8 +600,7 @@ pub fn apply_tool_call_repair_to_result( // The replayed transcript must agree with `toolCalls`, or the next turn // sends the model the unrepaired arguments. - let replay_input = - crate::response_messages::response_tool_call_input(&repaired.input, repaired.invalid); + let replay_input = crate::response_messages::response_tool_call_input(&repaired); for part in target .get_mut("responseMessages") .and_then(Value::as_array_mut) @@ -611,9 +621,12 @@ pub fn apply_tool_call_repair_to_result( // the same entry as `toolCalls`. part.insert( "toolCallId".to_string(), - repaired.tool_call_id.clone().into(), + repaired.tool_call_id().to_string().into(), + ); + part.insert( + "toolName".to_string(), + repaired.tool_name().to_string().into(), ); - part.insert("toolName".to_string(), repaired.tool_name.clone().into()); part.insert("input".to_string(), replay_input.clone()); } @@ -634,14 +647,14 @@ pub fn apply_tool_call_repair_to_result( GenerateContent::ToolApprovalRequest(approval) => &mut approval.tool_call, _ => continue, }; - if call.tool_call_id != tool_call_id { + if call.tool_call_id() != tool_call_id { continue; } *call = repaired.clone(); *entry = serde_json::to_value(&content) .map_err(|error| AiMuxError::InvalidArgument(format!("content: {error}")))?; } - if repaired.tool_call_id != tool_call_id { + if repaired.tool_call_id() != tool_call_id { for part in target .get_mut("responseMessages") .and_then(Value::as_array_mut) @@ -663,7 +676,7 @@ pub fn apply_tool_call_repair_to_result( if approved_id != tool_call_id { continue; } - approved_id.clone_from(&repaired.tool_call_id); + *approved_id = repaired.tool_call_id().to_string(); *part = serde_json::to_value(&approval).map_err(|error| { AiMuxError::InvalidArgument(format!("response message: {error}")) })?; @@ -697,7 +710,7 @@ mod tests { RawToolCall { tool_call_id: id.to_string(), tool_name: "weather".to_string(), - input: input.to_string(), + input: input.to_string().into(), provider_executed: None, dynamic: None, provider_metadata: None, @@ -716,7 +729,7 @@ mod tests { None, ) .await; - assert_eq!(call.invalid, Some(true)); + assert!(call.is_invalid()); let mut builder = ResponseMessageBuilder::default(); builder.tool_call(&call); if with_approval { diff --git a/aimux-core/src/replay.rs b/aimux-core/src/replay.rs index 1c11bb1ed..3e1c9a430 100644 --- a/aimux-core/src/replay.rs +++ b/aimux-core/src/replay.rs @@ -638,7 +638,7 @@ fn rebuild_generate_result(rec: &Recording) -> Result Result { RawToolCall { tool_call_id: id.clone(), tool_name: name.clone(), - input: String::new(), + input: crate::tool::RawArguments::default(), provider_executed: None, dynamic: None, provider_metadata: None, diff --git a/aimux-core/src/response_messages.rs b/aimux-core/src/response_messages.rs index 6d89336f5..443b3592d 100644 --- a/aimux-core/src/response_messages.rs +++ b/aimux-core/src/response_messages.rs @@ -22,8 +22,9 @@ use crate::shared::{FileBytes, GeneratedFileData, SharedProviderMetadata}; /// malformed primitive input must not be replayed as a prompt tool-call input. /// JavaScript's `typeof value === "object"` includes arrays and null, so those /// values are intentionally retained here as well. -pub(crate) fn response_tool_call_input(input: &Value, invalid: Option) -> Value { - if invalid == Some(true) && !matches!(input, Value::Object(_) | Value::Array(_) | Value::Null) { +pub(crate) fn response_tool_call_input(call: &crate::tool::ToolCall) -> Value { + let input = call.input(); + if call.is_invalid() && !matches!(input, Value::Object(_) | Value::Array(_) | Value::Null) { Value::Object(serde_json::Map::new()) } else { input.clone() @@ -239,7 +240,7 @@ impl ResponseMessageBuilder { GenerateContent::ToolApprovalRequest(approval) => { ContentPart::ToolApprovalRequest { approval_id: approval.approval_id.clone(), - tool_call_id: approval.tool_call.tool_call_id.clone(), + tool_call_id: approval.tool_call.tool_call_id().to_string(), reason: approval.reason.clone(), is_automatic: approval.is_automatic, signature: approval.signature.clone(), @@ -247,11 +248,11 @@ impl ResponseMessageBuilder { } } GenerateContent::ToolCall(call) => ContentPart::ToolCall { - tool_call_id: call.tool_call_id.clone(), - tool_name: call.tool_name.clone(), - input: response_tool_call_input(&call.input, call.invalid), - provider_executed: call.provider_executed, - provider_options: call.provider_metadata.clone(), + tool_call_id: call.tool_call_id().to_string(), + tool_name: call.tool_name().to_string(), + input: response_tool_call_input(call), + provider_executed: call.provider_executed(), + provider_options: call.provider_metadata().cloned(), }, GenerateContent::ToolResult(result) => ContentPart::ToolResult { tool_call_id: result.tool_call_id.clone(), diff --git a/aimux-core/src/stream_part.rs b/aimux-core/src/stream_part.rs index 1e226b2bb..bc1a7e176 100644 --- a/aimux-core/src/stream_part.rs +++ b/aimux-core/src/stream_part.rs @@ -392,7 +392,8 @@ impl StreamPart { Self::ResponseMetadata(_) | Self::Finish { .. } => None, Self::ToolCall(value) => Some(TextStreamPart::ToolCall(value)), Self::ToolApprovalRequest(value) => Some(TextStreamPart::ToolApprovalRequest(value)), - Self::ToolResult(value) => Some(TextStreamPart::ToolResult(value)), + // A7 E.3: provider output -> core stream part (stream-language-model-call.ts:772,788). + Self::ToolResult(value) => Some(TextStreamPart::ToolResult(value.into_core())), Self::File(value) => Some(TextStreamPart::File(value)), Self::ReasoningFile(value) => Some(TextStreamPart::ReasoningFile(value)), Self::Source(value) => Some(TextStreamPart::Source(value)), diff --git a/aimux-core/src/tool.rs b/aimux-core/src/tool.rs index e345a8fcf..225014683 100644 --- a/aimux-core/src/tool.rs +++ b/aimux-core/src/tool.rs @@ -114,6 +114,102 @@ impl From for Tool { } } +/// A provider's raw tool-argument text, kept verbatim (RFC-0042:80). +/// +/// Serializes as the plain JSON string (`serde(transparent)`), so the wire and +/// the generated TypeScript (`string`) are unchanged. Core parses it into +/// [`ToolCall::input`]. +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct RawArguments(String); + +impl RawArguments { + /// Wrap provider argument text as-is. + #[must_use] + pub fn new(text: impl Into) -> Self { + Self(text.into()) + } + + /// The argument text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Append streamed argument text (provider deltas). + pub fn push_str(&mut self, delta: &str) { + self.0.push_str(delta); + } + + /// The argument text, by value. + #[must_use] + pub fn into_string(self) -> String { + self.0 + } +} + +impl From for RawArguments { + fn from(text: String) -> Self { + Self(text) + } +} + +impl From<&str> for RawArguments { + fn from(text: &str) -> Self { + Self(text.to_owned()) + } +} + +impl From for String { + fn from(arguments: RawArguments) -> Self { + arguments.0 + } +} + +impl std::ops::Deref for RawArguments { + type Target = str; + fn deref(&self) -> &str { + &self.0 + } +} + +impl AsRef for RawArguments { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for RawArguments { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl PartialEq for RawArguments { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for RawArguments { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl PartialEq for RawArguments { + fn eq(&self, other: &String) -> bool { + &self.0 == other + } +} + +/// Same as `String == Value`: equal to a JSON string with the same text. +impl PartialEq for RawArguments { + fn eq(&self, other: &Value) -> bool { + other.as_str() == Some(self.0.as_str()) + } +} + /// Provider-facing tool call before Core parses and validates its input. /// /// The wire shape matches [`ToolCall`] field for field, except that `input` @@ -124,7 +220,8 @@ impl From for Tool { pub struct RawToolCall { pub tool_call_id: String, pub tool_name: String, - pub input: String, + #[ts(as = "String")] + pub input: RawArguments, #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_executed: Option, @@ -136,46 +233,277 @@ pub struct RawToolCall { pub provider_metadata: Option, } +// A7 (D2): static calls are tools declared up front; dynamic calls are tools +// defined at runtime (e.g. MCP). Only a dynamic call can be invalid, and an +// invalid call always carries its error (D26). The JSON shape is unchanged and +// follows the AI SDK canonical form (D25): a static call writes no `dynamic` +// (an input `"dynamic": false` is dropped), a dynamic call writes +// `"dynamic": true`, `invalid` is only written as `true` with its `error`, and +// `providerExecuted` is passed through as given. (Plain comments: the `///` +// line is the generated `ToolCall.ts` doc and must stay unchanged.) /// A tool call requested by the model. #[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct ToolCall { +#[serde(try_from = "ToolCallWire", into = "ToolCallWire")] +#[ts(export, as = "ToolCallWire")] +pub enum ToolCall { + /// A call to a tool declared in the request. + Static(StaticToolCall), + /// A call to a tool defined at runtime. + Dynamic(DynamicToolCall), +} + +/// A call to a statically declared tool. Always valid: a static call that +/// fails parsing or validation is reported as a dynamic invalid call. +#[derive(Debug, Clone, PartialEq)] +pub struct StaticToolCall { /// Provider-assigned call id. pub tool_call_id: String, /// Tool name. pub tool_name: String, + /// Parsed arguments. + pub input: Value, + /// Whether the tool call will be executed by the provider (passed through). + pub provider_executed: Option, + /// Additional provider-specific metadata associated with this call. + pub provider_metadata: Option, +} + +/// A call to a tool defined at runtime (e.g. MCP tools). +#[derive(Debug, Clone)] +pub struct DynamicToolCall { + /// Provider-assigned call id. + pub tool_call_id: String, + /// Tool name. + pub tool_name: String, + /// Parsed arguments, or the original string when the call is invalid and + /// the provider input was not valid JSON. + pub input: Value, + /// Whether the tool call will be executed by the provider (passed through). + pub provider_executed: Option, + /// Additional provider-specific metadata associated with this call. + pub provider_metadata: Option, + /// Whether lookup, parsing and validation succeeded. + pub validity: ToolCallValidity, +} + +/// Outcome of resolving a dynamic tool call. +#[derive(Debug, Clone)] +pub enum ToolCallValidity { + /// The call can be executed. + Valid, + /// Lookup, JSON parsing, or schema validation still failed after the + /// optional repair attempt. + Invalid { + /// Typed failure associated with the invalid call. + error: AiMuxError, + }, +} + +impl TryFrom for ToolCall { + type Error = String; + + fn try_from(wire: ToolCallWire) -> Result { + let invalid = wire.invalid == Some(true); + if wire.dynamic != Some(true) { + if invalid { + // D26: only a dynamic call can be invalid (#200:2290). + return Err("tool call: `invalid: true` requires `dynamic: true`".into()); + } + return Ok(Self::Static(StaticToolCall { + tool_call_id: wire.tool_call_id, + tool_name: wire.tool_name, + input: wire.input, + provider_executed: wire.provider_executed, + provider_metadata: wire.provider_metadata, + })); + } + let validity = match (invalid, wire.error) { + (true, Some(error)) => ToolCallValidity::Invalid { error }, + // D26: an invalid call always carries its error. + (true, None) => return Err("tool call: `invalid: true` requires `error`".into()), + (false, _) => ToolCallValidity::Valid, + }; + Ok(Self::Dynamic(DynamicToolCall { + tool_call_id: wire.tool_call_id, + tool_name: wire.tool_name, + input: wire.input, + provider_executed: wire.provider_executed, + provider_metadata: wire.provider_metadata, + validity, + })) + } +} + +impl From for ToolCallWire { + fn from(call: ToolCall) -> Self { + match call { + ToolCall::Static(call) => Self { + tool_call_id: call.tool_call_id, + tool_name: call.tool_name, + input: call.input, + provider_executed: call.provider_executed, + dynamic: None, + provider_metadata: call.provider_metadata, + invalid: None, + error: None, + }, + ToolCall::Dynamic(call) => { + let (invalid, error) = match call.validity { + ToolCallValidity::Valid => (None, None), + ToolCallValidity::Invalid { error } => (Some(true), Some(error)), + }; + Self { + tool_call_id: call.tool_call_id, + tool_name: call.tool_name, + input: call.input, + provider_executed: call.provider_executed, + dynamic: Some(true), + provider_metadata: call.provider_metadata, + invalid, + error, + } + } + } + } +} + +impl ToolCall { + /// Provider-assigned call id. + #[must_use] + pub fn tool_call_id(&self) -> &str { + match self { + Self::Static(call) => &call.tool_call_id, + Self::Dynamic(call) => &call.tool_call_id, + } + } + + /// Tool name. + #[must_use] + pub fn tool_name(&self) -> &str { + match self { + Self::Static(call) => &call.tool_name, + Self::Dynamic(call) => &call.tool_name, + } + } + + /// Parsed arguments (the original string for an unparsable invalid call). + #[must_use] + pub fn input(&self) -> &Value { + match self { + Self::Static(call) => &call.input, + Self::Dynamic(call) => &call.input, + } + } + + /// Mutable arguments. + pub fn input_mut(&mut self) -> &mut Value { + match self { + Self::Static(call) => &mut call.input, + Self::Dynamic(call) => &mut call.input, + } + } + + /// Whether the provider executes the call, as the provider reported it. + #[must_use] + pub fn provider_executed(&self) -> Option { + match self { + Self::Static(call) => call.provider_executed, + Self::Dynamic(call) => call.provider_executed, + } + } + + /// Provider-specific metadata. + #[must_use] + pub fn provider_metadata(&self) -> Option<&ProviderMetadata> { + match self { + Self::Static(call) => call.provider_metadata.as_ref(), + Self::Dynamic(call) => call.provider_metadata.as_ref(), + } + } + + /// Mutable provider-specific metadata. + pub fn provider_metadata_mut(&mut self) -> &mut Option { + match self { + Self::Static(call) => &mut call.provider_metadata, + Self::Dynamic(call) => &mut call.provider_metadata, + } + } + + /// Whether the tool is dynamic (defined at runtime). + #[must_use] + pub fn is_dynamic(&self) -> bool { + matches!(self, Self::Dynamic(_)) + } + + /// Whether the call is invalid (always a dynamic call). + #[must_use] + pub fn is_invalid(&self) -> bool { + self.error().is_some() + } + + /// The failure of an invalid call. + #[must_use] + pub fn error(&self) -> Option<&AiMuxError> { + match self { + Self::Dynamic(DynamicToolCall { + validity: ToolCallValidity::Invalid { error }, + .. + }) => Some(error), + _ => None, + } + } +} + +/// The JSON form of [`ToolCall`]: the pre-enum struct, unchanged, so the wire +/// and `ToolCall.ts` stay the same (A7). Private; conversions apply A7 D25/D26. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +struct ToolCallWire { + /// Provider-assigned call id. + tool_call_id: String, + /// Tool name. + tool_name: String, /// Parsed arguments, or the original string when `invalid` is true and the /// provider input was not valid JSON. - pub input: Value, + input: Value, /// Whether the tool call will be executed by the provider. #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider_executed: Option, + provider_executed: Option, /// Whether the tool is dynamic (defined at runtime, e.g. MCP tools). #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] - pub dynamic: Option, + dynamic: Option, /// Additional provider-specific metadata associated with this call. #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider_metadata: Option, + provider_metadata: Option, /// Set when lookup, JSON parsing, or schema validation still failed after /// the optional repair attempt. #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] - pub invalid: Option, + invalid: Option, /// Typed failure associated with an invalid tool call. #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] - pub error: Option, + error: Option, } +// A7 E.3: a tool result is split by who executed it. Client-executed results +// (`execute-tool-call.ts:169,199`) always write `dynamic` as a boolean and do +// not write `providerExecuted`. Provider-executed results pass `dynamic` +// through (`stream-language-model-call.ts:774,789`). The V4 provider layer +// has no `providerExecuted` on tool results (`language-model-v4-tool-result.ts`, +// @ai-sdk/provider 4.0.21); core sets it to `true` when it converts provider +// output (`convert-language-model-content.ts:113`, `stream-language-model- +// call.ts:772`): see [`ToolResult::into_core`]. JSON goes through the private +// `ToolResultWire`, so `ToolResult.ts` is unchanged. (Plain comments: the `///` +// lines are the generated `ToolResult.ts` doc and must stay unchanged.) /// A tool result produced by a provider-executed tool (e.g. xAI `file_search`, /// web search), carried next to the [`RawToolCall`] it answers. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] +#[serde(from = "ToolResultWire", into = "ToolResultWire")] +#[ts(export, as = "ToolResultWire")] pub struct ToolResult { /// Must match the corresponding call's `tool_call_id`. pub tool_call_id: String, @@ -183,21 +511,192 @@ pub struct ToolResult { /// The tool's output (usually a JSON-serializable value or plain text). pub result: Value, /// Whether the result is an error or error message. + pub is_error: Option, + /// Whether the result is preliminary (replaces prior, e.g. image previews). + pub preliminary: Option, + /// Who executed the tool (A7 E.3). + pub source: ToolResultSource, + /// Additional provider-specific metadata for the tool result. + pub provider_metadata: Option, +} + +/// Who executed a [`ToolResult`] (A7 E.3). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolResultSource { + /// Executed by the client. `dynamic` is always written as a boolean + /// (`execute-tool-call.ts:169,199`). `provider_executed` is plain data, + /// written as given (A7: providerExecuted is passed through); client + /// results from core never set it. + Client { + dynamic: bool, + provider_executed: Option, + }, + /// Executed by the provider. `dynamic` is passed through as given. + Provider { + dynamic: Option, + mark: ProviderExecutedMark, + }, +} + +/// Whether a provider-executed result has been through core conversion. +/// +/// Transitional: provider converters and core share `ToolResult` today, so a +/// V4 provider-layer result (no `providerExecuted`, as in @ai-sdk/provider +/// 4.0.21 `language-model-v4-tool-result.ts`) and a core result +/// (`providerExecuted: true`) are told apart here. Follow-up unit E.3b +/// switches the provider side to #16's V4 tool-result type once #16 merges +/// (the 3.3 split), and this marker goes away. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderExecutedMark { + /// V4 provider-layer output: `providerExecuted` is not written. + Unmarked, + /// Core output: `"providerExecuted": true` is written + /// ([`ToolResult::into_core`]). + Marked, +} + +impl ToolResultSource { + /// A provider-layer (V4) result: provider-executed, not yet marked. + #[must_use] + pub fn provider(dynamic: Option) -> Self { + Self::Provider { + dynamic, + mark: ProviderExecutedMark::Unmarked, + } + } +} + +impl ToolResult { + /// Whether the tool is dynamic (defined at runtime, e.g. MCP tools). + #[must_use] + pub fn is_dynamic(&self) -> bool { + match self.source { + ToolResultSource::Client { dynamic, .. } => dynamic, + ToolResultSource::Provider { dynamic, .. } => dynamic == Some(true), + } + } + + /// Whether the provider executed the tool. + #[must_use] + pub fn is_provider_executed(&self) -> bool { + matches!(self.source, ToolResultSource::Provider { .. }) + } + + /// `dynamic` as written on the wire. + #[must_use] + pub fn dynamic(&self) -> Option { + match self.source { + ToolResultSource::Client { dynamic, .. } => Some(dynamic), + ToolResultSource::Provider { dynamic, .. } => dynamic, + } + } + + /// `providerExecuted` as written on the wire. + #[must_use] + pub fn provider_executed(&self) -> Option { + ToolResultWire::from(self.clone()).provider_executed + } + + /// Core conversion of provider output into step content / text stream + /// parts: the result is provider-executed and is marked + /// `providerExecuted: true` (AI SDK `convert-language-model-content.ts: + /// 113,129,149,165`, `stream-language-model-call.ts:772,788`). `dynamic` + /// is passed through. + #[must_use] + pub fn into_core(mut self) -> Self { + let dynamic = match self.source { + ToolResultSource::Provider { dynamic, .. } => dynamic, + ToolResultSource::Client { dynamic, .. } => dynamic.then_some(true), + }; + self.source = ToolResultSource::Provider { + dynamic, + mark: ProviderExecutedMark::Marked, + }; + self + } +} + +impl From for ToolResult { + fn from(wire: ToolResultWire) -> Self { + let source = match wire.provider_executed { + Some(true) => ToolResultSource::Provider { + dynamic: wire.dynamic, + mark: ProviderExecutedMark::Marked, + }, + provider_executed => ToolResultSource::Client { + dynamic: wire.dynamic == Some(true), + provider_executed, + }, + }; + Self { + tool_call_id: wire.tool_call_id, + tool_name: wire.tool_name, + result: wire.result, + is_error: wire.is_error, + preliminary: wire.preliminary, + source, + provider_metadata: wire.provider_metadata, + } + } +} + +impl From for ToolResultWire { + fn from(result: ToolResult) -> Self { + let (dynamic, provider_executed) = match result.source { + ToolResultSource::Client { + dynamic, + provider_executed, + } => (Some(dynamic), provider_executed), + ToolResultSource::Provider { dynamic, mark } => ( + dynamic, + (mark == ProviderExecutedMark::Marked).then_some(true), + ), + }; + Self { + tool_call_id: result.tool_call_id, + tool_name: result.tool_name, + result: result.result, + is_error: result.is_error, + preliminary: result.preliminary, + dynamic, + provider_executed, + provider_metadata: result.provider_metadata, + } + } +} + +/// The JSON form of [`ToolResult`]: the E.3a struct, unchanged, so the wire +/// and `ToolResult.ts` stay the same (A7). Private. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +struct ToolResultWire { + /// Must match the corresponding call's `tool_call_id`. + tool_call_id: String, + tool_name: String, + /// The tool's output (usually a JSON-serializable value or plain text). + result: Value, + /// Whether the result is an error or error message. #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] - pub is_error: Option, + is_error: Option, /// Whether the result is preliminary (replaces prior, e.g. image previews). #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] - pub preliminary: Option, + preliminary: Option, /// Whether the tool is dynamic (defined at runtime, e.g. MCP tools). #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] - pub dynamic: Option, + dynamic: Option, + /// Whether the tool was executed by the provider (AI SDK + /// `generate-text/tool-result.ts:17,32`; #200:2292). Passed through as + /// given: absent / false / true. + #[ts(optional)] + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_executed: Option, /// Additional provider-specific metadata for the tool result. #[ts(optional)] #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider_metadata: Option, + provider_metadata: Option, } /// How the model should choose tools. diff --git a/aimux-core/tests/enum_state_wire_test.rs b/aimux-core/tests/enum_state_wire_test.rs new file mode 100644 index 000000000..b4af3dc5c --- /dev/null +++ b/aimux-core/tests/enum_state_wire_test.rs @@ -0,0 +1,343 @@ +//! Phase 1E shared wire samples (A7): `fixtures/v4/enum-state/*.jsonl`. +//! +//! Each line is deserialized into its type and serialized again; the output is +//! compared byte for byte with `baseline/.jsonl`, which was recorded at +//! `exp/restart@8455db9a` (before any enum change). Units E.1-E.4 run the same +//! comparison; where A7 §D25 changes the canonical output, they compare with +//! `normalized/.jsonl` instead and say so. E.2: `ToolCall` compares +//! with `normalized/` (D25); its D26 rows now fail conversion. +//! +//! Re-record the baseline (only at 8455db9a): `AIMUX_BLESS_ENUM_STATE=1 +//! cargo test -p aimux-core --test enum_state_wire_test`. + +use std::path::PathBuf; + +use aimux_core::stream_part::{StreamPart, TextStreamPart}; +use aimux_core::tool::{DynamicToolCall, RawToolCall, ToolCall, ToolCallValidity, ToolResult}; +use serde::Serialize; +use serde::de::DeserializeOwned; + +fn dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fixtures/v4/enum-state") +} + +fn lines(path: &PathBuf) -> Vec { + std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("{}: {e}", path.display())) + .lines() + .map(str::to_owned) + .collect() +} + +/// `serialize(deserialize(line))` for every line of `file`. +fn round_trip(file: &PathBuf) -> Vec { + lines(file) + .iter() + .enumerate() + .map(|(i, line)| { + let value: T = serde_json::from_str(line) + .unwrap_or_else(|e| panic!("{}:{}: {e}: {line}", file.display(), i + 1)); + serde_json::to_string(&value).unwrap() + }) + .collect() +} + +fn check(name: &str) { + let sample = dir().join(format!("{name}.jsonl")); + let baseline = dir().join("baseline").join(format!("{name}.jsonl")); + let out = round_trip::(&sample); + if std::env::var_os("AIMUX_BLESS_ENUM_STATE").is_some() { + std::fs::create_dir_all(baseline.parent().unwrap()).unwrap(); + std::fs::write( + &baseline, + out.iter().map(|l| format!("{l}\n")).collect::(), + ) + .unwrap(); + return; + } + let expected = lines(&baseline); + assert_eq!(out.len(), expected.len(), "{name}: line count"); + for (i, (got, want)) in out.iter().zip(&expected).enumerate() { + assert_eq!( + got, + want, + "{name}.jsonl line {}: differs from baseline", + i + 1 + ); + } +} + +/// The normalized samples are a fixed point of the round trip. +fn check_normalized(name: &str) { + let normalized = dir().join("normalized").join(format!("{name}.jsonl")); + let out = round_trip::(&normalized); + let expected = lines(&normalized); + assert_eq!(out.len(), expected.len(), "normalized/{name}: line count"); + for (i, (got, want)) in out.iter().zip(expected).enumerate() { + assert_eq!(*got, want, "normalized/{name}.jsonl line {}", i + 1); + } +} + +/// E.2: `ToolCall` is the Static/Dynamic enum, so its output is the A7 D25 +/// canonical form: every sample serializes to its `normalized/` line, and +/// normalized lines are a fixed point. +#[test] +fn tool_call_samples_match_normalized() { + let sample = dir().join("tool_call.jsonl"); + let normalized = lines(&dir().join("normalized").join("tool_call.jsonl")); + let out = round_trip::(&sample); + assert_eq!(out.len(), normalized.len()); + for (i, (got, want)) in out.iter().zip(&normalized).enumerate() { + assert_eq!(got, want, "tool_call.jsonl line {}", i + 1); + } + check_normalized::("tool_call"); +} + +/// D25: absent and explicit `false` give the same variant and the same JSON, +/// for `dynamic` and for `invalid`. +#[test] +fn tool_call_absent_and_false_are_the_same_variant() { + let base = r#""toolCallId":"call_1","toolName":"weather","input":{}"#; + let parse = + |extra: &str| -> ToolCall { serde_json::from_str(&format!("{{{base}{extra}}}")).unwrap() }; + for (absent, explicit_false) in [ + ("", r#","dynamic":false"#), + ("", r#","invalid":false"#), + (r#","dynamic":true"#, r#","dynamic":true,"invalid":false"#), + ] { + let (a, b) = (parse(absent), parse(explicit_false)); + assert_eq!(a.is_dynamic(), b.is_dynamic(), "{explicit_false}"); + assert!(matches!( + (&a, &b), + (ToolCall::Static(_), ToolCall::Static(_)) + | ( + ToolCall::Dynamic(DynamicToolCall { + validity: ToolCallValidity::Valid, + .. + }), + ToolCall::Dynamic(DynamicToolCall { + validity: ToolCallValidity::Valid, + .. + }) + ) + )); + assert_eq!( + serde_json::to_string(&a).unwrap(), + serde_json::to_string(&b).unwrap() + ); + } +} + +/// `providerExecuted` true / false / absent round-trips byte for byte, on +/// static, dynamic and invalid calls. +#[test] +fn tool_call_provider_executed_is_passed_through() { + for line in lines(&dir().join("normalized").join("tool_call.jsonl")) { + let call: ToolCall = serde_json::from_str(&line).unwrap(); + let value: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!( + call.provider_executed(), + value + .get("providerExecuted") + .and_then(serde_json::Value::as_bool), + "{line}" + ); + assert_eq!(serde_json::to_string(&call).unwrap(), line); + } +} + +/// D26: `invalid: true` without `dynamic: true`, or without `error`, fails +/// conversion (`tool_call_rejected.jsonl`, E.0). +#[test] +fn tool_call_rejected_samples_fail_conversion() { + let file = dir().join("tool_call_rejected.jsonl"); + let rows = lines(&file); + assert_eq!(rows.len(), 3); + for line in rows { + let error = serde_json::from_str::(&line).unwrap_err(); + assert!( + error.to_string().contains("invalid: true"), + "{error}: {line}" + ); + } +} + +/// An invalid call can only be built with its error: the variant holds it. +#[test] +fn invalid_tool_call_carries_its_error() { + let line = &lines(&dir().join("normalized").join("tool_call.jsonl"))[12]; + let call: ToolCall = serde_json::from_str(line).unwrap(); + assert!(call.is_dynamic() && call.is_invalid()); + assert!(matches!( + call.error(), + Some(aimux_core::error::AiMuxError::InvalidToolInput { .. }) + )); +} + +/// RFC-0042:84-93 core values in non-canonical spellings (duplicate key -> +/// last wins, `1e2` -> 100.0): compared by JSON semantics, not bytes. +#[test] +fn tool_call_semantic_samples_match_by_json_value() { + let file = dir().join("tool_call_semantic.jsonl"); + let expected = lines(&dir().join("normalized").join("tool_call_semantic.jsonl")); + let out = round_trip::(&file); + assert_eq!(out.len(), expected.len(), "tool_call_semantic: line count"); + for (i, (got, want)) in out.iter().zip(&expected).enumerate() { + let got: serde_json::Value = serde_json::from_str(got).unwrap(); + let want: serde_json::Value = serde_json::from_str(want).unwrap(); + assert_eq!(got, want, "tool_call_semantic.jsonl line {}", i + 1); + } +} + +/// E.3: `ToolResult` is split by source (A7). Every sample serializes to its +/// `normalized/` line (client-executed: `dynamic` always a boolean; +/// provider-executed: `dynamic` passed through), and normalized lines are a +/// fixed point. The 8455db9a baseline stays as the record of the old output. +#[test] +fn tool_result_samples_match_normalized() { + let sample = dir().join("tool_result.jsonl"); + let normalized = lines(&dir().join("normalized").join("tool_result.jsonl")); + let out = round_trip::(&sample); + assert_eq!(out.len(), normalized.len()); + for (i, (got, want)) in out.iter().zip(&normalized).enumerate() { + assert_eq!(got, want, "tool_result.jsonl line {}", i + 1); + } + check_normalized::("tool_result"); +} + +/// providerExecuted absent / false / true round-trip as given; client results +/// never gain the field; a provider-executed result without `dynamic` +/// round-trips byte for byte (A7 correction). +#[test] +fn tool_result_provider_executed_is_passed_through() { + let base = r#""toolCallId":"call_1","toolName":"web_search","result":{"hits":3}"#; + for (input, output, provider) in [ + ("", r#","dynamic":false"#, false), + (r#","dynamic":true"#, r#","dynamic":true"#, false), + ( + r#","providerExecuted":false"#, + r#","dynamic":false,"providerExecuted":false"#, + false, + ), + ( + r#","providerExecuted":true"#, + r#","providerExecuted":true"#, + true, + ), + ( + r#","dynamic":false,"providerExecuted":true"#, + r#","dynamic":false,"providerExecuted":true"#, + true, + ), + ] { + let json = format!("{{{base}{input}}}"); + let result: ToolResult = serde_json::from_str(&json).unwrap(); + assert_eq!(result.is_provider_executed(), provider, "{json}"); + assert_eq!( + serde_json::to_string(&result).unwrap(), + format!("{{{base}{output}}}"), + "{json}" + ); + } +} + +/// D25: absent and `false` give the same client variant and the same JSON. +#[test] +fn tool_result_absent_and_false_dynamic_are_the_same_variant() { + let base = r#""toolCallId":"call_1","toolName":"web_search","result":{"hits":3}"#; + let a: ToolResult = serde_json::from_str(&format!("{{{base}}}")).unwrap(); + let b: ToolResult = serde_json::from_str(&format!("{{{base},\"dynamic\":false}}")).unwrap(); + assert_eq!(a, b); + assert_eq!(a.source, b.source); +} + +/// A7 E.3: provider-layer (V4) results carry no providerExecuted; core +/// conversion marks them `true` and passes `dynamic` through. +#[test] +fn provider_layer_result_is_marked_by_core_conversion() { + use aimux_core::tool::ToolResultSource; + let v4 = ToolResult { + tool_call_id: "ws_1".into(), + tool_name: "webSearch".into(), + result: serde_json::json!({}), + is_error: None, + preliminary: None, + source: ToolResultSource::provider(None), + provider_metadata: None, + }; + assert_eq!( + serde_json::to_string(&v4).unwrap(), + r#"{"toolCallId":"ws_1","toolName":"webSearch","result":{}}"# + ); + assert_eq!( + serde_json::to_string(&v4.into_core()).unwrap(), + r#"{"toolCallId":"ws_1","toolName":"webSearch","result":{},"providerExecuted":true}"# + ); +} + +#[test] +fn raw_tool_call_samples_match_baseline() { + check::("raw_tool_call"); + check_normalized::("raw_tool_call"); +} + +#[test] +fn stream_tool_input_start_samples_match_baseline() { + check::("stream_tool_input_start"); + check_normalized::("stream_tool_input_start"); +} + +#[test] +fn text_stream_tool_input_start_samples_match_baseline() { + check::("text_stream_tool_input_start"); + check_normalized::("text_stream_tool_input_start"); +} + +/// At 8455db9a every sample already round-tripped unchanged: the baseline is +/// the sample itself, byte for byte. (The `tool_call` baselines stay as the +/// record of 8455db9a; E.2 compares `tool_call` with `normalized/`.) +#[test] +fn baseline_equals_samples() { + if std::env::var_os("AIMUX_BLESS_ENUM_STATE").is_some() { + // The baseline is being rewritten by the other tests right now. + return; + } + // tool_result: E.3a appended providerExecuted rows that 8455db9a cannot + // represent; its old rows are compared in tool_result_samples_match_baseline. + for name in [ + "raw_tool_call", + "stream_tool_input_start", + "text_stream_tool_input_start", + ] { + let sample = lines(&dir().join(format!("{name}.jsonl"))); + let baseline = lines(&dir().join("baseline").join(format!("{name}.jsonl"))); + assert_eq!(sample, baseline, "{name}"); + } +} + +/// Client results: `providerExecuted` is plain data. Absent and `false` +/// (with the normalized `dynamic`) round-trip byte for byte. +#[test] +fn client_tool_result_provider_executed_is_plain_data() { + use aimux_core::tool::ToolResultSource; + for (json, provider_executed) in [ + ( + r#"{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"dynamic":false}"#, + None, + ), + ( + r#"{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"dynamic":false,"providerExecuted":false}"#, + Some(false), + ), + ] { + let result: ToolResult = serde_json::from_str(json).unwrap(); + assert_eq!( + result.source, + ToolResultSource::Client { + dynamic: false, + provider_executed + } + ); + assert_eq!(serde_json::to_string(&result).unwrap(), json); + } +} diff --git a/aimux-core/tests/provider_executed_marking_test.rs b/aimux-core/tests/provider_executed_marking_test.rs new file mode 100644 index 000000000..45e4a3f5b --- /dev/null +++ b/aimux-core/tests/provider_executed_marking_test.rs @@ -0,0 +1,89 @@ +//! A7 E.3: core marks provider tool results `providerExecuted: true` where it +//! converts provider output (generate.rs step content, stream_part.rs text +//! stream), as AI SDK does (convert-language-model-content.ts:113, +//! stream-language-model-call.ts:772). The provider layer stays unmarked. + +use aimux_core::error::AiMuxError; +use aimux_core::generate::{GenerateTextOptions, generate_text}; +use aimux_core::language_model::LanguageModel; +use aimux_core::options::CallOptions; +use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::stream_part::{StreamPart, TextStreamPart}; +use aimux_core::tool::{ToolCall, ToolResult, ToolResultSource}; +use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; +use serde_json::json; + +fn v4_result() -> ToolResult { + ToolResult { + tool_call_id: "ws_1".into(), + tool_name: "web_search".into(), + result: json!({"hits": 3}), + is_error: None, + preliminary: None, + source: ToolResultSource::provider(None), + provider_metadata: None, + } +} + +struct Model; + +#[async_trait::async_trait] +impl LanguageModel for Model { + fn provider(&self) -> &str { + "mock" + } + fn model_id(&self) -> &str { + "mock" + } + async fn do_generate(&self, _: &CallOptions) -> Result { + Ok(GenerateResult { + content: vec![GenerateContent::ToolResult(v4_result())], + finish_reason: FinishReason { + unified: FinishReasonUnified::Stop, + raw: None, + }, + usage: Usage::default(), + warnings: vec![], + provider_metadata: None, + request: None, + response: None, + }) + } + async fn do_stream(&self, _: &CallOptions) -> Result { + unimplemented!() + } +} + +#[tokio::test] +async fn generate_marks_provider_tool_result() { + let result = generate_text(&Model, "hi", GenerateTextOptions::default()) + .await + .unwrap(); + let parts = serde_json::to_value(&result.content).unwrap(); + let tool_result = parts + .as_array() + .unwrap() + .iter() + .find(|p| p["type"] == "tool-result") + .expect("tool-result in step content"); + assert_eq!(tool_result["providerExecuted"], json!(true)); + assert!( + tool_result.get("dynamic").is_none(), + "dynamic passed through" + ); +} + +#[test] +fn text_stream_marks_provider_tool_result() { + let part: StreamPart = StreamPart::ToolResult(v4_result()); + let provider_json = serde_json::to_value(&part).unwrap(); + assert!( + provider_json.get("providerExecuted").is_none(), + "V4 layer unmarked" + ); + let Some(TextStreamPart::ToolResult(result)) = part.into_text_stream_part() else { + panic!("tool-result"); + }; + assert_eq!(result.provider_executed(), Some(true)); + assert_eq!(result.dynamic(), None); +} diff --git a/aimux-core/tests/tool_input_test.rs b/aimux-core/tests/tool_input_test.rs index b36cda348..18cac5261 100644 --- a/aimux-core/tests/tool_input_test.rs +++ b/aimux-core/tests/tool_input_test.rs @@ -30,10 +30,10 @@ async fn parses_and_validates_exact_json() { tool_call.dynamic = Some(true); let call = parse_tool_call(tool_call, Some(&[weather_tool()]), None, &[], None).await; - assert_eq!(call.input, json!({ "city": "Singapore", "days": 3 })); - assert_eq!(call.dynamic, None); - assert_eq!(call.invalid, None); - assert!(call.error.is_none()); + assert_eq!(*call.input(), json!({ "city": "Singapore", "days": 3 })); + assert!(!call.is_dynamic()); + assert!(!call.is_invalid()); + assert!(call.error().is_none()); } /// TS: should successfully process empty tool calls for tools that have no inputSchema @@ -52,8 +52,8 @@ async fn validates_empty_input_as_an_empty_object() { ) .await; - assert_eq!(call.input, json!({})); - assert_eq!(call.invalid, None); + assert_eq!(*call.input(), json!({})); + assert!(!call.is_invalid()); } /// TS: should throw InvalidToolInputError when args are invalid @@ -69,10 +69,10 @@ async fn preserves_parsed_input_when_schema_validation_fails() { ) .await; - assert_eq!(call.input, json!({ "city": 7 })); - assert_eq!(call.invalid, Some(true)); + assert_eq!(*call.input(), json!({ "city": 7 })); + assert!(call.is_invalid()); assert!(matches!( - call.error, + call.error(), Some(AiMuxError::InvalidToolInput { .. }) )); } @@ -92,13 +92,13 @@ async fn unknown_tool_is_an_invalid_dynamic_call_with_available_tools() { // The arguments still parse: `response_messages` drops a non-structured // input from the next turn's transcript, so an unknown tool called with // perfectly good arguments must not arrive here as raw text. - assert_eq!(call.input, json!({ "city": "Tokyo" })); - assert_eq!(call.dynamic, Some(true)); - assert_eq!(call.invalid, Some(true)); + assert_eq!(*call.input(), json!({ "city": "Tokyo" })); + assert!(call.is_dynamic()); + assert!(call.is_invalid()); assert!(matches!( - call.error, + call.error(), Some(AiMuxError::NoSuchTool { available_tools, .. }) - if available_tools == Some(vec!["weather".to_string()]) + if *available_tools == Some(vec!["weather".to_string()]) )); } @@ -131,8 +131,8 @@ async fn repair_runs_once_and_the_replacement_is_fully_revalidated() { .await; assert_eq!(calls.load(Ordering::SeqCst), 1); - assert_eq!(call.input, json!({ "city": "Singapore", "days": 3 })); - assert_eq!(call.invalid, None); + assert_eq!(*call.input(), json!({ "city": "Singapore", "days": 3 })); + assert!(!call.is_invalid()); } /// TS: should throw NoSuchToolError when tools is null @@ -149,7 +149,7 @@ async fn missing_tools_bypasses_repair_like_ai_sdk() { assert_eq!(calls.load(Ordering::SeqCst), 0); assert!(matches!( - call.error, + call.error(), Some(AiMuxError::NoSuchTool { available_tools: None, .. @@ -171,9 +171,9 @@ async fn repair_returning_none_keeps_the_original_failure() { ) .await; - assert_eq!(call.input, json!(original_input)); + assert_eq!(*call.input(), json!(original_input)); assert!(matches!( - call.error, + call.error(), Some(AiMuxError::InvalidToolInput { tool_input, .. }) if tool_input == original_input )); @@ -197,10 +197,10 @@ async fn repair_failure_keeps_both_typed_errors() { .await; assert!(matches!( - call.error, + call.error(), Some(AiMuxError::ToolCallRepair { original_error, cause }) - if matches!(*original_error, AiMuxError::InvalidToolInput { .. }) - && matches!(*cause, AiMuxError::Other(_)) + if matches!(**original_error, AiMuxError::InvalidToolInput { .. }) + && matches!(**cause, AiMuxError::Other(_)) )); } @@ -213,8 +213,8 @@ async fn provider_executed_dynamic_calls_do_not_require_a_local_tool() { let call = parse_tool_call(tool_call, None, None, &[], None).await; - assert_eq!(call.input, json!({ "query": "rust" })); - assert_eq!(call.invalid, None); + assert_eq!(*call.input(), json!({ "query": "rust" })); + assert!(!call.is_invalid()); } fn weather_tool_schema() -> serde_json::Value { diff --git a/aimux-provider-utils/src/streaming_tool_call_tracker.rs b/aimux-provider-utils/src/streaming_tool_call_tracker.rs index c4603239e..02a62fc84 100644 --- a/aimux-provider-utils/src/streaming_tool_call_tracker.rs +++ b/aimux-provider-utils/src/streaming_tool_call_tracker.rs @@ -233,7 +233,7 @@ impl StreamingToolCallTracker { tool_call_id: call.id, tool_name: call.name, // Raw argument text; Core parses it. - input: call.arguments, + input: call.arguments.into(), provider_executed: None, dynamic: None, provider_metadata: call.provider_metadata, diff --git a/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs b/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs index 28c6d070e..3af264e42 100644 --- a/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs +++ b/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs @@ -72,7 +72,7 @@ fn project(part: StreamPart) -> Part { }) => Part::ToolCall { tool_call_id, tool_name, - input, + input: input.to_string(), provider_metadata, }, other => panic!("tracker emitted an unexpected part: {other:?}"), diff --git a/aimux-providers/src/anthropic/stream.rs b/aimux-providers/src/anthropic/stream.rs index 025c6e192..de82ea181 100644 --- a/aimux-providers/src/anthropic/stream.rs +++ b/aimux-providers/src/anthropic/stream.rs @@ -382,7 +382,7 @@ pub(crate) fn stream_parts_for_result_block( result, is_error, preliminary: None, - dynamic: None, + source: aimux_core::tool::ToolResultSource::provider(None), provider_metadata: tool_call_caller_metadata(caller, CANONICAL), }) }; @@ -404,7 +404,7 @@ pub(crate) fn stream_parts_for_result_block( }), is_error: Some(true), preliminary: None, - dynamic: None, + source: aimux_core::tool::ToolResultSource::provider(None), provider_metadata: tool_call_caller_metadata(caller, CANONICAL), })]; }; @@ -502,7 +502,7 @@ pub(crate) fn stream_parts_for_result_block( result: content.clone(), is_error: *is_error, preliminary: None, - dynamic: Some(true), + source: aimux_core::tool::ToolResultSource::provider(Some(true)), provider_metadata: call.map(|(_, server)| { provider_namespace( options_name, @@ -701,10 +701,11 @@ pub(crate) fn parse_anthropic_content( || name.clone(), |name| names.to_custom_tool_name(name).to_string(), ), - input: toolset_name.as_ref().map_or_else( + input: (toolset_name.as_ref().map_or_else( || input.to_string(), |_| toolset_member_input(name, input).to_string(), - ), + )) + .into(), provider_executed: None, dynamic: None, provider_metadata: tool_call_metadata( @@ -738,7 +739,7 @@ pub(crate) fn parse_anthropic_content( content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: id.clone(), tool_name: names.to_custom_tool_name(provider_name).to_string(), - input: normalized_server_tool_input(name, input).to_string(), + input: normalized_server_tool_input(name, input).to_string().into(), provider_executed: Some(true), dynamic: (provider_name == "code_execution" && names.mark_code_execution_dynamic()) @@ -756,7 +757,7 @@ pub(crate) fn parse_anthropic_content( content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: id.clone(), tool_name: name.clone(), - input: input.to_string(), + input: input.to_string().into(), provider_executed: Some(true), dynamic: Some(true), provider_metadata: Some( @@ -800,7 +801,7 @@ pub(crate) fn parse_anthropic_content( ), is_error: None, preliminary: None, - dynamic: None, + source: aimux_core::tool::ToolResultSource::provider(None), provider_metadata: tool_call_caller_metadata( caller.as_ref(), CANONICAL, @@ -836,7 +837,7 @@ pub(crate) fn parse_anthropic_content( }), is_error: Some(true), preliminary: None, - dynamic: None, + source: aimux_core::tool::ToolResultSource::provider(None), provider_metadata: tool_call_caller_metadata(caller.as_ref(), CANONICAL), })), } @@ -856,7 +857,7 @@ pub(crate) fn parse_anthropic_content( result, is_error, preliminary: None, - dynamic: None, + source: aimux_core::tool::ToolResultSource::provider(None), provider_metadata: tool_call_caller_metadata(caller.as_ref(), CANONICAL), })); } @@ -871,7 +872,7 @@ pub(crate) fn parse_anthropic_content( result, is_error, preliminary: None, - dynamic: None, + source: aimux_core::tool::ToolResultSource::provider(None), provider_metadata: None, })); } @@ -891,7 +892,7 @@ pub(crate) fn parse_anthropic_content( result: payload.clone(), is_error: None, preliminary: None, - dynamic: None, + source: aimux_core::tool::ToolResultSource::provider(None), provider_metadata: None, })); } @@ -910,7 +911,7 @@ pub(crate) fn parse_anthropic_content( result, is_error, preliminary: None, - dynamic: None, + source: aimux_core::tool::ToolResultSource::provider(None), provider_metadata: None, })); } @@ -925,7 +926,7 @@ pub(crate) fn parse_anthropic_content( result, is_error, preliminary: None, - dynamic: None, + source: aimux_core::tool::ToolResultSource::provider(None), provider_metadata: None, })); } @@ -945,7 +946,7 @@ pub(crate) fn parse_anthropic_content( result: payload.clone(), is_error: *is_error, preliminary: None, - dynamic: Some(true), + source: aimux_core::tool::ToolResultSource::provider(Some(true)), provider_metadata: call.map(|(_, server)| { provider_namespace( options_name, @@ -1417,7 +1418,7 @@ impl AnthropicMessagesStream { controller.enqueue(StreamPart::ToolCall(RawToolCall { tool_call_id: id.clone(), tool_name: name.clone(), - input: input.to_string(), + input: input.to_string().into(), provider_executed: Some(true), dynamic: Some(true), provider_metadata: Some( @@ -1639,7 +1640,7 @@ impl AnthropicMessagesStream { controller.enqueue(StreamPart::ToolCall(RawToolCall { tool_call_id: id, tool_name: name, - input, + input: input.into(), provider_executed, dynamic, provider_metadata, diff --git a/aimux-providers/src/bedrock/model.rs b/aimux-providers/src/bedrock/model.rs index 08ec57395..78dcabf4c 100644 --- a/aimux-providers/src/bedrock/model.rs +++ b/aimux-providers/src/bedrock/model.rs @@ -181,7 +181,7 @@ impl LanguageModel for BedrockModel { { is_json_response_from_tool = true; *part = GenerateContent::Text { - text: tool.input.clone(), + text: tool.input.to_string(), provider_metadata: None, }; } @@ -555,7 +555,7 @@ impl AmazonBedrockChatStream { controller.enqueue(StreamPart::ToolCall(RawToolCall { tool_call_id: normalize_tool_call_id(&id, self.model_id.contains("mistral.")), tool_name: name, - input, + input: input.into(), provider_executed: None, dynamic: None, provider_metadata: None, @@ -812,7 +812,7 @@ fn extract_content( input: if tool_use.input.is_null() { "{}".into() } else { - tool_use.input.to_string() + tool_use.input.to_string().into() }, provider_executed: None, dynamic: None, diff --git a/aimux-providers/src/cohere/model.rs b/aimux-providers/src/cohere/model.rs index 861881943..ff37acec2 100644 --- a/aimux-providers/src/cohere/model.rs +++ b/aimux-providers/src/cohere/model.rs @@ -202,7 +202,7 @@ impl LanguageModel for CohereModel { content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: tc.id.clone(), tool_name: tc.function.name.clone(), - input, + input: input.into(), provider_executed: None, dynamic: None, provider_metadata: None, @@ -475,7 +475,7 @@ impl Transformer for CohereChatStream { controller.enqueue(StreamPart::ToolCall(RawToolCall { tool_call_id: tool.id, tool_name: tool.name, - input, + input: input.into(), provider_executed: None, dynamic: None, provider_metadata: None, diff --git a/aimux-providers/src/deepseek/model.rs b/aimux-providers/src/deepseek/model.rs index 272858c2d..02f454afa 100644 --- a/aimux-providers/src/deepseek/model.rs +++ b/aimux-providers/src/deepseek/model.rs @@ -442,7 +442,7 @@ impl LanguageModel for DeepSeekChatLanguageModel { .filter(|id| !id.is_empty()) .unwrap_or_else(generate_id), tool_name: call.function.name, - input: call.function.arguments, + input: call.function.arguments.into(), provider_executed: None, dynamic: None, provider_metadata: None, diff --git a/aimux-providers/src/google/model.rs b/aimux-providers/src/google/model.rs index c119c77b8..cfea14bfa 100644 --- a/aimux-providers/src/google/model.rs +++ b/aimux-providers/src/google/model.rs @@ -458,7 +458,7 @@ impl GoogleLanguageModelStream { controller.enqueue(StreamPart::ToolCall(RawToolCall { tool_call_id: id.clone(), tool_name: self.code_execution_tool_name.clone(), - input: ec.to_string(), + input: ec.to_string().into(), provider_executed: Some(true), dynamic: None, provider_metadata: None, @@ -483,7 +483,7 @@ impl GoogleLanguageModelStream { result: json!({ "outcome": outcome, "output": output }), is_error: None, preliminary: None, - dynamic: None, + source: aimux_core::tool::ToolResultSource::provider(None), provider_metadata: None, })); } @@ -506,7 +506,7 @@ impl GoogleLanguageModelStream { controller.enqueue(StreamPart::ToolCall(RawToolCall { tool_call_id: id, tool_name: format!("server:{tool_type}"), - input: args.to_string(), + input: args.to_string().into(), provider_executed: Some(true), dynamic: Some(true), provider_metadata: Some(server_meta), @@ -537,7 +537,7 @@ impl GoogleLanguageModelStream { result: response, is_error: None, preliminary: None, - dynamic: None, + source: aimux_core::tool::ToolResultSource::provider(None), provider_metadata: Some(server_meta), })); } else if let Some(inline) = part.get("inlineData") { @@ -635,7 +635,7 @@ impl GoogleLanguageModelStream { controller.enqueue(StreamPart::ToolCall(RawToolCall { tool_call_id: id, tool_name: name.to_string(), - input: args_str, + input: args_str.into(), provider_executed: None, dynamic: None, provider_metadata: thought_sig_meta.clone(), @@ -818,7 +818,7 @@ fn extract_content_from_candidate( content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: id.clone(), tool_name: code_execution_tool_name.to_string(), - input: ec.to_string(), + input: ec.to_string().into(), provider_executed: Some(true), dynamic: None, provider_metadata: None, @@ -839,7 +839,7 @@ fn extract_content_from_candidate( result: json!({ "outcome": outcome, "output": output }), is_error: None, preliminary: None, - dynamic: None, + source: aimux_core::tool::ToolResultSource::provider(None), provider_metadata: None, })); } @@ -890,7 +890,7 @@ fn extract_content_from_candidate( content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: id, tool_name: name, - input: input.to_string(), + input: input.to_string().into(), provider_executed: None, dynamic: None, provider_metadata: thought_sig_meta.clone(), @@ -940,7 +940,7 @@ fn extract_content_from_candidate( content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: id, tool_name: format!("server:{tool_type}"), - input: input.to_string(), + input: input.to_string().into(), provider_executed: Some(true), dynamic: Some(true), provider_metadata: Some(server_meta), @@ -970,7 +970,7 @@ fn extract_content_from_candidate( result: response, is_error: None, preliminary: None, - dynamic: None, + source: aimux_core::tool::ToolResultSource::provider(None), provider_metadata: Some(server_meta), })); last_server_tool_call_id = None; diff --git a/aimux-providers/src/groq/model.rs b/aimux-providers/src/groq/model.rs index 01e820208..674d9f971 100644 --- a/aimux-providers/src/groq/model.rs +++ b/aimux-providers/src/groq/model.rs @@ -323,7 +323,7 @@ impl LanguageModel for GroqChatLanguageModel { .filter(|id| !id.is_empty()) .unwrap_or_else(generate_id), tool_name: tool_call.function.name, - input: tool_call.function.arguments, + input: tool_call.function.arguments.into(), provider_executed: None, dynamic: None, provider_metadata: None, diff --git a/aimux-providers/src/huggingface/responses.rs b/aimux-providers/src/huggingface/responses.rs index 3fa39e504..22e3a5f48 100644 --- a/aimux-providers/src/huggingface/responses.rs +++ b/aimux-providers/src/huggingface/responses.rs @@ -442,7 +442,7 @@ impl Transformer for HuggingFaceResponsesStream { controller.enqueue(StreamPart::ToolCall(RawToolCall { tool_call_id: call_id.clone(), tool_name: name.clone(), - input, + input: input.into(), provider_executed: None, dynamic: None, provider_metadata: None, @@ -457,7 +457,9 @@ impl Transformer for HuggingFaceResponsesStream { result: Value::String(output.to_string()), is_error: None, preliminary: None, - dynamic: None, + source: aimux_core::tool::ToolResultSource::provider( + None, + ), provider_metadata: None, })); } @@ -1014,7 +1016,7 @@ fn build_generate_content(response: &Value) -> Result, AiMu content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: call_id.to_string(), tool_name: name.to_string(), - input, + input: input.into(), provider_executed: None, dynamic: None, provider_metadata: None, @@ -1032,7 +1034,7 @@ fn build_generate_content(response: &Value) -> Result, AiMu content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: id.to_string(), tool_name: name.to_string(), - input, + input: input.into(), provider_executed: Some(true), dynamic: Some(true), provider_metadata: None, @@ -1045,7 +1047,7 @@ fn build_generate_content(response: &Value) -> Result, AiMu result: output.clone(), is_error: None, preliminary: None, - dynamic: Some(true), + source: aimux_core::tool::ToolResultSource::provider(Some(true)), provider_metadata: None, })); } @@ -1057,7 +1059,7 @@ fn build_generate_content(response: &Value) -> Result, AiMu content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: id.to_string(), tool_name: "list_tools".to_string(), - input: json!({ "server_label": server_label }).to_string(), + input: json!({ "server_label": server_label }).to_string().into(), provider_executed: Some(true), dynamic: Some(true), provider_metadata: None, @@ -1070,7 +1072,7 @@ fn build_generate_content(response: &Value) -> Result, AiMu result: json!({ "tools": tools }), is_error: None, preliminary: None, - dynamic: Some(true), + source: aimux_core::tool::ToolResultSource::provider(Some(true)), provider_metadata: None, })); } diff --git a/aimux-providers/src/mistral/model.rs b/aimux-providers/src/mistral/model.rs index b39fa8664..d73644303 100644 --- a/aimux-providers/src/mistral/model.rs +++ b/aimux-providers/src/mistral/model.rs @@ -237,7 +237,7 @@ impl LanguageModel for MistralModel { content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: tc.id, tool_name: tc.function.name, - input, + input: input.into(), provider_executed: None, dynamic: None, provider_metadata: None, diff --git a/aimux-providers/src/open_responses.rs b/aimux-providers/src/open_responses.rs index 4d3223a5c..f736c42c0 100644 --- a/aimux-providers/src/open_responses.rs +++ b/aimux-providers/src/open_responses.rs @@ -343,7 +343,7 @@ impl LanguageModel for OpenResponsesModel { content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: call_id, tool_name: name, - input, + input: input.into(), provider_executed: None, dynamic: None, provider_metadata: None, @@ -663,7 +663,7 @@ impl Transformer for OpenResponsesStream { controller.enqueue(StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, - input, + input: input.into(), provider_executed: None, dynamic: None, provider_metadata: None, diff --git a/aimux-providers/src/openai/model.rs b/aimux-providers/src/openai/model.rs index fa079cd69..e1da418f6 100644 --- a/aimux-providers/src/openai/model.rs +++ b/aimux-providers/src/openai/model.rs @@ -235,7 +235,7 @@ pub(crate) async fn execute_generate( content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: tc.id, tool_name: tc.function.name, - input, + input: input.into(), provider_executed: None, dynamic: None, provider_metadata: None, diff --git a/aimux-providers/src/openai/responses/provider_events.rs b/aimux-providers/src/openai/responses/provider_events.rs index cbd7ce64c..79bc88da8 100644 --- a/aimux-providers/src/openai/responses/provider_events.rs +++ b/aimux-providers/src/openai/responses/provider_events.rs @@ -33,7 +33,7 @@ pub(crate) fn provider_tool_content( GenerateContent::ToolCall(RawToolCall { tool_call_id: id.to_string(), tool_name, - input, + input: input.into(), provider_executed: executed, dynamic, provider_metadata: meta, @@ -46,7 +46,7 @@ pub(crate) fn provider_tool_content( result: value, is_error: None, preliminary: None, - dynamic: None, + source: aimux_core::tool::ToolResultSource::provider(None), provider_metadata: meta, }) }; diff --git a/aimux-providers/src/openai/responses/responses_convert.rs b/aimux-providers/src/openai/responses/responses_convert.rs index d922f63bc..2f5dcd674 100644 --- a/aimux-providers/src/openai/responses/responses_convert.rs +++ b/aimux-providers/src/openai/responses/responses_convert.rs @@ -226,7 +226,7 @@ pub(crate) fn build_responses_generate_result_with_tools( content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: call_id, tool_name: name, - input, + input: input.into(), provider_executed: None, dynamic: None, provider_metadata: Some( @@ -253,7 +253,7 @@ pub(crate) fn build_responses_generate_result_with_tools( content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: call_id, tool_name: name, - input, + input: input.into(), provider_executed: None, dynamic: None, provider_metadata: Some( @@ -918,7 +918,7 @@ impl Transformer for OpenAIResponsesStream { result: json!({"result": parsed["partial_image_b64"]}), preliminary: Some(true), is_error: None, - dynamic: None, + source: aimux_core::tool::ToolResultSource::provider(None), provider_metadata: None, })); } @@ -1208,7 +1208,7 @@ impl Transformer for OpenAIResponsesStream { controller.enqueue(StreamPart::ToolCall(RawToolCall { tool_call_id: call_id, tool_name: name, - input, + input: input.into(), provider_executed: None, dynamic: None, provider_metadata: Some( @@ -1242,7 +1242,7 @@ impl Transformer for OpenAIResponsesStream { controller.enqueue(StreamPart::ToolCall(RawToolCall { tool_call_id: call_id, tool_name: name, - input, + input: input.into(), provider_executed: None, dynamic: None, provider_metadata: Some( @@ -1404,7 +1404,9 @@ impl Transformer for OpenAIResponsesStream { id: call .tool_call_id .clone(), - delta: call.input.clone(), + delta: call + .input + .to_string(), provider_metadata: None, }, ); @@ -1670,7 +1672,7 @@ fn provider_tool_start(item: &Value, names: &HashMap) -> Vec ToolResult { result, is_error, preliminary: None, - dynamic: None, + source: aimux_core::tool::ToolResultSource::provider(None), provider_metadata: None, } } @@ -232,7 +232,7 @@ impl LanguageModel for XaiResponsesModel { .unwrap_or("") .to_string(), tool_name: tool_name.clone(), - input: "{}".to_string(), + input: "{}".to_string().into(), provider_executed: Some(true), dynamic: None, provider_metadata: None, @@ -251,7 +251,7 @@ impl LanguageModel for XaiResponsesModel { content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: part_id.to_string(), tool_name: tool_name.clone(), - input: String::new(), + input: aimux_core::tool::RawArguments::default(), provider_executed: Some(true), dynamic: None, provider_metadata: None, @@ -281,7 +281,7 @@ impl LanguageModel for XaiResponsesModel { result: json!({ "queries": queries, "results": results }), is_error: None, preliminary: None, - dynamic: None, + source: aimux_core::tool::ToolResultSource::provider(None), provider_metadata: None, })); continue; @@ -307,7 +307,7 @@ impl LanguageModel for XaiResponsesModel { content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: part_id.to_string(), tool_name: tool_name.clone(), - input: tool_input, + input: tool_input.into(), provider_executed: Some(true), dynamic: None, provider_metadata: None, @@ -319,7 +319,7 @@ impl LanguageModel for XaiResponsesModel { result: map_web_search_action(part.get("action").unwrap_or(&Value::Null)), is_error: None, preliminary: None, - dynamic: None, + source: aimux_core::tool::ToolResultSource::provider(None), provider_metadata: None, })); } @@ -373,7 +373,7 @@ impl LanguageModel for XaiResponsesModel { content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: call_id.to_string(), tool_name: name.to_string(), - input, + input: input.into(), provider_executed: None, dynamic: None, provider_metadata: None, @@ -1029,7 +1029,7 @@ impl Transformer for XaiResponsesStream { controller.enqueue(StreamPart::ToolCall(RawToolCall { tool_call_id: part_id.to_string(), tool_name: tool_name.clone(), - input: input.to_string(), + input: input.to_string().into(), provider_executed: Some(true), dynamic: None, provider_metadata: None, @@ -1062,7 +1062,7 @@ impl Transformer for XaiResponsesStream { result: json!({ "queries": queries, "results": results }), is_error: None, preliminary: None, - dynamic: None, + source: aimux_core::tool::ToolResultSource::provider(None), provider_metadata: None, })); } @@ -1115,7 +1115,7 @@ impl Transformer for XaiResponsesStream { controller.enqueue(StreamPart::ToolCall(RawToolCall { tool_call_id: part_id.to_string(), tool_name: tool_name.clone(), - input: tool_input, + input: tool_input.into(), provider_executed: Some(true), dynamic: None, provider_metadata: None, @@ -1135,7 +1135,7 @@ impl Transformer for XaiResponsesStream { }, is_error: None, preliminary: None, - dynamic: None, + source: aimux_core::tool::ToolResultSource::provider(None), provider_metadata: None, })); } @@ -1225,7 +1225,7 @@ impl Transformer for XaiResponsesStream { controller.enqueue(StreamPart::ToolCall(RawToolCall { tool_call_id: call_id.to_string(), tool_name: name.to_string(), - input, + input: input.into(), provider_executed: None, dynamic: None, provider_metadata: None, diff --git a/aimux-providers/tests/data_loss_regression_test.rs b/aimux-providers/tests/data_loss_regression_test.rs index 7bd988e72..d19d2b917 100644 --- a/aimux-providers/tests/data_loss_regression_test.rs +++ b/aimux-providers/tests/data_loss_regression_test.rs @@ -228,8 +228,8 @@ fn tool_calls(content: &[GenerateContent]) -> Vec> { // Provider results intentionally carry the exact wire string; // parse only in this assertion helper so nested data-loss // checks remain readable without weakening that boundary. - let parsed_input = - serde_json::from_str(input).unwrap_or_else(|_| Value::String(input.clone())); + let parsed_input = serde_json::from_str(input) + .unwrap_or_else(|_| Value::String(input.to_string())); Some(( tool_call_id.as_str(), tool_name.as_str(), @@ -258,20 +258,21 @@ fn tool_results(content: &[GenerateContent]) -> Vec> { content .iter() .filter_map(|c| match c { - GenerateContent::ToolResult(ToolResult { - tool_call_id, - tool_name, - result, - is_error, - dynamic, - provider_metadata, - .. - }) => Some(( + GenerateContent::ToolResult( + tool_result @ ToolResult { + tool_call_id, + tool_name, + result, + is_error, + provider_metadata, + .. + }, + ) => Some(( tool_call_id.as_str(), tool_name.as_str(), result, *is_error, - *dynamic, + tool_result.dynamic(), provider_metadata.as_ref(), )), _ => None, diff --git a/aimux-providers/tests/e2e_test.rs b/aimux-providers/tests/e2e_test.rs index 5e023300e..caa081b53 100644 --- a/aimux-providers/tests/e2e_test.rs +++ b/aimux-providers/tests/e2e_test.rs @@ -12,7 +12,7 @@ use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; use aimux_core::language_model_message::ToolResultOutput; use aimux_core::message::{MessageContent, ModelMessage, Role}; use aimux_core::stream_part::TextStreamPart; -use aimux_core::tool::{FunctionTool, Tool, ToolCall, ToolChoice}; +use aimux_core::tool::{FunctionTool, Tool, ToolChoice}; use aimux_core::types::FinishReasonUnified; use aimux_providers::anthropic::{AnthropicProvider, AnthropicProviderSettings, create_anthropic}; use aimux_providers::openai::{OpenAIProvider, OpenAIProviderSettings, create_openai}; @@ -194,8 +194,8 @@ async fn e2e_openai_generate_with_tools() { // The first call should return a tool call assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); assert_eq!(result.tool_calls.len(), 1); - assert_eq!(result.tool_calls[0].tool_name, "get_weather"); - assert_eq!(result.tool_calls[0].input["location"], "Tokyo"); + assert_eq!(result.tool_calls[0].tool_name(), "get_weather"); + assert_eq!(result.tool_calls[0].input()["location"], "Tokyo"); } #[tokio::test] @@ -590,9 +590,9 @@ async fn e2e_openai_tool_call_round_trip() { FinishReasonUnified::ToolCalls ); assert_eq!(result1.tool_calls.len(), 1); - assert_eq!(result1.tool_calls[0].tool_name, "get_weather"); - assert_eq!(result1.tool_calls[0].input["location"], "Tokyo"); - assert_eq!(result1.tool_calls[0].tool_call_id, "call_abc"); + assert_eq!(result1.tool_calls[0].tool_name(), "get_weather"); + assert_eq!(result1.tool_calls[0].input()["location"], "Tokyo"); + assert_eq!(result1.tool_calls[0].tool_call_id(), "call_abc"); // Step 2: user executes the tool, then builds the full conversation: // user → assistant(tool_call) → tool(result) @@ -783,7 +783,7 @@ async fn e2e_openai_tool_choice_required() { // Model returned a tool call assert_eq!(result.tool_calls.len(), 1); - assert_eq!(result.tool_calls[0].tool_name, "get_weather"); + assert_eq!(result.tool_calls[0].tool_name(), "get_weather"); // Verify the request body carries tool_choice: "required" let requests = server @@ -879,9 +879,7 @@ async fn e2e_openai_stream_tool_calls() { // If a complete ToolCall part exists, verify its fields let tool_call = parts.iter().find_map(|p| match p { - TextStreamPart::ToolCall(ToolCall { - tool_name, input, .. - }) => Some((tool_name, input)), + TextStreamPart::ToolCall(call) => Some((call.tool_name(), call.input())), _ => None, }); if let Some((tool_name, input)) = tool_call { diff --git a/aimux-providers/tests/huggingface_responses_test.rs b/aimux-providers/tests/huggingface_responses_test.rs index 648194dae..e558296b5 100644 --- a/aimux-providers/tests/huggingface_responses_test.rs +++ b/aimux-providers/tests/huggingface_responses_test.rs @@ -580,11 +580,14 @@ async fn should_handle_mcp_tools_with_annotations() { .await .expect("dynamic MCP call should pass Core validation without local tools"); let call = result.tool_calls.first().expect("MCP tool call"); - assert_eq!(call.tool_name, "search"); - assert_eq!(call.provider_executed, Some(true)); - assert_eq!(call.dynamic, Some(true)); - assert_eq!(call.invalid, None); - assert_eq!(call.input, json!({ "query": "San Francisco tech events" })); + assert_eq!(call.tool_name(), "search"); + assert_eq!(call.provider_executed(), Some(true)); + assert!(call.is_dynamic()); + assert!(!call.is_invalid()); + assert_eq!( + *call.input(), + json!({ "query": "San Francisco tech events" }) + ); } // ════════════════════════════════════════════════════════════════════════════ diff --git a/aimux-providers/tests/provider_executed_core_conversion_test.rs b/aimux-providers/tests/provider_executed_core_conversion_test.rs new file mode 100644 index 000000000..aa44755cb --- /dev/null +++ b/aimux-providers/tests/provider_executed_core_conversion_test.rs @@ -0,0 +1,292 @@ +//! A7 E.3: every provider that builds provider-executed tool results yields +//! `"providerExecuted": true` after core conversion, on the generate path +//! (`generate_text`, generate.rs step content) and the stream path +//! (`stream_text`, `into_text_stream_part`). Provider-layer output itself stays +//! unmarked (V4 has no providerExecuted on tool results). +//! +//! Inputs are the repo's recorded cassettes where one exists (anthropic, +//! gemini, openai responses), the gemini cassettes replayed against Vertex +//! (same wire), and the upstream-port mock bodies for xAI and Hugging Face. + +#[path = "common/mock_fetch.rs"] +mod mock_fetch; + +use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; +use aimux_core::language_model::LanguageModel; +use aimux_core::tool::{ProviderTool, Tool}; +use aimux_provider_utils::Resolvable; +use aimux_providers::anthropic::{AnthropicProviderSettings, create_anthropic}; +use aimux_providers::openai::{OpenAIProviderSettings, create_openai}; +use aimux_providers::vertex::{VertexProviderSettings, create_google_vertex}; +use aimux_providers::xai::{XAIProviderSettings, create_xai}; +use aimux_providers::{ + GoogleProviderSettings, HuggingFaceProviderSettings, create_google, create_huggingface, +}; +use futures::StreamExt; +use mock_fetch::{Canned, MockFetch}; +use serde_json::{Value, json}; + +fn cassette(rel: &str) -> Canned { + let path = format!("{}/tests/cassettes/{rel}", env!("CARGO_MANIFEST_DIR")); + let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + let body = v["response"]["body"].as_str().unwrap().to_owned(); + let trimmed = body.trim_start(); + let content_type = if trimmed.starts_with("event:") || trimmed.starts_with("data:") { + "text/event-stream" + } else { + "application/json" + }; + Canned { + status: 200, + headers: vec![("content-type".into(), content_type.into())], + body: body.into_bytes(), + } +} + +fn sse(events: &[Value]) -> Canned { + Canned { + status: 200, + headers: vec![("content-type".into(), "text/event-stream".into())], + body: (events + .iter() + .map(|event| format!("data: {event}\n\n")) + .collect::() + + "data: [DONE]\n\n") + .into_bytes(), + } +} + +fn options(tool_id: &str, name: &str) -> GenerateTextOptions { + GenerateTextOptions { + tools: Some(vec![Tool::Provider(ProviderTool { + id: tool_id.into(), + name: name.into(), + args: serde_json::Map::new(), + })]), + ..Default::default() + } +} + +/// Every tool-result / tool-error in `parts` is marked; returns how many. +fn assert_all_marked(what: &str, parts: &Value) -> usize { + let results: Vec<&Value> = parts + .as_array() + .unwrap() + .iter() + .filter(|p| p["type"] == "tool-result" || p["type"] == "tool-error") + .collect(); + assert!(!results.is_empty(), "{what}: no provider tool result"); + for r in &results { + assert_eq!(r["providerExecuted"], json!(true), "{what}: {r}"); + } + results.len() +} + +async fn generate(what: &str, model: &dyn LanguageModel, opts: GenerateTextOptions) -> usize { + let result = generate_text(model, "hi", opts).await.unwrap(); + assert_all_marked(what, &serde_json::to_value(&result.content).unwrap()) +} + +async fn stream(what: &str, model: &dyn LanguageModel, opts: GenerateTextOptions) -> usize { + let result = stream_text(model, "hi", opts).await.unwrap(); + let parts: Vec = result + .stream + .map(|p| serde_json::to_value(p.unwrap()).unwrap()) + .collect() + .await; + assert_all_marked(what, &Value::Array(parts)) +} + +macro_rules! anthropic { + ($canned:expr) => { + create_anthropic(AnthropicProviderSettings { + api_key: Some("k".into()), + base_url: Some("https://example.test/v1".into()), + fetch: Some(MockFetch::new(vec![$canned]).transport()), + ..Default::default() + }) + .unwrap() + .messages("claude-sonnet-4-0") + }; +} + +#[tokio::test] +async fn anthropic_generate_and_stream() { + let ws = options("anthropic.web_search_20250305", "web_search"); + let model = anthropic!(cassette("anthropic/test_pause_turn_web_search_vcr_1.json")); + generate("anthropic web_search", &model, ws).await; + let ce = options("anthropic.code_execution_20250825", "code_execution"); + let model = anthropic!(cassette( + "anthropic/test_anthropic_code_execution_tool_stream.json" + )); + stream("anthropic code_execution stream", &model, ce).await; + let wf = options("anthropic.web_fetch_20250910", "web_fetch"); + let model = anthropic!(cassette( + "anthropic/test_anthropic_web_fetch_tool_stream.json" + )); + stream("anthropic web_fetch stream", &model, wf).await; +} + +fn google(canned: Canned) -> impl LanguageModel { + create_google(GoogleProviderSettings { + api_key: Some("k".into()), + base_url: Some("https://example.test/v1beta".into()), + fetch: Some(MockFetch::new(vec![canned]).transport()), + ..Default::default() + }) + .unwrap() + .chat("gemini-3-flash-preview") +} + +fn vertex(canned: Canned) -> impl LanguageModel { + create_google_vertex(VertexProviderSettings { + api_key: Some(Resolvable::Value("k".into())), + base_url: Some("https://example.test".into()), + fetch: Some(MockFetch::new(vec![canned]).transport()), + ..Default::default() + }) + .unwrap() + .chat("gemini-2.5-flash") +} + +#[tokio::test] +async fn google_generate_and_stream() { + let ce = || options("google.code_execution", "code_execution"); + let model = google(cassette("gemini/test_code_execution.json")); + generate("google code_execution", &model, ce()).await; + let model = google(cassette("gemini/test_code_execution_stream.json")); + stream("google code_execution stream", &model, ce()).await; +} + +#[tokio::test] +async fn vertex_generate_and_stream() { + let ce = || options("google.code_execution", "code_execution"); + let model = vertex(cassette("gemini/test_code_execution.json")); + generate("vertex code_execution", &model, ce()).await; + let model = vertex(cassette("gemini/test_code_execution_stream.json")); + stream("vertex code_execution stream", &model, ce()).await; +} + +fn openai_responses(canned: Canned) -> impl LanguageModel { + create_openai(OpenAIProviderSettings { + api_key: Some("k".into()), + base_url: Some("https://example.test/v1".into()), + fetch: Some(MockFetch::new(vec![canned]).transport()), + ..Default::default() + }) + .unwrap() + .responses("gpt-5") +} + +#[tokio::test] +async fn openai_responses_generate_and_stream() { + let ws = || options("openai.web_search", "web_search"); + let model = openai_responses(cassette( + "openai/test_openai_responses_model_web_search_tool_1.json", + )); + generate("openai web_search", &model, ws()).await; + // Events from openai_upstream_e2e_test::responses_web_search_stream (the + // recorded *_stream_1 cassette holds a non-streaming body). + let events = [ + json!({"type":"response.created","response":{"id":"r","created_at":1741630255,"model":"test-model"}}), + json!({"type":"response.output_item.added","output_index":0,"item":{"type":"web_search_call","id":"ws_1","status":"in_progress"}}), + json!({"type":"response.output_item.done","output_index":0,"item":{"type":"web_search_call","id":"ws_1","status":"completed"}}), + json!({"type":"response.completed","response":{"id":"r","usage":{"input_tokens":1,"input_tokens_details":{"cached_tokens":0},"output_tokens":1,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":2}}}), + ]; + let model = openai_responses(sse(&events)); + stream("openai web_search stream", &model, ws()).await; +} + +fn xai(canned: Canned) -> impl LanguageModel { + create_xai(XAIProviderSettings { + api_key: Some("k".into()), + base_url: Some("https://example.test/v1/".into()), + fetch: Some(MockFetch::new(vec![canned]).transport()), + ..Default::default() + }) + .unwrap() + .responses("grok-4-fast-non-reasoning") +} + +#[tokio::test] +async fn xai_generate_and_stream() { + // Body from xai_upstream_e2e_test::tool_results_preserve_actions_and_sources. + let body = json!({ + "id": "resp_123", "object": "response", "status": "completed", + "model": "grok-4-fast-non-reasoning", + "output": [{"type": "web_search_call", "id": "ws_1", "name": "web_search", "call_id": "", + "status": "completed", "action": {"type": "search", "query": "q", "sources": []}}], + "usage": {"input_tokens": 10, "output_tokens": 5}, + }); + let model = xai(Canned::json(&body)); + generate( + "xai web_search", + &model, + options("xai.web_search", "web_search"), + ) + .await; + let item = json!({"type": "file_search_call", "id": "fs_1", "status": "completed", + "queries": ["q"], "results": [{"file_id": "f", "filename": "a.txt", "score": 1.0, "text": "t"}]}); + let events = [ + json!({"type": "response.created", "response": {"id": "resp_1", "object": "response", "model": "grok-4-fast-non-reasoning", "output": []}}), + json!({"type": "response.output_item.added", "output_index": 0, "item": item}), + json!({"type": "response.output_item.done", "output_index": 0, "item": item}), + json!({"type": "response.completed", "response": {"id": "resp_1", "object": "response", "model": "grok-4-fast-non-reasoning", "status": "completed", "output": [item], "usage": {"input_tokens": 1, "output_tokens": 1}}}), + ]; + let model = xai(sse(&events)); + let mut fs = options("xai.file_search", "file_search"); + if let Some(Tool::Provider(tool)) = fs.tools.as_mut().and_then(|t| t.first_mut()) { + tool.args.insert("vectorStoreIds".into(), json!(["vs_1"])); + } + stream("xai file_search stream", &model, fs).await; +} + +fn huggingface(canned: Canned) -> impl LanguageModel { + create_huggingface(HuggingFaceProviderSettings { + api_key: Some("k".to_string().into()), + base_url: Some("https://example.test/v1".into()), + fetch: Some(MockFetch::new(vec![canned]).transport()), + ..Default::default() + }) + .unwrap() + .responses("deepseek-ai/DeepSeek-V3-0324") +} + +#[tokio::test] +async fn huggingface_generate_and_stream() { + // Bodies from huggingface_responses_test (MCP call; streamed call with output). + let body = json!({ + "id": "resp_mcp", "model": "m", "object": "response", "status": "completed", + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + "output": [{"id": "mcp_1", "type": "mcp_call", "server_label": "web_search", + "name": "search", "arguments": "{}", "output": "Found 25"}] + }); + let model = huggingface(Canned::json(&body)); + generate( + "huggingface mcp_call", + &model, + GenerateTextOptions::default(), + ) + .await; + let item = r#"{"id":"fc_1","type":"function_call","call_id":"call_1","name":"calculator","arguments":"{}","output":"8"}"#; + let body = [ + r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"calculator","arguments":""},"sequence_number":1}"#.to_owned(), + format!(r#"{{"type":"response.output_item.done","output_index":0,"item":{item},"sequence_number":2}}"#), + r#"{"type":"response.completed","response":{"id":"r","status":"completed","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}},"sequence_number":3}"#.to_owned(), + ] + .iter() + .map(|e| format!("data: {e}\n\n")) + .collect::(); + let canned = Canned { + status: 200, + headers: vec![("content-type".into(), "text/event-stream".into())], + body: body.into_bytes(), + }; + let model = huggingface(canned); + stream( + "huggingface output stream", + &model, + GenerateTextOptions::default(), + ) + .await; +} diff --git a/bindings/node/src/types/ToolResult.ts b/bindings/node/src/types/ToolResult.ts index 6f9839746..d558ba594 100644 --- a/bindings/node/src/types/ToolResult.ts +++ b/bindings/node/src/types/ToolResult.ts @@ -26,6 +26,12 @@ preliminary?: boolean, * Whether the tool is dynamic (defined at runtime, e.g. MCP tools). */ dynamic?: boolean, +/** + * Whether the tool was executed by the provider (AI SDK + * `generate-text/tool-result.ts:17,32`; #200:2292). Passed through as + * given: absent / false / true. + */ +providerExecuted?: boolean, /** * Additional provider-specific metadata for the tool result. */ diff --git a/docs/amendments/A7-enum-state-modeling.md b/docs/amendments/A7-enum-state-modeling.md new file mode 100644 index 000000000..793bd3bf4 --- /dev/null +++ b/docs/amendments/A7-enum-state-modeling.md @@ -0,0 +1,93 @@ +# A7:内容/消息类型的状态建模统一为 enum(D2 扩展,Trey 2026-10-11 已决) + +> 基线:fork `exp/restart@8455db9a`(下文 file:line 都在这个提交上核过);`aimux-provider` 只在 `origin/exp/u1.6-aimux-provider-v4@b837aac3` 上(PR 分支,还没合并)。#200 = `docs/aisdk-architecture-alignment.md`(8455db9a 上同文件)。V4 = `@ai-sdk/provider@4.0.21 src/language-model/v4/`(只用来核对)。参考:rig@9e7cb60 `crates/rig-core/src/completion/message.rs:167`(UserContent)、`:187`(AssistantContent)、`message/native.rs:232`(Opaque);async-openai@c2deb88 `types/responses/response.rs:260`(Item)、`:344/:347`(McpApprovalRequest/Response)。 + +## 1. 决定 +- 互斥的状态用 Rust enum 表达,不再用多个 `Option` 组合。只有彼此独立的数据才用 `Option`。 +- **公共 JSON 不变**(RFC-0042:80、:97;#200:2298): + - 不新增 tag 字段,不用 `#[serde(untagged)]`,不用 `flatten`。 + - 已经有 `type` 判别字段的类型,就复用 `type`。 + - 没有 `type` 的(ToolCall/ToolResult),按 #200:2298 用 SDK 已有的 `dynamic` 字段判别。 +- 统一的实现方式:"wire 镜像 struct + `#[serde(try_from = "XWire", into = "XWire")]`",ts-rs 用 `#[ts(as = "XWire")]`。 + - XWire 就是现在的 struct,原样搬过去、改成私有。所以 serde 输出和 `bindings/node/src/types/*.ts` 都保持不变,手写绑定(5 个 Types 文件、MultimodalTypes、wrapper.py)也不用碰。 + - 改动只在 Rust 公共 API 上:字段访问换成 match 或访问器。 +- **absent 和 `false` 必须分开**:例如 #200:2570、:2597 的示例里显式写了 `"providerExecuted": false`。要做到逐字节往返,enum 里必须记住"字段原来是缺省还是显式 false"(见风险 R1、D25)。 + +## 2. 清单(8455db9a) +| 类型 | file:line | 现在用来编码状态的 Option/bool | 提议的 enum | wire 判别字段 | serde 能否逐字节复现 | +|---|---|---|---|---|---| +| `ToolCall` | aimux-core/src/tool.rs:143 | `dynamic`:158、`invalid`:167、`error`:171(`provider_executed`:154 是独立数据) | `enum ToolCall{Static(StaticToolCall),Dynamic(DynamicToolCall)}`;Dynamic 里放 `validity: Valid \| Invalid{error}` | `dynamic`(不是 `type`) | 能。走 try_from 加 Wire。⚠R1:absent 和 `dynamic:false`、`invalid:false` 要分开;⚠ 现在可以出现 `invalid:true` 但没有 `dynamic:true`,而 #200:2290 规定 Static 不能带 invalid,所以这种输入要么归一成 Dynamic,要么报错(D26) | +| `ToolResult` | tool.rs:179 | `dynamic`:196;`is_error`:188、`preliminary`:192 | `Static/Dynamic`。is_error/preliminary 留作独立字段,因为 V4 tool-result.ts:28、:39 就是独立可选字段 | `dynamic` | 能,⚠R1 | +| `RawToolCall` | tool.rs:124 | `dynamic`:133(`provider_executed`:130 是独立数据) | **不做 enum**:#200:2724 定为"与 V4ToolCall 合并或用 newtype";input 改成 `RawArguments(String)`(RFC-0042:80) | — | 能(newtype 用 `serde(transparent)`) | +| `ContentPart` | aimux-core/src/content.rs:20 | 已经是 `type` enum。`ToolCall.provider_executed`:161 和 `ToolApprovalRequest.is_automatic`:56 都是独立数据 | 不变;只把内联的 ToolCall 字段抽成复用的 struct | `type`(kebab) | 能。⚠ `File/FileBase64/FileUrl/FileReference` 是 aimux 自己的 tag,保持不动 | +| `LanguageModelMessage` / `UserPart` / `AssistantPart` / `ToolPart` | language_model_message.rs:33、:233、:242、:256 | 已经是 enum(`role`、`type`) | 不变 | `role`、`type` | 能 | +| `ToolCallPart` | language_model_message.rs:102 | `provider_executed`:108,独立数据 | 不变 | — | 能 | +| `ToolApprovalResponsePart` | language_model_message.rs:218 | `approved: bool`:220 | **保留 bool**:V4 prompt.ts:260 起 `approved` 是数据,`reason` 不论批准与否都可以带 | — | 能 | +| `ToolResultOutput`/`ToolResultContent` | language_model_message.rs:135、:181 | 已经是 `type` enum | 不变 | `type` | 能 | +| `MessageContent` / `ModelPrompt` | aimux-core/src/message.rs:28、:82 | **已有 `#[serde(untagged)]`** | 不在本 amendment 范围内,不改(要改就得改 JSON)。按审查标准(3)标为"无法满足、原样保留" | 无 | 不适用(标红) | +| `GenerateContent` | aimux-core/src/result.rs:24 | 已经是 `type` enum | 不变;`ToolCall(C)`、`ToolResult(ToolResult)` 跟着 U2/U3 变 | `type` | 能 | +| `RawToolApprovalRequest` | result.rs:72 | 无 | 不变 | — | 能 | +| `ToolApprovalRequestOutput` | result.rs:84 | `is_automatic`:92,独立数据 | 不变;`tool_call` 跟着 U2 变 | — | 能 | +| `StreamPart` | aimux-core/src/stream_part.rs:29 | `ToolInputStart.provider_executed`:80、`dynamic`:84 | 只把 `dynamic` 换成复用 U2 的 `ToolKind{Static{explicit_false},Dynamic}`,做法和 Wire 一样 | `type` | 能,⚠R1。enum 变体里的字段要靠自定义 `with` 模块才能保持 absent/false | +| `TextStreamPart` | stream_part.rs:176 | 同上::236、:240 | 同上 | `type` | 同上 | +| `GenerateTextResult` | aimux-core/src/generate.rs:216 | 无状态型 Option | 不变(`tool_calls` 跟着 U2 变) | — | 能 | +| `parsed_tool_call_arguments` | aimux-core/src/openai_output.rs:1031-1033 | 参数 `invalid: Option` | 改成接收 `&ToolCall` 后 match | 内部函数 | 不涉及 wire | +| `response_tool_call_input` | aimux-core/src/response_messages.rs:25 | 参数 `invalid: Option` | 同上 | 内部函数 | 不涉及 wire | +| `LanguageModelV4ToolCall` / `ToolResult`(**PR 分支** u1.6@b837aac3) | aimux-provider/src/content.rs:84(:89、:91)、:99(:105、:107、:109) | provider_executed、dynamic、is_error、preliminary | **不做 enum**:V4 DTO 按 #200:2151-2180、V4 tool-call.ts:29、:35、tool-result.ts:28-45 原样逐字段对应,全部是 `Option` | `type`(content enum :117) | 能,不改 | + +## 3. provider 执行(服务端)工具在 1.10 里怎么建模:透传,不按工具种类分变体 +- #200:2151 写明"provider 输出采用完整 V4 content 与 stream part"。:2157-2177 `V4ToolCallPayload`、`V4ToolResultPayload` 用的是 `provider_executed: Optional`;:2201-2211 `LanguageModelV4Content` 只有一个 `ToolCall`、一个 `ToolResult` 变体;:2233 tool-input-start 也带 `providerExecuted?`;:2522 只按 providerExecuted 决定回放到哪条消息。 +- V4:`language-model-v4-tool-call.ts:7`(`type:'tool-call'`)、:29(`providerExecuted?`);`tool-result.ts:8`;`stream-part.ts:57`;prompt `language-model-v4-prompt.ts:195`、:216。上游没有任何按服务端工具种类划分的 part。 +- #200:68 规定审批只透传 provider 执行的工具;:2009 规定 tool-approval-response 只为 provider 执行工具保留。 +- 结论:1.10 里**服务端工具是透传**,复用同一个 ToolCall/ToolResult 加上 `providerExecuted` 字段;工具本身的载荷留在 `input`、`result` 这两个 JSON 值里。这和 rig 的 `Opaque`(native.rs:232)思路一致。async-openai 那种"每种 item 一个变体"(response.rs:260)是 OpenAI Responses 原生 wire 的做法,属于 L0 native(PHASES 阶段 5),不放进 V4/core。 +- `providerExecuted` 和 `dynamic` 互相独立(一次调用可以既是 provider 执行又是 dynamic),所以它**不进**Static/Dynamic enum,保留 `Option`。即使以后要建模成 `Executor{Unspecified,Client,Provider}`,也必须保住三态,等价于 Option。#200 对此没有反对意见,这不算新决定。 + +## 4. 风险 +- R1 absent 和 false:`Option` 是三态,enum 如果只有两态,`"dynamic":false` 往返时会被吞掉,JSON 就变了。做法是 Static 里记一个 `dynamic_explicit_false: bool`(属于 wire 保真用的私有信息),或者确认 fixture 里从来没有 false。仓库 JSON 里,这五个字段显式写成 false 的只有一处:bedrock cassette 里的 `"isError": false`(`aimux-providers/tests/cassettes/bedrock/test_bedrock_model_with_code_execution_tool_1.json`)。那是 provider 的 HTTP 原文,而且 isError 本来就不进 enum。不过 provider 转换代码会构造 `Some(false)`(例如 `aimux-providers/src/xai/responses/convert.rs`,这几处是否都和 dynamic/invalid 有关,还要逐一核对),所以不能假定运行时不会出现 false。默认:保真(D25)。 +- R2 `invalid:true` 但没有 `dynamic:true`:现在的 JSON 能表示这种状态,enum 不能。见 D26。 +- R3 ts-rs:要是直接在 enum 上 derive TS,会生成 TS 联合,绑定就变了。必须用 `#[ts(as="XWire")]`,并且 `gen_ts_types.py --check` 保持零差异。 +- R4 `ToolCall` 没有 `PartialEq`(tool.rs:140),因为 error 是 AiMuxError。往返测试比 JSON(`serde_json::Value` 加上原文字节),不比 Rust 值。 +- R5 泛型 `StreamPart`/`GenerateContent` 的 ts-rs `concrete(...)`(stream_part.rs:28)不能动。 +- R6 `MessageContent`/`ModelPrompt` 原本就是 untagged,不满足审查标准(3),只记录,不在本 amendment 里改。 +- R7 绑定基线:Python、Flutter、4 个 Node job 在 8455db9a 上原本就红,只要求不新增失败。 + +## 5. 待决问题 +- ~~D25~~ 已决:按 AI SDK 规范化,见下。 +- ~~D26~~ 已决:报错,见下。 + +## D25 / D26 已决(Trey 2026-10-11):规范形式与验收 +依据见 DECISIONS.md D25/D26 已决条(行号都在 ai@7.0.127 / @ai-sdk/provider@4.0.21 里核过,并用 node 实跑过)。R1 原来的"Static 里加 explicit_false 标记"作废。 + +| 面 | 字段 | 输入 | 规范输出 | AI SDK 依据 | +|---|---|---|---|---| +| core ToolCall(step content / stream tool-call) | dynamic | absent、false → Static;true → Dynamic | Static 不写;Dynamic 写 `true` | parse-tool-call.ts:276、:280-287 | +| 同上 | invalid / error | true(必须 dynamic:true 且带 error)→ Invalid;absent、false → Valid | Invalid 写 `invalid:true`+`error`;Valid 不写 | parse-tool-call.ts:126-128 | +| core ToolResult / ToolError | dynamic | absent、false → Static;true → Dynamic | **总是写**布尔 `false`/`true` | execute-tool-call.ts:169、:199 | +| StreamPart/TextStreamPart ToolInputStart | dynamic | absent、false → Static;true → Dynamic | **总是写**布尔 | stream-language-model-call.ts:809 | +| 以上所有面 + V4 tool-call/tool-result/stream-part + prompt / response message 的 tool-call | providerExecuted | 三态 | **原样透传**,不归一(AI SDK 本身就是透传) | parse-tool-call.ts:130,273,284;to-response-messages.ts:95;convert-to-language-model-prompt.ts:342;stream-language-model-call.ts:807-808 | +| V4 DTO(provider 层)dynamic | — | 三态 | 透传,不改(V4 不进 enum) | provider tool-call.ts:35、stream-part.ts:58 | + +注意:provider 执行的 tool-result 在 AI SDK 里 `dynamic` 是透传的(stream-language-model-call.ts:774、:789),可能缺省。aimux 统一写布尔,这会和 AI SDK 在"provider 没给 dynamic"时的输出不同(AI SDK 不写,aimux 写 false)。可以接受,因为 TS 类型 `dynamic?: false` 两种都允许;如果要完全对齐,就得在 provider 执行的结果上保留三态。E.3 先按"总是写"做,差异记进样本。 + +D26 处理:`try_from(ToolCallWire)` 遇到 `invalid==Some(true) && dynamic!=Some(true)` 返回 Err(AI SDK 类型和生成端都不会产生这种组合,aimux 的 parse_tool_call.rs:307-309 也不会)。`invalid:true` 但缺 error 也返回 Err。 + +验收(替换 E.2–E.4 原来的"逐字节"要求): +1. 规范化往返逐字节一致:样本先过一遍规范化(按上表),`serialize(deserialize(x)) == normalize(x)`,而且 `serialize(deserialize(normalize(x))) == normalize(x)`。 +2. 等价样例:`{"dynamic":false}` 和缺省反序列化成同一个变体(`assert_eq!`),序列化结果也相同;ToolCall 里 `invalid:false` 和缺省同理。 +3. providerExecuted:true/false/absent 三条样本往返逐字节不变。 +4. D26:`{"invalid":true,"error":…}` 不带 dynamic、`{"dynamic":false,"invalid":true}` 都 try_from 返回 Err。 + +### 与 #200 的冲突(修订说明) +- #200:2582 的 core ToolCall 示例写了 `"dynamic": false`。AI SDK 静态调用不写 dynamic(parse-tool-call.ts:280-287,实测也是这样)。改为不写这个字段。 +- #200:2575-2583 的 core 示例把 V4 里的 `"providerExecuted": false`(#200:2570)丢掉了,但 AI SDK 会原样保留(parse-tool-call.ts:284,实测 step content 输出 `"providerExecuted":false`)。core 示例应该保留 `"providerExecuted": false`。#200:2597 回填消息保留 false,这一点和 AI SDK 一致(to-response-messages.ts:95)。 +- #200:2290 `dynamic?:false, invalid?:false` 和类型一致(tool-call.ts:24-25),但只说明"允许写 false";规范输出是不写。#200:2292 TypedToolResult `dynamic` 必填,和 execute-tool-call.ts:169/199 一致,但 provider 执行的路径是透传(见上面的注意)。 +- #200:2291 `invalid?:bool` 只出现在 Dynamic 里,和 D26 一致,不冲突。 + +## 更正 2026-10-11:服务商执行的 tool-result/tool-error 的 dynamic +上面那一节说"总是写",作废。Trey 定了以 AI SDK 为准,AI SDK 在这条路径上会把 dynamic 原样传过去(`stream-language-model-call.ts:774,789`,`convert-language-model-content.ts:114,130,150,166`)。所以服务商执行(providerExecuted 为 true)的结果里,dynamic 和 providerExecuted 一样原样保留三态;只有客户端执行的结果才规范化成总是写布尔(`execute-tool-call.ts:169,199`)。E.3 的验收要加一条样例:服务商执行、并且不写 dynamic 的结果,来回转换后逐字节不变。 + +## E.3 建模定稿(2026-10-11,dr eggbot 确认,属于落实"以 AI SDK 为准") +ToolResult 按来源分 enum: +- 客户端执行:分 Static 和 Dynamic 两个变体,`dynamic` 规范化输出为布尔(`execute-tool-call.ts:169,199`)。 +- 服务商执行:单独一个变体,`dynamic` 在里面作为透传数据 `Option` 保存,原样往返(`stream-language-model-call.ts:774,789`)。 +E.3 验收追加一条:服务商执行且不写 `dynamic` 的样例,往返逐字节不变。 diff --git a/fixtures/v4/enum-state/baseline/raw_tool_call.jsonl b/fixtures/v4/enum-state/baseline/raw_tool_call.jsonl new file mode 100644 index 000000000..321467a81 --- /dev/null +++ b/fixtures/v4/enum-state/baseline/raw_tool_call.jsonl @@ -0,0 +1,18 @@ +{"toolCallId":"call_1","toolName":"weather","input":"\"Tokyo\""} +{"toolCallId":"call_1","toolName":"weather","input":"\"{\\\"x\\\":1}\""} +{"toolCallId":"call_1","toolName":"weather","input":"123"} +{"toolCallId":"call_1","toolName":"weather","input":"true"} +{"toolCallId":"call_1","toolName":"weather","input":"null"} +{"toolCallId":"call_1","toolName":"weather","input":"{\"a\":1,\"a\":2}"} +{"toolCallId":"call_1","toolName":"weather","input":" 1e2 "} +{"toolCallId":"call_1","toolName":"weather","input":"{\"a\":"} +{"toolCallId":"call_1","toolName":"weather","input":""} +{"toolCallId":"call_1","toolName":"weather","input":"{}"} +{"toolCallId":"call_1","toolName":"weather","input":"{}","dynamic":false} +{"toolCallId":"call_1","toolName":"weather","input":"{}","dynamic":true} +{"toolCallId":"call_1","toolName":"weather","input":"{}","providerExecuted":true} +{"toolCallId":"call_1","toolName":"weather","input":"{}","providerExecuted":true,"dynamic":false} +{"toolCallId":"call_1","toolName":"weather","input":"{}","providerExecuted":true,"dynamic":true} +{"toolCallId":"call_1","toolName":"weather","input":"{}","providerExecuted":false} +{"toolCallId":"call_1","toolName":"weather","input":"{}","providerExecuted":false,"dynamic":false} +{"toolCallId":"call_1","toolName":"weather","input":"{}","providerExecuted":false,"dynamic":true} diff --git a/fixtures/v4/enum-state/baseline/stream_tool_input_start.jsonl b/fixtures/v4/enum-state/baseline/stream_tool_input_start.jsonl new file mode 100644 index 000000000..35ec87d1c --- /dev/null +++ b/fixtures/v4/enum-state/baseline/stream_tool_input_start.jsonl @@ -0,0 +1,10 @@ +{"type":"tool-input-start","id":"call_1","toolName":"weather"} +{"type":"tool-input-start","id":"call_1","toolName":"weather","dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","dynamic":true} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":true} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":true,"dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":true,"dynamic":true} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":false,"dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":false,"dynamic":true} +{"type":"tool-input-start","id":"call_1","toolName":"weather","title":"Weather"} diff --git a/fixtures/v4/enum-state/baseline/text_stream_tool_input_start.jsonl b/fixtures/v4/enum-state/baseline/text_stream_tool_input_start.jsonl new file mode 100644 index 000000000..35ec87d1c --- /dev/null +++ b/fixtures/v4/enum-state/baseline/text_stream_tool_input_start.jsonl @@ -0,0 +1,10 @@ +{"type":"tool-input-start","id":"call_1","toolName":"weather"} +{"type":"tool-input-start","id":"call_1","toolName":"weather","dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","dynamic":true} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":true} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":true,"dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":true,"dynamic":true} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":false,"dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":false,"dynamic":true} +{"type":"tool-input-start","id":"call_1","toolName":"weather","title":"Weather"} diff --git a/fixtures/v4/enum-state/baseline/tool_call.jsonl b/fixtures/v4/enum-state/baseline/tool_call.jsonl new file mode 100644 index 000000000..0fbbde9ab --- /dev/null +++ b/fixtures/v4/enum-state/baseline/tool_call.jsonl @@ -0,0 +1,24 @@ +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"}} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"dynamic":false} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"dynamic":true} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"providerExecuted":true} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"providerExecuted":true,"dynamic":false} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"providerExecuted":true,"dynamic":true} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"providerExecuted":false} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"providerExecuted":false,"dynamic":false} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"providerExecuted":false,"dynamic":true} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"dynamic":false,"invalid":false} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"invalid":false} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"dynamic":true,"invalid":false} +{"toolCallId":"call_1","toolName":"weather","input":"{\"city\":","dynamic":true,"invalid":true,"error":{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"{\"city\":","cause":"EOF while parsing"}} +{"toolCallId":"call_1","toolName":"weather","input":"{\"city\":","providerExecuted":true,"dynamic":true,"providerMetadata":{"openai":{"itemId":"fc_1"}},"invalid":true,"error":{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"{\"city\":","cause":"EOF while parsing"}} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"providerMetadata":{"openai":{"itemId":"fc_1"}}} +{"toolCallId":"call_1","toolName":"weather","input":"Tokyo"} +{"toolCallId":"call_1","toolName":"weather","input":"{\"x\":1}"} +{"toolCallId":"call_1","toolName":"weather","input":123} +{"toolCallId":"call_1","toolName":"weather","input":true} +{"toolCallId":"call_1","toolName":"weather","input":null} +{"toolCallId":"call_1","toolName":"weather","input":{"a":2}} +{"toolCallId":"call_1","toolName":"weather","input":100.0} +{"toolCallId":"call_1","toolName":"weather","input":"","dynamic":true,"invalid":true,"error":{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"","cause":"JSON parsing failed"}} +{"toolCallId":"call_1","toolName":"weather","input":"{\"a\":","dynamic":true,"invalid":true,"error":{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"{\"a\":","cause":"JSON parsing failed"}} diff --git a/fixtures/v4/enum-state/baseline/tool_call_rejected.jsonl b/fixtures/v4/enum-state/baseline/tool_call_rejected.jsonl new file mode 100644 index 000000000..0f41fdd28 --- /dev/null +++ b/fixtures/v4/enum-state/baseline/tool_call_rejected.jsonl @@ -0,0 +1,3 @@ +{"toolCallId":"call_1","toolName":"weather","input":"{\"city\":","invalid":true,"error":{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"{\"city\":","cause":"EOF while parsing"}} +{"toolCallId":"call_1","toolName":"weather","input":"{\"city\":","dynamic":false,"invalid":true,"error":{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"{\"city\":","cause":"EOF while parsing"}} +{"toolCallId":"call_1","toolName":"weather","input":"{\"city\":","dynamic":true,"invalid":true} diff --git a/fixtures/v4/enum-state/baseline/tool_result.jsonl b/fixtures/v4/enum-state/baseline/tool_result.jsonl new file mode 100644 index 000000000..7ee4d60fd --- /dev/null +++ b/fixtures/v4/enum-state/baseline/tool_result.jsonl @@ -0,0 +1,8 @@ +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3}} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"dynamic":false} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"dynamic":true} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"isError":true} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"isError":false} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"preliminary":true,"dynamic":false} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"preliminary":false} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"providerMetadata":{"xai":{"id":"fs_1"}}} diff --git a/fixtures/v4/enum-state/gen_samples.py b/fixtures/v4/enum-state/gen_samples.py new file mode 100644 index 000000000..c78f1f511 --- /dev/null +++ b/fixtures/v4/enum-state/gen_samples.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Generate the enum-state shared samples and their D25/D26 normalized forms. + +Samples are JSONL: one compact JSON value per line, keys in the Rust struct's +serialization order, so `serialize(deserialize(line))` can be compared byte for +byte. Run from this directory; the output is committed, this script only +documents how it was produced (A7 §D25/D26). +""" +import json + +def dump(v): + return json.dumps(v, separators=(",", ":"), ensure_ascii=False) + +ERR = {"name": "AI_InvalidToolInputError", "toolName": "weather", + "toolInput": "{\"city\":", "cause": "EOF while parsing"} + +_DEFAULT = object() + +def tool_call(dynamic=None, invalid=None, error=None, pe=None, inp=_DEFAULT, meta=False): + v = {"toolCallId": "call_1", "toolName": "weather", + "input": {"city": "Tokyo"} if inp is _DEFAULT else inp} + if pe is not None: v["providerExecuted"] = pe + if dynamic is not None: v["dynamic"] = dynamic + if meta: v["providerMetadata"] = {"openai": {"itemId": "fc_1"}} + if invalid is not None: v["invalid"] = invalid + if error is not None: v["error"] = error + return v + +def norm_tool_call(v): + # D25: dynamic absent/false -> Static (not written); true written. + # invalid absent/false -> Valid (not written); providerExecuted passthrough. + v = dict(v) + if v.get("dynamic") is False: del v["dynamic"] + if v.get("invalid") is False: del v["invalid"] + return v + +tool_calls = [] +for pe in (None, True, False): + for dyn in (None, False, True): + tool_calls.append(tool_call(dynamic=dyn, pe=pe)) +tool_calls += [ + tool_call(dynamic=False, invalid=False), + tool_call(invalid=False), + tool_call(dynamic=True, invalid=False), + tool_call(dynamic=True, invalid=True, error=ERR, inp="{\"city\":"), + tool_call(dynamic=True, invalid=True, error=ERR, inp="{\"city\":", pe=True, meta=True), + tool_call(meta=True), +] +# RFC-0042:84-93 core side: the parsed value of each provider text vector +# (raw text itself is in raw_tool_call.jsonl, compared byte for byte). A +# string stays a string (`"{\"x\":1}"` is not an object); empty text and +# `{"a":` are invalid dynamic calls with an error, never `{}`. +def vector_error(text): + return {"name": "AI_InvalidToolInputError", "toolName": "weather", + "toolInput": text, "cause": "JSON parsing failed"} +tool_calls += [ + tool_call(inp="Tokyo"), + tool_call(inp="{\"x\":1}"), + tool_call(inp=123), + tool_call(inp=True), + tool_call(inp=None), + tool_call(inp={"a": 2}), + tool_call(inp=100.0), + tool_call(dynamic=True, invalid=True, error=vector_error(""), inp=""), + tool_call(dynamic=True, invalid=True, error=vector_error("{\"a\":"), inp="{\"a\":"), +] +# Non-canonical spellings of rows 4 and 5 (duplicate key, `1e2`): compared by +# JSON semantics with normalized/tool_call_semantic.jsonl, not byte for byte. +tool_call_semantic_text = [ + '{"toolCallId":"call_1","toolName":"weather","input":{"a":1,"a":2}}', + '{"toolCallId":"call_1","toolName":"weather","input":1e2}', +] +tool_call_semantic_norm = [ + tool_call(inp={"a": 2}), + tool_call(inp=100.0), +] + +# D26: rejected by try_from from E.2 on (invalid without dynamic:true, or without error). +tool_calls_rejected = [ + tool_call(invalid=True, error=ERR, inp="{\"city\":"), + tool_call(dynamic=False, invalid=True, error=ERR, inp="{\"city\":"), + tool_call(dynamic=True, invalid=True, inp="{\"city\":"), +] + +def tool_result(dynamic=None, is_error=None, preliminary=None, meta=False, pe=None): + v = {"toolCallId": "call_1", "toolName": "web_search", "result": {"hits": 3}} + if is_error is not None: v["isError"] = is_error + if preliminary is not None: v["preliminary"] = preliminary + if dynamic is not None: v["dynamic"] = dynamic + if pe is not None: v["providerExecuted"] = pe + if meta: v["providerMetadata"] = {"xai": {"id": "fs_1"}} + return v + +tool_results = [tool_result(dynamic=d) for d in (None, False, True)] + [ + tool_result(is_error=True), tool_result(is_error=False), + tool_result(preliminary=True, dynamic=False), tool_result(preliminary=False), + tool_result(meta=True), +] +# E.3a: providerExecuted three states (absent rows above), crossed with +# dynamic; includes provider-executed without dynamic (A7 correction). +tool_results += [tool_result(dynamic=d, pe=p) for p in (False, True) for d in (None, False, True)] + +def norm_tool_result(v): + # A7 D25 + correction: client-executed (providerExecuted not true) always + # writes the dynamic boolean (execute-tool-call.ts:169,199); provider- + # executed passes dynamic through (stream-language-model-call.ts:774,789). + # providerExecuted itself is always passed through. Enforced from E.3. + out = dict(v) + if v.get("providerExecuted") is not True: + out["dynamic"] = bool(v.get("dynamic", False)) + order = ["toolCallId", "toolName", "result", "isError", "preliminary", + "dynamic", "providerExecuted", "providerMetadata"] + return {k: out[k] for k in order if k in out} + +def raw_tool_call(inp, dynamic=None, pe=None): + v = {"toolCallId": "call_1", "toolName": "weather", "input": inp} + if pe is not None: v["providerExecuted"] = pe + if dynamic is not None: v["dynamic"] = dynamic + return v + +# RFC-0042:84-91 rows 1-6, reused from 1.3 fixtures/v4/tool-input-vectors.json +# (branch exp/u1.3-tool-input-vectors; not on 8455db9a), as provider input text. +vectors = ["\"Tokyo\"", "\"{\\\"x\\\":1}\"", "123", "true", "null", + "{\"a\":1,\"a\":2}", " 1e2 ", "{\"a\":", ""] +raw_tool_calls = [raw_tool_call(s) for s in vectors] + [ + raw_tool_call("{}", dynamic=d, pe=p) for p in (None, True, False) for d in (None, False, True) +] + +def input_start(dynamic=None, pe=None, title=None): + v = {"type": "tool-input-start", "id": "call_1", "toolName": "weather"} + if pe is not None: v["providerExecuted"] = pe + if dynamic is not None: v["dynamic"] = dynamic + if title is not None: v["title"] = title + return v + +def norm_input_start(v): + # D25: ToolInputStart always writes the dynamic boolean + # (stream-language-model-call.ts:809); providerExecuted passthrough. + out = {} + for k in ("type", "id", "toolName", "providerExecuted"): + if k in v: out[k] = v[k] + out["dynamic"] = bool(v.get("dynamic", False)) + for k in ("title", "providerMetadata"): + if k in v: out[k] = v[k] + return out + +input_starts = [input_start(dynamic=d, pe=p) for p in (None, True, False) for d in (None, False, True)] +input_starts.append(input_start(title="Weather")) + +files = { + "tool_call": (tool_calls, norm_tool_call), + "tool_call_rejected": (tool_calls_rejected, None), + # E.3a adds the providerExecuted field (passthrough); the D25 normalized + # form below is the E.3 target (see enum_state_wire_test.rs). + "tool_result": (tool_results, norm_tool_result), + "raw_tool_call": (raw_tool_calls, lambda v: v), + "stream_tool_input_start": (input_starts, norm_input_start), + "text_stream_tool_input_start": (input_starts, norm_input_start), +} +import os +os.makedirs("normalized", exist_ok=True) +with open("tool_call_semantic.jsonl", "w") as f: + f.write("".join(t + "\n" for t in tool_call_semantic_text)) +with open("normalized/tool_call_semantic.jsonl", "w") as f: + f.write("".join(dump(r) + "\n" for r in tool_call_semantic_norm)) +for name, (rows, norm) in files.items(): + with open(f"{name}.jsonl", "w") as f: + f.write("".join(dump(r) + "\n" for r in rows)) + if norm: + with open(f"normalized/{name}.jsonl", "w") as f: + f.write("".join(dump(norm(r)) + "\n" for r in rows)) diff --git a/fixtures/v4/enum-state/normalized/raw_tool_call.jsonl b/fixtures/v4/enum-state/normalized/raw_tool_call.jsonl new file mode 100644 index 000000000..321467a81 --- /dev/null +++ b/fixtures/v4/enum-state/normalized/raw_tool_call.jsonl @@ -0,0 +1,18 @@ +{"toolCallId":"call_1","toolName":"weather","input":"\"Tokyo\""} +{"toolCallId":"call_1","toolName":"weather","input":"\"{\\\"x\\\":1}\""} +{"toolCallId":"call_1","toolName":"weather","input":"123"} +{"toolCallId":"call_1","toolName":"weather","input":"true"} +{"toolCallId":"call_1","toolName":"weather","input":"null"} +{"toolCallId":"call_1","toolName":"weather","input":"{\"a\":1,\"a\":2}"} +{"toolCallId":"call_1","toolName":"weather","input":" 1e2 "} +{"toolCallId":"call_1","toolName":"weather","input":"{\"a\":"} +{"toolCallId":"call_1","toolName":"weather","input":""} +{"toolCallId":"call_1","toolName":"weather","input":"{}"} +{"toolCallId":"call_1","toolName":"weather","input":"{}","dynamic":false} +{"toolCallId":"call_1","toolName":"weather","input":"{}","dynamic":true} +{"toolCallId":"call_1","toolName":"weather","input":"{}","providerExecuted":true} +{"toolCallId":"call_1","toolName":"weather","input":"{}","providerExecuted":true,"dynamic":false} +{"toolCallId":"call_1","toolName":"weather","input":"{}","providerExecuted":true,"dynamic":true} +{"toolCallId":"call_1","toolName":"weather","input":"{}","providerExecuted":false} +{"toolCallId":"call_1","toolName":"weather","input":"{}","providerExecuted":false,"dynamic":false} +{"toolCallId":"call_1","toolName":"weather","input":"{}","providerExecuted":false,"dynamic":true} diff --git a/fixtures/v4/enum-state/normalized/stream_tool_input_start.jsonl b/fixtures/v4/enum-state/normalized/stream_tool_input_start.jsonl new file mode 100644 index 000000000..14cd1a38d --- /dev/null +++ b/fixtures/v4/enum-state/normalized/stream_tool_input_start.jsonl @@ -0,0 +1,10 @@ +{"type":"tool-input-start","id":"call_1","toolName":"weather","dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","dynamic":true} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":true,"dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":true,"dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":true,"dynamic":true} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":false,"dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":false,"dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":false,"dynamic":true} +{"type":"tool-input-start","id":"call_1","toolName":"weather","dynamic":false,"title":"Weather"} diff --git a/fixtures/v4/enum-state/normalized/text_stream_tool_input_start.jsonl b/fixtures/v4/enum-state/normalized/text_stream_tool_input_start.jsonl new file mode 100644 index 000000000..14cd1a38d --- /dev/null +++ b/fixtures/v4/enum-state/normalized/text_stream_tool_input_start.jsonl @@ -0,0 +1,10 @@ +{"type":"tool-input-start","id":"call_1","toolName":"weather","dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","dynamic":true} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":true,"dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":true,"dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":true,"dynamic":true} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":false,"dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":false,"dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":false,"dynamic":true} +{"type":"tool-input-start","id":"call_1","toolName":"weather","dynamic":false,"title":"Weather"} diff --git a/fixtures/v4/enum-state/normalized/tool_call.jsonl b/fixtures/v4/enum-state/normalized/tool_call.jsonl new file mode 100644 index 000000000..873e7b872 --- /dev/null +++ b/fixtures/v4/enum-state/normalized/tool_call.jsonl @@ -0,0 +1,24 @@ +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"}} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"}} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"dynamic":true} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"providerExecuted":true} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"providerExecuted":true} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"providerExecuted":true,"dynamic":true} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"providerExecuted":false} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"providerExecuted":false} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"providerExecuted":false,"dynamic":true} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"}} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"}} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"dynamic":true} +{"toolCallId":"call_1","toolName":"weather","input":"{\"city\":","dynamic":true,"invalid":true,"error":{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"{\"city\":","cause":"EOF while parsing"}} +{"toolCallId":"call_1","toolName":"weather","input":"{\"city\":","providerExecuted":true,"dynamic":true,"providerMetadata":{"openai":{"itemId":"fc_1"}},"invalid":true,"error":{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"{\"city\":","cause":"EOF while parsing"}} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"providerMetadata":{"openai":{"itemId":"fc_1"}}} +{"toolCallId":"call_1","toolName":"weather","input":"Tokyo"} +{"toolCallId":"call_1","toolName":"weather","input":"{\"x\":1}"} +{"toolCallId":"call_1","toolName":"weather","input":123} +{"toolCallId":"call_1","toolName":"weather","input":true} +{"toolCallId":"call_1","toolName":"weather","input":null} +{"toolCallId":"call_1","toolName":"weather","input":{"a":2}} +{"toolCallId":"call_1","toolName":"weather","input":100.0} +{"toolCallId":"call_1","toolName":"weather","input":"","dynamic":true,"invalid":true,"error":{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"","cause":"JSON parsing failed"}} +{"toolCallId":"call_1","toolName":"weather","input":"{\"a\":","dynamic":true,"invalid":true,"error":{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"{\"a\":","cause":"JSON parsing failed"}} diff --git a/fixtures/v4/enum-state/normalized/tool_call_semantic.jsonl b/fixtures/v4/enum-state/normalized/tool_call_semantic.jsonl new file mode 100644 index 000000000..2891fdb5d --- /dev/null +++ b/fixtures/v4/enum-state/normalized/tool_call_semantic.jsonl @@ -0,0 +1,2 @@ +{"toolCallId":"call_1","toolName":"weather","input":{"a":2}} +{"toolCallId":"call_1","toolName":"weather","input":100.0} diff --git a/fixtures/v4/enum-state/normalized/tool_result.jsonl b/fixtures/v4/enum-state/normalized/tool_result.jsonl new file mode 100644 index 000000000..76f5f32cd --- /dev/null +++ b/fixtures/v4/enum-state/normalized/tool_result.jsonl @@ -0,0 +1,14 @@ +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"dynamic":false} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"dynamic":false} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"dynamic":true} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"isError":true,"dynamic":false} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"isError":false,"dynamic":false} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"preliminary":true,"dynamic":false} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"preliminary":false,"dynamic":false} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"dynamic":false,"providerMetadata":{"xai":{"id":"fs_1"}}} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"dynamic":false,"providerExecuted":false} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"dynamic":false,"providerExecuted":false} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"dynamic":true,"providerExecuted":false} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"providerExecuted":true} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"dynamic":false,"providerExecuted":true} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"dynamic":true,"providerExecuted":true} diff --git a/fixtures/v4/enum-state/raw_tool_call.jsonl b/fixtures/v4/enum-state/raw_tool_call.jsonl new file mode 100644 index 000000000..321467a81 --- /dev/null +++ b/fixtures/v4/enum-state/raw_tool_call.jsonl @@ -0,0 +1,18 @@ +{"toolCallId":"call_1","toolName":"weather","input":"\"Tokyo\""} +{"toolCallId":"call_1","toolName":"weather","input":"\"{\\\"x\\\":1}\""} +{"toolCallId":"call_1","toolName":"weather","input":"123"} +{"toolCallId":"call_1","toolName":"weather","input":"true"} +{"toolCallId":"call_1","toolName":"weather","input":"null"} +{"toolCallId":"call_1","toolName":"weather","input":"{\"a\":1,\"a\":2}"} +{"toolCallId":"call_1","toolName":"weather","input":" 1e2 "} +{"toolCallId":"call_1","toolName":"weather","input":"{\"a\":"} +{"toolCallId":"call_1","toolName":"weather","input":""} +{"toolCallId":"call_1","toolName":"weather","input":"{}"} +{"toolCallId":"call_1","toolName":"weather","input":"{}","dynamic":false} +{"toolCallId":"call_1","toolName":"weather","input":"{}","dynamic":true} +{"toolCallId":"call_1","toolName":"weather","input":"{}","providerExecuted":true} +{"toolCallId":"call_1","toolName":"weather","input":"{}","providerExecuted":true,"dynamic":false} +{"toolCallId":"call_1","toolName":"weather","input":"{}","providerExecuted":true,"dynamic":true} +{"toolCallId":"call_1","toolName":"weather","input":"{}","providerExecuted":false} +{"toolCallId":"call_1","toolName":"weather","input":"{}","providerExecuted":false,"dynamic":false} +{"toolCallId":"call_1","toolName":"weather","input":"{}","providerExecuted":false,"dynamic":true} diff --git a/fixtures/v4/enum-state/stream_tool_input_start.jsonl b/fixtures/v4/enum-state/stream_tool_input_start.jsonl new file mode 100644 index 000000000..35ec87d1c --- /dev/null +++ b/fixtures/v4/enum-state/stream_tool_input_start.jsonl @@ -0,0 +1,10 @@ +{"type":"tool-input-start","id":"call_1","toolName":"weather"} +{"type":"tool-input-start","id":"call_1","toolName":"weather","dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","dynamic":true} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":true} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":true,"dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":true,"dynamic":true} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":false,"dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":false,"dynamic":true} +{"type":"tool-input-start","id":"call_1","toolName":"weather","title":"Weather"} diff --git a/fixtures/v4/enum-state/text_stream_tool_input_start.jsonl b/fixtures/v4/enum-state/text_stream_tool_input_start.jsonl new file mode 100644 index 000000000..35ec87d1c --- /dev/null +++ b/fixtures/v4/enum-state/text_stream_tool_input_start.jsonl @@ -0,0 +1,10 @@ +{"type":"tool-input-start","id":"call_1","toolName":"weather"} +{"type":"tool-input-start","id":"call_1","toolName":"weather","dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","dynamic":true} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":true} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":true,"dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":true,"dynamic":true} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":false,"dynamic":false} +{"type":"tool-input-start","id":"call_1","toolName":"weather","providerExecuted":false,"dynamic":true} +{"type":"tool-input-start","id":"call_1","toolName":"weather","title":"Weather"} diff --git a/fixtures/v4/enum-state/tool_call.jsonl b/fixtures/v4/enum-state/tool_call.jsonl new file mode 100644 index 000000000..0fbbde9ab --- /dev/null +++ b/fixtures/v4/enum-state/tool_call.jsonl @@ -0,0 +1,24 @@ +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"}} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"dynamic":false} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"dynamic":true} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"providerExecuted":true} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"providerExecuted":true,"dynamic":false} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"providerExecuted":true,"dynamic":true} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"providerExecuted":false} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"providerExecuted":false,"dynamic":false} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"providerExecuted":false,"dynamic":true} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"dynamic":false,"invalid":false} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"invalid":false} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"dynamic":true,"invalid":false} +{"toolCallId":"call_1","toolName":"weather","input":"{\"city\":","dynamic":true,"invalid":true,"error":{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"{\"city\":","cause":"EOF while parsing"}} +{"toolCallId":"call_1","toolName":"weather","input":"{\"city\":","providerExecuted":true,"dynamic":true,"providerMetadata":{"openai":{"itemId":"fc_1"}},"invalid":true,"error":{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"{\"city\":","cause":"EOF while parsing"}} +{"toolCallId":"call_1","toolName":"weather","input":{"city":"Tokyo"},"providerMetadata":{"openai":{"itemId":"fc_1"}}} +{"toolCallId":"call_1","toolName":"weather","input":"Tokyo"} +{"toolCallId":"call_1","toolName":"weather","input":"{\"x\":1}"} +{"toolCallId":"call_1","toolName":"weather","input":123} +{"toolCallId":"call_1","toolName":"weather","input":true} +{"toolCallId":"call_1","toolName":"weather","input":null} +{"toolCallId":"call_1","toolName":"weather","input":{"a":2}} +{"toolCallId":"call_1","toolName":"weather","input":100.0} +{"toolCallId":"call_1","toolName":"weather","input":"","dynamic":true,"invalid":true,"error":{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"","cause":"JSON parsing failed"}} +{"toolCallId":"call_1","toolName":"weather","input":"{\"a\":","dynamic":true,"invalid":true,"error":{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"{\"a\":","cause":"JSON parsing failed"}} diff --git a/fixtures/v4/enum-state/tool_call_rejected.jsonl b/fixtures/v4/enum-state/tool_call_rejected.jsonl new file mode 100644 index 000000000..0f41fdd28 --- /dev/null +++ b/fixtures/v4/enum-state/tool_call_rejected.jsonl @@ -0,0 +1,3 @@ +{"toolCallId":"call_1","toolName":"weather","input":"{\"city\":","invalid":true,"error":{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"{\"city\":","cause":"EOF while parsing"}} +{"toolCallId":"call_1","toolName":"weather","input":"{\"city\":","dynamic":false,"invalid":true,"error":{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"{\"city\":","cause":"EOF while parsing"}} +{"toolCallId":"call_1","toolName":"weather","input":"{\"city\":","dynamic":true,"invalid":true} diff --git a/fixtures/v4/enum-state/tool_call_semantic.jsonl b/fixtures/v4/enum-state/tool_call_semantic.jsonl new file mode 100644 index 000000000..fced4e33d --- /dev/null +++ b/fixtures/v4/enum-state/tool_call_semantic.jsonl @@ -0,0 +1,2 @@ +{"toolCallId":"call_1","toolName":"weather","input":{"a":1,"a":2}} +{"toolCallId":"call_1","toolName":"weather","input":1e2} diff --git a/fixtures/v4/enum-state/tool_result.jsonl b/fixtures/v4/enum-state/tool_result.jsonl new file mode 100644 index 000000000..57206fbef --- /dev/null +++ b/fixtures/v4/enum-state/tool_result.jsonl @@ -0,0 +1,14 @@ +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3}} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"dynamic":false} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"dynamic":true} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"isError":true} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"isError":false} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"preliminary":true,"dynamic":false} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"preliminary":false} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"providerMetadata":{"xai":{"id":"fs_1"}}} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"providerExecuted":false} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"dynamic":false,"providerExecuted":false} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"dynamic":true,"providerExecuted":false} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"providerExecuted":true} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"dynamic":false,"providerExecuted":true} +{"toolCallId":"call_1","toolName":"web_search","result":{"hits":3},"dynamic":true,"providerExecuted":true}