blob: 056c463624b54e5f253eba75081ed600ef88cbac [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use maka_agent_facts::language::Language;
use maka_model::prompt::{ContentPart, Message, ToolOutput, ToolResult};
/// Chat serializes tool content as text. Expose already materialized media in
/// a user message, after all responses in the tool batch have been delivered.
pub(crate) fn project(messages: Vec<Message>, language: Language) -> Vec<Message> {
let mut projected = Vec::with_capacity(messages.len());
let mut images = Vec::new();
for mut message in messages {
if let Message::Tool { content, .. } = &mut message {
for result in content {
extract(result, &mut images, language);
}
} else {
flush(&mut projected, &mut images);
}
projected.push(message);
}
flush(&mut projected, &mut images);
projected
}
fn flush(messages: &mut Vec<Message>, images: &mut Vec<ContentPart>) {
if !images.is_empty() {
messages.push(Message::User {
content: std::mem::take(images),
provider_options: None,
});
}
}
fn media(part: &ContentPart) -> bool {
matches!(part, ContentPart::File { media_type, .. } if media_type.starts_with("image/") || media_type.starts_with("audio/"))
}
fn label(value: &str) -> String {
value.chars().take(128).collect()
}
fn extract(result: &mut ToolResult, images: &mut Vec<ContentPart>, language: Language) {
let ToolOutput::Content(parts) = &mut result.output else {
return;
};
if !parts.iter().any(media) {
return;
}
let kind = if parts.iter().any(|part| matches!(part, ContentPart::File { media_type, .. } if media_type.starts_with("audio/"))) { "Media" } else { "Images" };
images.push(ContentPart::text(match language {
Language::En => format!(
"{kind} from tool {} (toolCallId: {}):",
label(&result.tool_name),
label(&result.tool_call_id)
),
Language::ZhCn => format!(
"工具 {} 提供的{}(toolCallId: {}):",
label(&result.tool_name),
if kind == "Images" { "图片" } else { "媒体" },
label(&result.tool_call_id)
),
Language::ZhTw => format!(
"工具 {} 提供的{}(toolCallId: {}):",
label(&result.tool_name),
if kind == "Images" { "圖片" } else { "媒體" },
label(&result.tool_call_id)
),
}));
let mut retained = Vec::new();
for part in std::mem::take(parts) {
if media(&part) {
images.push(part);
} else {
retained.push(part);
}
}
if retained.is_empty() {
retained.push(ContentPart::text(match language {
Language::En => format!(
"Tool {} are supplied in the following user message.",
kind.to_ascii_lowercase()
),
Language::ZhCn => format!(
"工具{}见下一条用户消息。",
if kind == "Images" { "图片" } else { "媒体" }
),
Language::ZhTw => format!(
"工具{}見下一則使用者訊息。",
if kind == "Images" { "圖片" } else { "媒體" }
),
}));
}
*parts = retained;
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Value, json};
fn result(id: &str, parts: Value) -> Value {
json!({"type":"tool-result","toolCallId":id,"toolName":format!("tool-{id}"),
"providerOptions":{"openai":{"test":"result"}},
"output":{"type":"content","value":parts}})
}
fn file(data: &str) -> Value {
json!({"type":"file","mediaType":"image/png",
"data":{"type":"data","data":data},
"providerOptions":{"openai":{"imageDetail":"low"}}})
}
#[test]
fn images_follow_complete_tool_batch_and_keep_metadata() {
let first = result(
"one",
json!([
{"type":"text","text":"before"}, file("first-image"),
{"type":"file","mediaType":"application/pdf","data":{"type":"data","data":"pdf"}},
{"type":"text","text":"after"}
]),
);
let second = result("two", json!([file("second-image")]));
let messages = vec![
json!({"role":"assistant","content":[
{"type":"tool-call","toolCallId":"one","toolName":"tool-one","input":{"kind":"json","data":{}}},
{"type":"tool-call","toolCallId":"two","toolName":"tool-two","input":{"kind":"json","data":{}}}
]}),
json!({"role":"tool","providerOptions":{"test":"message"},"content":[first]}),
json!({"role":"tool","content":[second]}),
json!({"role":"assistant","content":[
{"type":"tool-call","toolCallId":"three","toolName":"tool-three","input":{"kind":"json","data":{}}},
{"type":"tool-call","toolCallId":"four","toolName":"tool-four","input":{"kind":"json","data":{}}}
]}),
json!({"role":"tool","content":[
result("three", json!([file("third-image")])),
result("four", json!([file("fourth-image")]))
]}),
];
let projected = serde_json::to_value(project(
serde_json::from_value(json!(messages)).unwrap(),
Language::En,
))
.unwrap();
assert_eq!(projected.as_array().unwrap().len(), 7);
assert_eq!(projected[1]["role"], "tool");
assert_eq!(projected[2]["role"], "tool");
assert_eq!(projected[3]["role"], "user");
assert_eq!(projected[4]["role"], "assistant");
assert_eq!(projected[1]["providerOptions"], json!({"test":"message"}));
for (index, id) in [(1, "one"), (2, "two")] {
assert_eq!(projected[index]["content"][0]["toolCallId"], id);
assert_eq!(
projected[index]["content"][0]["providerOptions"],
json!({"openai":{"test":"result"}})
);
}
assert_eq!(
projected[1]["content"][0]["output"]["value"],
json!([
{"type":"text","text":"before"},
{"type":"file","mediaType":"application/pdf","data":{"type":"data","data":"pdf"}},
{"type":"text","text":"after"}
])
);
assert_eq!(
projected[2]["content"][0]["output"]["value"][0]["type"],
"text"
);
assert_eq!(
projected[3]["content"],
json!([
{"type":"text","text":"Images from tool tool-one (toolCallId: one):"}, file("first-image"),
{"type":"text","text":"Images from tool tool-two (toolCallId: two):"}, file("second-image")
])
);
assert_eq!(projected[5]["role"], "tool");
assert_eq!(projected[6]["role"], "user");
assert_eq!(
projected[6]["content"],
json!([
{"type":"text","text":"Images from tool tool-three (toolCallId: three):"}, file("third-image"),
{"type":"text","text":"Images from tool tool-four (toolCallId: four):"}, file("fourth-image")
])
);
let encoded = serde_json::to_string(&projected).unwrap();
for data in ["first-image", "second-image", "third-image", "fourth-image"] {
assert_eq!(encoded.matches(data).count(), 1);
}
}
#[test]
fn opaque_json_and_unrelated_messages_are_unchanged() {
let messages = vec![
json!({"role":"user","content":[file("user-image")]}),
json!({"role":"tool","content":[{
"type":"tool-result","toolCallId":"opaque","toolName":"tool",
"output":{"type":"json","value":{"kind":"image","parts":[file("opaque")]}}
}, result("text", json!([{"type":"text","text":"plain"}]))]}),
];
assert_eq!(
serde_json::to_value(project(
serde_json::from_value(json!(messages)).unwrap(),
Language::En
))
.unwrap(),
json!(messages)
);
}
}