Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion aimux-core/src/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions aimux-core/src/parse_tool_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,7 @@ mod tests {
reason: None,
is_automatic: None,
signature: None,
input_schema_input: None,
});
}
let assembled = builder.finish();
Expand Down
62 changes: 61 additions & 1 deletion aimux-core/src/response_messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Value>) -> 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>) -> 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());
}
}
8 changes: 8 additions & 0 deletions aimux-core/src/result.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ pub struct ToolApprovalRequestOutput {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub signature: Option<String>,
/// 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<serde_json::Value>,
}

/// A source / citation (e.g. URL citation from search-preview models). The
Expand Down
66 changes: 66 additions & 0 deletions aimux-core/tests/tool_approval_request_output_test.rs
Original file line number Diff line number Diff line change
@@ -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>) -> 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"
);
}
Loading