//! Tool definitions for file editing via LLM.
use jaq_interpret::FilterT;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::db::models::{BodyElement, ChartType, TranscriptEntry};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tool {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ToolResult {
pub success: bool,
pub message: String,
}
/// Available tools for file editing
pub static AVAILABLE_TOOLS: once_cell::sync::Lazy<Vec<Tool>> =
once_cell::sync::Lazy::new(|| {
vec![
Tool {
name: "add_heading".to_string(),
description: "Add a heading element to the file body".to_string(),
parameters: json!({
"type": "object",
"properties": {
"level": {
"type": "integer",
"description": "Heading level (1-6)",
"minimum": 1,
"maximum": 6
},
"text": {
"type": "string",
"description": "The heading text"
},
"position": {
"type": "integer",
"description": "Optional position to insert at (0-indexed). If not specified, appends to end."
}
},
"required": ["level", "text"]
}),
},
Tool {
name: "add_paragraph".to_string(),
description: "Add a paragraph element to the file body".to_string(),
parameters: json!({
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "The paragraph text"
},
"position": {
"type": "integer",
"description": "Optional position to insert at (0-indexed). If not specified, appends to end."
}
},
"required": ["text"]
}),
},
Tool {
name: "add_code".to_string(),
description: "Add a code block element to the file body".to_string(),
parameters: json!({
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "The code content"
},
"language": {
"type": "string",
"description": "Optional programming language for syntax highlighting (e.g., 'javascript', 'python', 'rust')"
},
"position": {
"type": "integer",
"description": "Optional position to insert at (0-indexed). If not specified, appends to end."
}
},
"required": ["content"]
}),
},
Tool {
name: "add_list".to_string(),
description: "Add a list element (ordered or unordered) to the file body".to_string(),
parameters: json!({
"type": "object",
"properties": {
"items": {
"type": "array",
"items": { "type": "string" },
"description": "Array of list item strings"
},
"ordered": {
"type": "boolean",
"description": "If true, creates a numbered list; if false (default), creates a bullet list"
},
"position": {
"type": "integer",
"description": "Optional position to insert at (0-indexed). If not specified, appends to end."
}
},
"required": ["items"]
}),
},
Tool {
name: "add_chart".to_string(),
description: "Add a chart visualization to the file body. Supports line, bar, pie, and area charts.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"chart_type": {
"type": "string",
"enum": ["line", "bar", "pie", "area"],
"description": "Type of chart to create"
},
"title": {
"type": "string",
"description": "Optional chart title"
},
"data": {
"type": "array",
"description": "Array of data points. Each point should have a 'name' field and one or more numeric value fields.",
"items": {
"type": "object"
}
},
"config": {
"type": "object",
"description": "Optional chart configuration (colors, axes, etc.)"
},
"position": {
"type": "integer",
"description": "Optional position to insert at (0-indexed). If not specified, appends to end."
}
},
"required": ["chart_type", "data"]
}),
},
Tool {
name: "remove_element".to_string(),
description: "Remove an element from the file body by index".to_string(),
parameters: json!({
"type": "object",
"properties": {
"index": {
"type": "integer",
"description": "Index of element to remove (0-indexed)"
}
},
"required": ["index"]
}),
},
Tool {
name: "update_element".to_string(),
description: "Update an existing element in the file body. IMPORTANT: You must provide ALL required fields. For heading: type, level (1-6), text. For paragraph: type, text. For code: type, content, language (optional). For list: type, items (array of strings), ordered (boolean). For chart: type, chartType (line/bar/pie/area), data (array of objects).".to_string(),
parameters: json!({
"type": "object",
"properties": {
"index": {
"type": "integer",
"description": "Index of element to update (0-indexed)"
},
"element_type": {
"type": "string",
"enum": ["heading", "paragraph", "code", "list", "chart"],
"description": "Type of element"
},
"text": {
"type": "string",
"description": "Text content (required for heading and paragraph)"
},
"level": {
"type": "integer",
"description": "Heading level 1-6 (required for heading)"
},
"content": {
"type": "string",
"description": "Code content (required for code)"
},
"language": {
"type": "string",
"description": "Programming language for syntax highlighting (optional for code)"
},
"items": {
"type": "array",
"items": { "type": "string" },
"description": "List items (required for list)"
},
"ordered": {
"type": "boolean",
"description": "If true, numbered list; if false, bullet list (for list)"
},
"chartType": {
"type": "string",
"enum": ["line", "bar", "pie", "area"],
"description": "Chart type (required for chart)"
},
"data": {
"type": "array",
"description": "Chart data array (required for chart)",
"items": { "type": "object" }
},
"title": {
"type": "string",
"description": "Chart title (optional for chart)"
}
},
"required": ["index", "element_type"]
}),
},
Tool {
name: "reorder_elements".to_string(),
description: "Move an element from one position to another".to_string(),
parameters: json!({
"type": "object",
"properties": {
"from_index": {
"type": "integer",
"description": "Current index of the element"
},
"to_index": {
"type": "integer",
"description": "New index for the element"
}
},
"required": ["from_index", "to_index"]
}),
},
Tool {
name: "set_summary".to_string(),
description: "Set the file summary text".to_string(),
parameters: json!({
"type": "object",
"properties": {
"summary": {
"type": "string",
"description": "The summary text"
}
},
"required": ["summary"]
}),
},
Tool {
name: "parse_csv".to_string(),
description: "Parse CSV data into JSON format suitable for charts".to_string(),
parameters: json!({
"type": "object",
"properties": {
"csv": {
"type": "string",
"description": "CSV data string with header row"
}
},
"required": ["csv"]
}),
},
Tool {
name: "clear_body".to_string(),
description: "Clear all elements from the file body".to_string(),
parameters: json!({
"type": "object",
"properties": {}
}),
},
Tool {
name: "jq".to_string(),
description: "Transform JSON data using jq expressions. Useful for filtering, mapping, grouping, and aggregating data before creating charts.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"input": {
"description": "The JSON data to transform (can be an array or object)"
},
"filter": {
"type": "string",
"description": "The jq filter expression. Examples: '.[] | select(.value > 10)', 'group_by(.category) | map({name: .[0].category, count: length})', '[.[] | {name: .label, value: .amount}]'"
}
},
"required": ["input", "filter"]
}),
},
// Interactive tools
Tool {
name: "ask_user".to_string(),
description: "Ask the user one or more questions. Use this when you need clarification, want to offer choices, or need user input before proceeding. Questions can be single-select (user picks one option) or multi-select (user can pick multiple options). The question text supports markdown formatting. The conversation will pause until the user responds.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"questions": {
"type": "array",
"description": "List of questions to ask the user",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Unique identifier for this question (e.g., 'chart_type', 'color_scheme')"
},
"question": {
"type": "string",
"description": "The question to ask the user. Supports markdown formatting (bold, code, lists, etc.)"
},
"options": {
"type": "array",
"items": { "type": "string" },
"description": "Multiple choice options for the user to select from"
},
"allowMultiple": {
"type": "boolean",
"description": "If true, user can select multiple options (multi-select). If false or omitted, user selects exactly one option (single-select). Default: false"
},
"allowCustom": {
"type": "boolean",
"description": "If true, user can provide a custom text answer instead of selecting from options. Default: true"
}
},
"required": ["id", "question", "options"]
}
}
},
"required": ["questions"]
}),
},
// Content viewing tools
Tool {
name: "view_body".to_string(),
description: "View the complete body structure with full content of all elements. Returns detailed information about each element including type, index, and full text/data.".to_string(),
parameters: json!({
"type": "object",
"properties": {},
"required": []
}),
},
Tool {
name: "read_element".to_string(),
description: "Read the full content of a specific body element by its index. Use this to get complete details of a single element.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"index": {
"type": "integer",
"description": "Index of the element to read (0-indexed)"
}
},
"required": ["index"]
}),
},
Tool {
name: "view_transcript".to_string(),
description: "View the complete transcript of the file. Returns all transcript entries with speaker names, text, and timestamps.".to_string(),
parameters: json!({
"type": "object",
"properties": {},
"required": []
}),
},
// Version history tools
Tool {
name: "list_versions".to_string(),
description: "List all available versions of the current document. Returns version numbers, sources (user/llm/system), timestamps, and change descriptions.".to_string(),
parameters: json!({
"type": "object",
"properties": {},
"required": []
}),
},
Tool {
name: "read_version".to_string(),
description: "Read the content of a specific historical version of the document. This is read-only and does not modify the current document.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"version": {
"type": "integer",
"description": "The version number to read"
}
},
"required": ["version"]
}),
},
Tool {
name: "restore_version".to_string(),
description: "Restore the document to a previous version. This creates a new version with the content from the target version. The current content will be preserved as a historical version.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"target_version": {
"type": "integer",
"description": "The version number to restore to"
},
"reason": {
"type": "string",
"description": "Optional reason for the restore (will be recorded in change description)"
}
},
"required": ["target_version"]
}),
},
]
});
/// Request for version-related operations that require async database access
#[derive(Debug, Clone)]
pub enum VersionToolRequest {
/// List all versions of the current file
ListVersions,
/// Read a specific version
ReadVersion { version: i32 },
/// Restore to a specific version
RestoreVersion { target_version: i32, reason: Option<String> },
}
/// A question to ask the user
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct UserQuestion {
/// Unique identifier for this question
pub id: String,
/// The question text
pub question: String,
/// Multiple choice options
pub options: Vec<String>,
/// Whether multiple options can be selected
#[serde(default)]
pub allow_multiple: bool,
/// Whether a custom answer is allowed
#[serde(default = "default_allow_custom")]
pub allow_custom: bool,
}
fn default_allow_custom() -> bool {
true
}
/// User's answer to a question
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct UserAnswer {
/// Question ID this answers
pub id: String,
/// Selected option(s) or custom answer
pub answers: Vec<String>,
}
/// Result of executing a tool call with modified file state
#[derive(Debug)]
pub struct ToolExecutionResult {
pub result: ToolResult,
pub new_body: Option<Vec<BodyElement>>,
pub new_summary: Option<String>,
pub parsed_data: Option<serde_json::Value>,
/// Request for async version operations (handled by chat handler)
pub version_request: Option<VersionToolRequest>,
/// Questions to ask the user (pauses conversation until answered)
pub pending_questions: Option<Vec<UserQuestion>>,
}
/// Execute a tool call and return the result along with any state changes
pub fn execute_tool_call(
call: &ToolCall,
current_body: &[BodyElement],
current_summary: Option<&str>,
transcript: &[TranscriptEntry],
) -> ToolExecutionResult {
match call.name.as_str() {
"add_heading" => execute_add_heading(call, current_body),
"add_paragraph" => execute_add_paragraph(call, current_body),
"add_code" => execute_add_code(call, current_body),
"add_list" => execute_add_list(call, current_body),
"add_chart" => execute_add_chart(call, current_body),
"remove_element" => execute_remove_element(call, current_body),
"update_element" => execute_update_element(call, current_body),
"reorder_elements" => execute_reorder_elements(call, current_body),
"set_summary" => execute_set_summary(call, current_summary),
"parse_csv" => execute_parse_csv(call),
"clear_body" => execute_clear_body(),
"jq" => execute_jq(call),
// Interactive tools
"ask_user" => execute_ask_user(call),
// Content viewing tools
"view_body" => execute_view_body(current_body),
"read_element" => execute_read_element(call, current_body),
"view_transcript" => execute_view_transcript(transcript),
// Version history tools - return request for async handling
"list_versions" => execute_list_versions(),
"read_version" => execute_read_version(call),
"restore_version" => execute_restore_version(call),
_ => ToolExecutionResult {
result: ToolResult {
success: false,
message: format!("Unknown tool: {}", call.name),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
},
}
}
fn execute_ask_user(call: &ToolCall) -> ToolExecutionResult {
let questions_value = call.arguments.get("questions");
let Some(questions_array) = questions_value.and_then(|v| v.as_array()) else {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: "Missing or invalid 'questions' parameter".to_string(),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
};
};
let mut questions: Vec<UserQuestion> = Vec::new();
for q in questions_array {
let id = q.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
let question = q.get("question").and_then(|v| v.as_str()).unwrap_or("").to_string();
let options: Vec<String> = q
.get("options")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|o| o.as_str())
.map(|s| s.to_string())
.collect()
})
.unwrap_or_default();
let allow_multiple = q.get("allowMultiple").and_then(|v| v.as_bool()).unwrap_or(false);
let allow_custom = q.get("allowCustom").and_then(|v| v.as_bool()).unwrap_or(true);
if id.is_empty() || question.is_empty() || options.is_empty() {
continue;
}
questions.push(UserQuestion {
id,
question,
options,
allow_multiple,
allow_custom,
});
}
if questions.is_empty() {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: "No valid questions provided".to_string(),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
};
}
let question_count = questions.len();
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!("Asking user {} question(s). Waiting for response...", question_count),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: Some(questions),
}
}
fn execute_add_heading(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
let level = call.arguments.get("level").and_then(|v| v.as_u64()).unwrap_or(1) as u8;
let text = call
.arguments
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let position = call.arguments.get("position").and_then(|v| v.as_u64());
let element = BodyElement::Heading { level, text: text.clone() };
let mut new_body = current_body.to_vec();
if let Some(pos) = position {
let pos = pos as usize;
if pos <= new_body.len() {
new_body.insert(pos, element);
} else {
new_body.push(element);
}
} else {
new_body.push(element);
}
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!("Added heading: {}", text),
},
new_body: Some(new_body),
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
}
}
fn execute_add_paragraph(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
let text = call
.arguments
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let position = call.arguments.get("position").and_then(|v| v.as_u64());
let element = BodyElement::Paragraph { text: text.clone() };
let mut new_body = current_body.to_vec();
if let Some(pos) = position {
let pos = pos as usize;
if pos <= new_body.len() {
new_body.insert(pos, element);
} else {
new_body.push(element);
}
} else {
new_body.push(element);
}
let preview = if text.len() > 50 {
format!("{}...", &text[..50])
} else {
text
};
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!("Added paragraph: {}", preview),
},
new_body: Some(new_body),
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
}
}
fn execute_add_code(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
let language = call
.arguments
.get("language")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let content = call
.arguments
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let position = call.arguments.get("position").and_then(|v| v.as_u64());
let element = BodyElement::Code {
language: language.clone(),
content: content.clone(),
};
let mut new_body = current_body.to_vec();
if let Some(pos) = position {
let pos = pos as usize;
if pos <= new_body.len() {
new_body.insert(pos, element);
} else {
new_body.push(element);
}
} else {
new_body.push(element);
}
let lang_str = language.as_deref().unwrap_or("plain");
let preview: String = content.chars().take(50).collect();
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!("Added code block ({}): {}", lang_str, preview),
},
new_body: Some(new_body),
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
}
}
fn execute_add_list(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
let ordered = call
.arguments
.get("ordered")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let items: Vec<String> = call
.arguments
.get("items")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let position = call.arguments.get("position").and_then(|v| v.as_u64());
let element = BodyElement::List {
ordered,
items: items.clone(),
};
let mut new_body = current_body.to_vec();
if let Some(pos) = position {
let pos = pos as usize;
if pos <= new_body.len() {
new_body.insert(pos, element);
} else {
new_body.push(element);
}
} else {
new_body.push(element);
}
let list_type = if ordered { "ordered" } else { "unordered" };
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!("Added {} list with {} items", list_type, items.len()),
},
new_body: Some(new_body),
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
}
}
fn execute_add_chart(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
let chart_type_str = call
.arguments
.get("chart_type")
.and_then(|v| v.as_str())
.unwrap_or("bar");
let chart_type = match chart_type_str {
"line" => ChartType::Line,
"bar" => ChartType::Bar,
"pie" => ChartType::Pie,
"area" => ChartType::Area,
_ => ChartType::Bar,
};
let title = call
.arguments
.get("title")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let data = call
.arguments
.get("data")
.cloned()
.unwrap_or(json!([]));
let config = call.arguments.get("config").cloned();
let position = call.arguments.get("position").and_then(|v| v.as_u64());
let element = BodyElement::Chart {
chart_type,
title: title.clone(),
data,
config,
};
let mut new_body = current_body.to_vec();
if let Some(pos) = position {
let pos = pos as usize;
if pos <= new_body.len() {
new_body.insert(pos, element);
} else {
new_body.push(element);
}
} else {
new_body.push(element);
}
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!(
"Added {} chart{}",
chart_type_str,
title.map(|t| format!(": {}", t)).unwrap_or_default()
),
},
new_body: Some(new_body),
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
}
}
fn execute_remove_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
let index = call.arguments.get("index").and_then(|v| v.as_u64());
let Some(index) = index else {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: "Missing index parameter".to_string(),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
};
};
let index = index as usize;
if index >= current_body.len() {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: format!("Index {} out of bounds (body has {} elements)", index, current_body.len()),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
};
}
let mut new_body = current_body.to_vec();
new_body.remove(index);
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!("Removed element at index {}", index),
},
new_body: Some(new_body),
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
}
}
fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
let index = call.arguments.get("index").and_then(|v| v.as_u64());
let element_type = call.arguments.get("element_type").and_then(|v| v.as_str());
let Some(index) = index else {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: "Missing index parameter".to_string(),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
};
};
let Some(element_type) = element_type else {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: "Missing element_type parameter".to_string(),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
};
};
let index = index as usize;
if index >= current_body.len() {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: format!("Index {} out of bounds (body has {} elements)", index, current_body.len()),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
};
}
// Build the element based on type
let new_element = match element_type {
"heading" => {
let level = call.arguments.get("level").and_then(|v| v.as_u64()).unwrap_or(1) as u8;
let text = call.arguments.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string();
BodyElement::Heading { level, text }
}
"paragraph" => {
let text = call.arguments.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string();
BodyElement::Paragraph { text }
}
"code" => {
let language = call.arguments.get("language").and_then(|v| v.as_str()).map(|s| s.to_string());
let content = call.arguments.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string();
BodyElement::Code { language, content }
}
"list" => {
let ordered = call.arguments.get("ordered").and_then(|v| v.as_bool()).unwrap_or(false);
let items = call.arguments.get("items")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
BodyElement::List { ordered, items }
}
"chart" => {
let chart_type_str = call.arguments.get("chartType").and_then(|v| v.as_str()).unwrap_or("bar");
let chart_type = match chart_type_str {
"line" => ChartType::Line,
"bar" => ChartType::Bar,
"pie" => ChartType::Pie,
"area" => ChartType::Area,
_ => ChartType::Bar,
};
let title = call.arguments.get("title").and_then(|v| v.as_str()).map(|s| s.to_string());
let data = call.arguments.get("data").cloned().unwrap_or(json!([]));
let config = call.arguments.get("config").cloned();
BodyElement::Chart { chart_type, title, data, config }
}
_ => {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: format!("Unknown element_type: {}. Must be heading, paragraph, code, list, or chart.", element_type),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
};
}
};
let mut new_body = current_body.to_vec();
new_body[index] = new_element;
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!("Updated element at index {} to {}", index, element_type),
},
new_body: Some(new_body),
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
}
}
fn execute_reorder_elements(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
let from_index = call.arguments.get("from_index").and_then(|v| v.as_u64());
let to_index = call.arguments.get("to_index").and_then(|v| v.as_u64());
let (Some(from), Some(to)) = (from_index, to_index) else {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: "Missing from_index or to_index parameter".to_string(),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
};
};
let from = from as usize;
let to = to as usize;
if from >= current_body.len() || to >= current_body.len() {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: format!(
"Index out of bounds: from={}, to={}, body has {} elements",
from, to, current_body.len()
),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
};
}
let mut new_body = current_body.to_vec();
let element = new_body.remove(from);
new_body.insert(to, element);
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!("Moved element from index {} to {}", from, to),
},
new_body: Some(new_body),
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
}
}
fn execute_set_summary(call: &ToolCall, _current_summary: Option<&str>) -> ToolExecutionResult {
let summary = call
.arguments
.get("summary")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
ToolExecutionResult {
result: ToolResult {
success: true,
message: "Summary updated".to_string(),
},
new_body: None,
new_summary: Some(summary),
parsed_data: None,
version_request: None,
pending_questions: None,
}
}
fn execute_parse_csv(call: &ToolCall) -> ToolExecutionResult {
let csv = call
.arguments
.get("csv")
.and_then(|v| v.as_str())
.unwrap_or("");
let lines: Vec<&str> = csv.lines().collect();
if lines.is_empty() {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: "Empty CSV data".to_string(),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
};
}
let headers: Vec<&str> = lines[0].split(',').map(|s| s.trim()).collect();
let mut data: Vec<serde_json::Value> = Vec::new();
for line in lines.iter().skip(1) {
if line.trim().is_empty() {
continue;
}
let values: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
let mut row = serde_json::Map::new();
for (i, header) in headers.iter().enumerate() {
if let Some(value) = values.get(i) {
// Try to parse as number, otherwise use string
if let Ok(num) = value.parse::<f64>() {
row.insert(header.to_string(), json!(num));
} else {
row.insert(header.to_string(), json!(value));
}
}
}
data.push(serde_json::Value::Object(row));
}
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!("Parsed {} rows from CSV", data.len()),
},
new_body: None,
new_summary: None,
parsed_data: Some(json!(data)),
version_request: None,
pending_questions: None,
}
}
fn execute_clear_body() -> ToolExecutionResult {
ToolExecutionResult {
result: ToolResult {
success: true,
message: "Cleared all body elements".to_string(),
},
new_body: Some(vec![]),
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
}
}
fn execute_jq(call: &ToolCall) -> ToolExecutionResult {
let input = match call.arguments.get("input") {
Some(v) => v.clone(),
None => {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: "Missing input parameter".to_string(),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
};
}
};
let filter = match call.arguments.get("filter").and_then(|v| v.as_str()) {
Some(f) => f,
None => {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: "Missing filter parameter".to_string(),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
};
}
};
// Parse the jq filter
let mut defs = jaq_interpret::ParseCtx::new(Vec::new());
defs.insert_natives(jaq_core::core());
defs.insert_defs(jaq_std::std());
let (parsed_filter, errs) = jaq_parse::parse(filter, jaq_parse::main());
if !errs.is_empty() {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: format!("Invalid jq filter: {:?}", errs),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
};
}
let Some(parsed_filter) = parsed_filter else {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: "Failed to parse jq filter".to_string(),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
};
};
// Compile the filter
let compiled = defs.compile(parsed_filter);
if !defs.errs.is_empty() {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: format!("Failed to compile jq filter ({} errors)", defs.errs.len()),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
};
}
// Convert serde_json::Value to jaq Value
let jaq_input = json_to_jaq(&input);
// Execute the filter
let inputs = jaq_interpret::RcIter::new(std::iter::empty());
let mut results: Vec<serde_json::Value> = Vec::new();
for output in compiled.run((jaq_interpret::Ctx::new([], &inputs), jaq_input)) {
match output {
Ok(val) => {
results.push(jaq_to_json(&val));
}
Err(e) => {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: format!("jq execution error: {:?}", e),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
};
}
}
}
// Return single value or array based on results
let output = if results.len() == 1 {
results.into_iter().next().unwrap()
} else {
json!(results)
};
let preview = {
let s = output.to_string();
if s.len() > 100 {
format!("{}...", &s[..100])
} else {
s
}
};
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!("jq transform complete: {}", preview),
},
new_body: None,
new_summary: None,
parsed_data: Some(output),
version_request: None,
pending_questions: None,
}
}
// =============================================================================
// Content Viewing Tool Execution Functions
// =============================================================================
fn execute_view_body(current_body: &[BodyElement]) -> ToolExecutionResult {
if current_body.is_empty() {
return ToolExecutionResult {
result: ToolResult {
success: true,
message: "Body is empty (no elements)".to_string(),
},
new_body: None,
new_summary: None,
parsed_data: Some(json!([])),
version_request: None,
pending_questions: None,
};
}
let elements: Vec<serde_json::Value> = current_body
.iter()
.enumerate()
.map(|(i, element)| {
match element {
BodyElement::Heading { level, text } => json!({
"index": i,
"type": "heading",
"level": level,
"text": text
}),
BodyElement::Paragraph { text } => json!({
"index": i,
"type": "paragraph",
"text": text
}),
BodyElement::Code { language, content } => json!({
"index": i,
"type": "code",
"language": language,
"content": content
}),
BodyElement::List { ordered, items } => json!({
"index": i,
"type": "list",
"ordered": ordered,
"items": items
}),
BodyElement::Chart { chart_type, title, data, config } => json!({
"index": i,
"type": "chart",
"chartType": format!("{:?}", chart_type).to_lowercase(),
"title": title,
"data": data,
"config": config
}),
BodyElement::Image { src, alt, caption } => json!({
"index": i,
"type": "image",
"src": src,
"alt": alt,
"caption": caption
}),
BodyElement::Markdown { content } => json!({
"index": i,
"type": "markdown",
"content": content
}),
}
})
.collect();
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!("Body contains {} element(s)", current_body.len()),
},
new_body: None,
new_summary: None,
parsed_data: Some(json!(elements)),
version_request: None,
pending_questions: None,
}
}
fn execute_read_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
let index = call.arguments.get("index").and_then(|v| v.as_u64());
let Some(index) = index else {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: "Missing index parameter".to_string(),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
};
};
let index = index as usize;
if index >= current_body.len() {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: format!("Index {} out of bounds (body has {} elements)", index, current_body.len()),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
};
}
let element = ¤t_body[index];
let element_data = match element {
BodyElement::Heading { level, text } => json!({
"index": index,
"type": "heading",
"level": level,
"text": text
}),
BodyElement::Paragraph { text } => json!({
"index": index,
"type": "paragraph",
"text": text
}),
BodyElement::Code { language, content } => json!({
"index": index,
"type": "code",
"language": language,
"content": content
}),
BodyElement::List { ordered, items } => json!({
"index": index,
"type": "list",
"ordered": ordered,
"items": items
}),
BodyElement::Chart { chart_type, title, data, config } => json!({
"index": index,
"type": "chart",
"chartType": format!("{:?}", chart_type).to_lowercase(),
"title": title,
"data": data,
"config": config
}),
BodyElement::Image { src, alt, caption } => json!({
"index": index,
"type": "image",
"src": src,
"alt": alt,
"caption": caption
}),
BodyElement::Markdown { content } => json!({
"index": index,
"type": "markdown",
"content": content
}),
};
let type_str = match element {
BodyElement::Heading { .. } => "heading",
BodyElement::Paragraph { .. } => "paragraph",
BodyElement::Code { .. } => "code",
BodyElement::List { .. } => "list",
BodyElement::Chart { .. } => "chart",
BodyElement::Image { .. } => "image",
BodyElement::Markdown { .. } => "markdown",
};
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!("Element {} is a {}", index, type_str),
},
new_body: None,
new_summary: None,
parsed_data: Some(element_data),
version_request: None,
pending_questions: None,
}
}
fn execute_view_transcript(transcript: &[TranscriptEntry]) -> ToolExecutionResult {
if transcript.is_empty() {
return ToolExecutionResult {
result: ToolResult {
success: true,
message: "Transcript is empty".to_string(),
},
new_body: None,
new_summary: None,
parsed_data: Some(json!([])),
version_request: None,
pending_questions: None,
};
}
let entries: Vec<serde_json::Value> = transcript
.iter()
.enumerate()
.map(|(i, entry)| {
json!({
"index": i,
"speaker": entry.speaker,
"text": entry.text,
"start": entry.start,
"end": entry.end
})
})
.collect();
// Calculate duration from timestamps
let duration_info = if let (Some(first), Some(last)) = (transcript.first(), transcript.last()) {
let duration_secs = last.end - first.start;
let minutes = (duration_secs / 60.0).floor() as u32;
let seconds = (duration_secs % 60.0).round() as u32;
format!(" (duration: {}:{:02})", minutes, seconds)
} else {
String::new()
};
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!("Transcript has {} entries{}", transcript.len(), duration_info),
},
new_body: None,
new_summary: None,
parsed_data: Some(json!(entries)),
version_request: None,
pending_questions: None,
}
}
// =============================================================================
// Version History Tool Execution Functions
// =============================================================================
// These return version_request instead of performing the operation directly,
// because they require async database access which is handled in the chat handler.
fn execute_list_versions() -> ToolExecutionResult {
ToolExecutionResult {
result: ToolResult {
success: true,
message: "Listing versions...".to_string(),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: Some(VersionToolRequest::ListVersions),
pending_questions: None,
}
}
fn execute_read_version(call: &ToolCall) -> ToolExecutionResult {
let version = call.arguments.get("version").and_then(|v| v.as_i64());
let Some(version) = version else {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: "Missing version parameter".to_string(),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
};
};
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!("Reading version {}...", version),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: Some(VersionToolRequest::ReadVersion { version: version as i32 }),
pending_questions: None,
}
}
fn execute_restore_version(call: &ToolCall) -> ToolExecutionResult {
let target_version = call.arguments.get("target_version").and_then(|v| v.as_i64());
let reason = call
.arguments
.get("reason")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let Some(target_version) = target_version else {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: "Missing target_version parameter".to_string(),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: None,
pending_questions: None,
};
};
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!("Restoring to version {}...", target_version),
},
new_body: None,
new_summary: None,
parsed_data: None,
version_request: Some(VersionToolRequest::RestoreVersion {
target_version: target_version as i32,
reason,
}),
pending_questions: None,
}
}
/// Convert serde_json::Value to jaq_interpret::Val
fn json_to_jaq(value: &serde_json::Value) -> jaq_interpret::Val {
match value {
serde_json::Value::Null => jaq_interpret::Val::Null,
serde_json::Value::Bool(b) => jaq_interpret::Val::Bool(*b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
jaq_interpret::Val::Int(i as isize)
} else if let Some(f) = n.as_f64() {
jaq_interpret::Val::Float(f)
} else {
jaq_interpret::Val::Null
}
}
serde_json::Value::String(s) => jaq_interpret::Val::Str(s.clone().into()),
serde_json::Value::Array(arr) => {
jaq_interpret::Val::Arr(std::rc::Rc::new(arr.iter().map(json_to_jaq).collect()))
}
serde_json::Value::Object(obj) => {
let mut map: indexmap::IndexMap<std::rc::Rc<String>, jaq_interpret::Val, ahash::RandomState> =
indexmap::IndexMap::with_hasher(ahash::RandomState::new());
for (k, v) in obj {
map.insert(std::rc::Rc::new(k.clone()), json_to_jaq(v));
}
jaq_interpret::Val::Obj(std::rc::Rc::new(map))
}
}
}
/// Convert jaq_interpret::Val to serde_json::Value
fn jaq_to_json(value: &jaq_interpret::Val) -> serde_json::Value {
match value {
jaq_interpret::Val::Null => serde_json::Value::Null,
jaq_interpret::Val::Bool(b) => json!(*b),
jaq_interpret::Val::Int(i) => json!(*i),
jaq_interpret::Val::Float(f) => json!(*f),
jaq_interpret::Val::Num(n) => {
// Try to parse the number string
if let Ok(i) = n.parse::<i64>() {
json!(i)
} else if let Ok(f) = n.parse::<f64>() {
json!(f)
} else {
json!(n.as_ref())
}
}
jaq_interpret::Val::Str(s) => json!(s.as_ref()),
jaq_interpret::Val::Arr(arr) => {
json!(arr.iter().map(jaq_to_json).collect::<Vec<_>>())
}
jaq_interpret::Val::Obj(obj) => {
let mut map = serde_json::Map::new();
for (k, v) in obj.iter() {
map.insert((**k).clone(), jaq_to_json(v));
}
serde_json::Value::Object(map)
}
}
}