use indexmap::IndexSet;
use oino_ai::{AgentMessage, AssistantContent, JsonObject, JsonValue, Message, UserContent};
use serde::{Deserialize, Serialize};
use super::js;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileOperations {
pub read: IndexSet<String>,
pub written: IndexSet<String>,
pub edited: IndexSet<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileLists {
pub read_files: Vec<String>,
pub modified_files: Vec<String>,
}
impl FileOperations {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn extract_from_message(&mut self, message: &AgentMessage) {
match message {
AgentMessage::ToolResult(result) => {
for call in result.nested_calls.iter().flat_map(|nested| &nested.calls) {
self.add(&call.name, call.arguments.as_ref());
}
}
AgentMessage::Assistant(assistant) => {
for block in &assistant.content {
if let AssistantContent::ToolCall(call) = block {
self.add(&call.name, Some(&call.arguments));
}
}
}
_ => {}
}
}
fn add(&mut self, tool: &str, arguments: Option<&JsonObject>) {
let Some(path) = arguments
.and_then(|args| args.get("path"))
.and_then(JsonValue::as_str)
.filter(|path| !path.is_empty())
else {
return;
};
let set = match tool {
"read" => &mut self.read,
"write" => &mut self.written,
"edit" => &mut self.edited,
_ => return,
};
set.insert(path.to_owned());
}
#[must_use]
pub fn file_lists(&self) -> FileLists {
let modified: IndexSet<&String> = self.edited.iter().chain(&self.written).collect();
let mut read_files: Vec<String> = self
.read
.iter()
.filter(|path| !modified.contains(path))
.cloned()
.collect();
let mut modified_files: Vec<String> = modified.into_iter().cloned().collect();
read_files.sort_by(|a, b| js::cmp16(a, b));
modified_files.sort_by(|a, b| js::cmp16(a, b));
FileLists {
read_files,
modified_files,
}
}
pub fn seed_from_details(&mut self, details: &JsonValue) {
let list = |key: &str| {
details
.get(key)
.and_then(JsonValue::as_array)
.into_iter()
.flatten()
.filter_map(JsonValue::as_str)
.map(str::to_owned)
};
self.read.extend(list("readFiles"));
self.edited.extend(list("modifiedFiles"));
}
}
#[must_use]
pub fn format_file_operations(read_files: &[String], modified_files: &[String]) -> String {
let mut sections = Vec::new();
if !read_files.is_empty() {
sections.push(format!(
"<read-files>\n{}\n</read-files>",
read_files.join("\n")
));
}
if !modified_files.is_empty() {
sections.push(format!(
"<modified-files>\n{}\n</modified-files>",
modified_files.join("\n")
));
}
if sections.is_empty() {
return String::new();
}
format!("\n\n{}", sections.join("\n\n"))
}
pub const TOOL_RESULT_MAX_CHARS: usize = 2000;
fn truncate_for_summary(text: &str, max_chars: usize) -> String {
let length = js::len16(text);
if length <= max_chars {
return text.to_owned();
}
format!(
"{}\n\n[... {} more characters truncated]",
js::prefix16(text, max_chars),
length - max_chars
)
}
fn blocks_text(blocks: &[UserContent], separator: &str) -> String {
blocks
.iter()
.filter_map(|block| match block {
UserContent::Text(text) => Some(text.text.as_str()),
UserContent::Image(_) => None,
})
.collect::<Vec<_>>()
.join(separator)
}
#[must_use]
pub fn serialize_conversation(messages: &[Message]) -> String {
let mut parts = Vec::new();
for message in messages {
match message {
Message::User(user) => {
let content = user.content.text("");
if !content.is_empty() {
parts.push(format!("[User]: {content}"));
}
}
Message::Assistant(assistant) => {
let mut texts = Vec::new();
let mut calls = Vec::new();
for block in &assistant.content {
match block {
AssistantContent::Text(text) => texts.push(text.text.as_str()),
AssistantContent::ToolCall(call) => {
let args = call
.arguments
.iter()
.map(|(key, value)| format!("{key}={}", js::stringify(value)))
.collect::<Vec<_>>()
.join(", ");
calls.push(format!("{}({args})", call.name));
}
AssistantContent::Thinking(_) => {}
}
}
if !texts.is_empty() {
parts.push(format!("[Assistant]: {}", texts.join("\n")));
}
if !calls.is_empty() {
parts.push(format!("[Assistant tool calls]: {}", calls.join("; ")));
}
}
Message::ToolResult(result) => {
let content = blocks_text(&result.content, "");
if !content.is_empty() {
parts.push(format!(
"[Tool result]: {}",
truncate_for_summary(&content, TOOL_RESULT_MAX_CHARS)
));
}
}
Message::System(_) => {}
}
}
parts.join("\n\n")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn seed_ignores_malformed_details() {
let mut ops = FileOperations::new();
ops.seed_from_details(&json!({"readFiles": "x", "modifiedFiles": ["a", 1]}));
assert!(ops.read.is_empty());
assert_eq!(ops.edited.iter().collect::<Vec<_>>(), ["a"]);
}
}