//! 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}; use crate::llm::templates; #[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> = 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"] }), }, // Template tools Tool { name: "suggest_templates".to_string(), description: "Get suggested file templates based on a contract phase. Returns templates with predefined structures appropriate for research, specify, plan, execute, or review phases. Use this to help users start documents with proper structure.".to_string(), parameters: json!({ "type": "object", "properties": { "phase": { "type": "string", "enum": ["research", "specify", "plan", "execute", "review"], "description": "The contract phase to get templates for. If not provided, returns all templates." } }, "required": [] }), }, Tool { name: "apply_template".to_string(), description: "Apply a template to the current file, replacing the body with the template structure. The template provides a starting structure that should be customized for the user's needs.".to_string(), parameters: json!({ "type": "object", "properties": { "template_id": { "type": "string", "description": "The template ID to apply (e.g., 'research-notes', 'requirements', 'architecture')" } }, "required": ["template_id"] }), }, ] }); /// 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 }, } /// 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, /// 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, } /// Result of executing a tool call with modified file state #[derive(Debug)] pub struct ToolExecutionResult { pub result: ToolResult, pub new_body: Option>, pub new_summary: Option, pub parsed_data: Option, /// Request for async version operations (handled by chat handler) pub version_request: Option, /// Questions to ask the user (pauses conversation until answered) pub pending_questions: Option>, } /// 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), // Template tools "suggest_templates" => execute_suggest_templates(call), "apply_template" => execute_apply_template(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 = 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 = 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 = 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 = 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::() { 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 = 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 = 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 = 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, } } // ============================================================================= // Template Tool Execution Functions // ============================================================================= fn execute_suggest_templates(call: &ToolCall) -> ToolExecutionResult { let phase = call.arguments.get("phase").and_then(|v| v.as_str()); let template_list = match phase { Some(p) => templates::templates_for_phase(p), None => templates::all_templates(), }; if template_list.is_empty() { return ToolExecutionResult { result: ToolResult { success: true, message: format!( "No templates available for phase: {}", phase.unwrap_or("(none)") ), }, new_body: None, new_summary: None, parsed_data: Some(json!([])), version_request: None, pending_questions: None, }; } // Convert templates to JSON (without the full body for display) let templates_json: Vec = template_list .iter() .map(|t| { json!({ "id": t.id, "name": t.name, "phase": t.phase, "description": t.description, "elementCount": t.suggested_body.len() }) }) .collect(); let phase_msg = phase .map(|p| format!(" for '{}' phase", p)) .unwrap_or_default(); ToolExecutionResult { result: ToolResult { success: true, message: format!( "Found {} template(s){}. Use apply_template with a template_id to apply one.", templates_json.len(), phase_msg ), }, new_body: None, new_summary: None, parsed_data: Some(json!(templates_json)), version_request: None, pending_questions: None, } } fn execute_apply_template(call: &ToolCall) -> ToolExecutionResult { let template_id = call .arguments .get("template_id") .and_then(|v| v.as_str()); let Some(template_id) = template_id else { return ToolExecutionResult { result: ToolResult { success: false, message: "Missing template_id parameter".to_string(), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; }; // Find the template let all = templates::all_templates(); let template = all.iter().find(|t| t.id == template_id); let Some(template) = template else { let available: Vec = all.iter().map(|t| t.id.clone()).collect(); return ToolExecutionResult { result: ToolResult { success: false, message: format!( "Template '{}' not found. Available: {}", template_id, available.join(", ") ), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; }; ToolExecutionResult { result: ToolResult { success: true, message: format!( "Applied template '{}' ({}) with {} elements. You can now customize the content.", template.name, template.phase, template.suggested_body.len() ), }, new_body: Some(template.suggested_body.clone()), new_summary: None, parsed_data: None, version_request: None, 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, 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::() { json!(i) } else if let Ok(f) = n.parse::() { 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::>()) } 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) } } }