diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eaf7a4d97..5c1c7c748 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,11 @@ on: push: branches: [master] pull_request: - branches: [master] + branches: [master, "exp/**"] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: CARGO_TERM_COLOR: always diff --git a/aimux-core/src/generate.rs b/aimux-core/src/generate.rs index 0df13af5d..2ff3420c7 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -787,6 +787,7 @@ pub(crate) async fn generate_text_from_language_model_prompt( reason: None, is_automatic: None, signature: None, + input_schema_input: None, }); } GenerateContent::ToolResult(result) => rm.tool_result(result.clone()), @@ -1225,7 +1226,7 @@ pub async fn stream_text( if let Some(call) = tool_calls.get(&approval.tool_call_id) { yield Ok(TextStreamPart::ToolApprovalRequest(ToolApprovalRequestOutput { approval_id: approval.approval_id, tool_call: call.clone(), - reason: None, is_automatic: None, signature: None, + reason: None, is_automatic: None, signature: None, input_schema_input: None, })); } else { yield Ok(TextStreamPart::Error { error: AiMuxError::ToolCallNotFoundForApproval { diff --git a/aimux-core/src/parse_tool_call.rs b/aimux-core/src/parse_tool_call.rs index 79be0bacd..873455d3a 100644 --- a/aimux-core/src/parse_tool_call.rs +++ b/aimux-core/src/parse_tool_call.rs @@ -726,6 +726,7 @@ mod tests { reason: None, is_automatic: None, signature: None, + input_schema_input: None, }); } let assembled = builder.finish(); diff --git a/aimux-core/src/response_messages.rs b/aimux-core/src/response_messages.rs index 6d89336f5..d348a3ace 100644 --- a/aimux-core/src/response_messages.rs +++ b/aimux-core/src/response_messages.rs @@ -243,7 +243,13 @@ impl ResponseMessageBuilder { reason: approval.reason.clone(), is_automatic: approval.is_automatic, signature: approval.signature.clone(), - input_schema_input: None, + // As AI SDK `to-response-messages.ts`: only when it + // differs from the validated input. + input_schema_input: approval + .input_schema_input + .as_ref() + .filter(|raw| **raw != approval.tool_call.input) + .cloned(), } } GenerateContent::ToolCall(call) => ContentPart::ToolCall { @@ -312,3 +318,57 @@ fn file_base64(data: &FileBytes) -> String { FileBytes::Binary(data) => base64::engine::general_purpose::STANDARD.encode(data), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn approval(input_schema_input: Option) -> ToolApprovalRequestOutput { + ToolApprovalRequestOutput { + approval_id: "approval-1".into(), + tool_call: crate::tool::ToolCall { + tool_call_id: "call-1".into(), + tool_name: "weather".into(), + input: serde_json::json!({"city": "Paris"}), + provider_executed: Some(true), + dynamic: None, + provider_metadata: None, + invalid: None, + error: None, + }, + reason: None, + is_automatic: None, + signature: None, + input_schema_input, + } + } + + fn approval_part(input_schema_input: Option) -> Value { + let mut builder = ResponseMessageBuilder::new(); + builder.approval(&approval(input_schema_input)); + let messages = serde_json::to_value(builder.finish().messages).unwrap(); + messages[0]["content"] + .as_array() + .unwrap() + .iter() + .find(|part| part["type"] == "tool-approval-request") + .cloned() + .unwrap() + } + + #[test] + fn approval_writes_input_schema_input_when_it_differs_from_the_input() { + let part = approval_part(Some(serde_json::json!({"city": "paris"}))); + assert_eq!( + part["inputSchemaInput"], + serde_json::json!({"city": "paris"}) + ); + } + + #[test] + fn approval_omits_input_schema_input_when_equal_or_absent() { + let equal = approval_part(Some(serde_json::json!({"city": "Paris"}))); + assert!(equal.get("inputSchemaInput").is_none()); + assert!(approval_part(None).get("inputSchemaInput").is_none()); + } +} diff --git a/aimux-core/src/result.rs b/aimux-core/src/result.rs index a3cd58f55..966166ab6 100644 --- a/aimux-core/src/result.rs +++ b/aimux-core/src/result.rs @@ -93,6 +93,14 @@ pub struct ToolApprovalRequestOutput { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub signature: Option, + /// Tool input before input-schema validation and transformation. Carried + /// internally only: it is not part of this type's JSON. It reaches the + /// response message's `tool-approval-request` part when it differs from + /// `tool_call.input` (AI SDK `to-response-messages.ts`). Pass-through + /// only: core runs no host approval (#200 ยง0.4 Q1). + #[serde(skip)] + #[ts(skip)] + pub input_schema_input: Option, } /// A source / citation (e.g. URL citation from search-preview models). The diff --git a/aimux-core/tests/tool_approval_request_output_test.rs b/aimux-core/tests/tool_approval_request_output_test.rs new file mode 100644 index 000000000..4cad8e89a --- /dev/null +++ b/aimux-core/tests/tool_approval_request_output_test.rs @@ -0,0 +1,66 @@ +//! `inputSchemaInput` (#200:1952 lists it on the model-message +//! `tool-approval-request` content part). +//! +//! - The prompt/response content part (`ContentPart::ToolApprovalRequest`) +//! carries it, camelCase, optional, and round-trips it. +//! - `ToolApprovalRequestOutput` keeps it internally only; it is not part of +//! that type's JSON. + +use aimux_core::content::ContentPart; +use aimux_core::result::ToolApprovalRequestOutput; +use serde_json::json; + +fn part_json(input_schema_input: Option) -> serde_json::Value { + let mut value = json!({ + "type": "tool-approval-request", + "approvalId": "approval-1", + "toolCallId": "call-1", + "reason": "needs confirmation" + }); + if let Some(raw) = input_schema_input { + value["inputSchemaInput"] = raw; + } + value +} + +#[test] +fn content_part_round_trips_with_input_schema_input() { + for raw in [json!({"city": "paris"}), json!("{\"city\":\"paris\"")] { + let wire = part_json(Some(raw.clone())); + let part: ContentPart = serde_json::from_value(wire.clone()).unwrap(); + let ContentPart::ToolApprovalRequest { + input_schema_input, .. + } = &part + else { + panic!("expected a tool-approval-request part"); + }; + assert_eq!(input_schema_input.as_ref(), Some(&raw)); + assert_eq!(serde_json::to_value(&part).unwrap(), wire); + } +} + +#[test] +fn content_part_round_trips_without_input_schema_input() { + let wire = part_json(None); + let part: ContentPart = serde_json::from_value(wire.clone()).unwrap(); + let back = serde_json::to_value(&part).unwrap(); + assert!(back.get("inputSchemaInput").is_none()); + assert_eq!(back, wire); +} + +#[test] +fn approval_output_json_has_no_input_schema_input() { + let wire = json!({ + "approvalId": "approval-1", + "toolCall": {"toolCallId": "call-1", "toolName": "weather", "input": {"city": "Paris"}}, + "inputSchemaInput": {"city": "paris"} + }); + let mut approval: ToolApprovalRequestOutput = serde_json::from_value(wire).unwrap(); + assert_eq!(approval.input_schema_input, None, "not read from JSON"); + approval.input_schema_input = Some(json!({"city": "paris"})); + let back = serde_json::to_value(&approval).unwrap(); + assert!( + back.get("inputSchemaInput").is_none(), + "not written to JSON" + ); +}