diff --git a/.gitignore b/.gitignore index 64a5d7f2d..e740f0efc 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ dist-ssr .env.local .env.*.local +my_docs/* + # CI secrets for local testing (contains real tokens) scripts/ci-secrets.json scripts/ci-secrets.local.json diff --git a/src-tauri/src/commands/runtime.rs b/src-tauri/src/commands/runtime.rs index 47c0b6ecb..b3d4cdf24 100644 --- a/src-tauri/src/commands/runtime.rs +++ b/src-tauri/src/commands/runtime.rs @@ -10,7 +10,9 @@ use crate::models::socket::SocketState; use crate::runtime::socket_manager::SocketManager; use crate::utils::config::get_backend_url; use std::sync::Arc; +use std::collections::HashMap; use tauri::State; +use serde::{Deserialize, Serialize}; // Desktop-only imports #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -40,6 +42,32 @@ pub struct ToolResult { pub is_error: bool, } +// ============================================================================= +// ZeroClaw Format Compatibility Types +// ============================================================================= + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ZeroClawToolSchema { + #[serde(rename = "type")] + pub type_field: String, + pub function: ZeroClawFunction, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ZeroClawFunction { + pub name: String, + pub description: String, + pub parameters: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ZeroClawToolResult { + pub success: bool, + pub output: String, + pub error: Option, + pub execution_time: Option, +} + // ============================================================================= // Desktop implementations (V8 available) // ============================================================================= @@ -257,6 +285,201 @@ mod desktop { .to_string_lossy() .to_string()) } + + // ============================================================================= + // ZeroClaw Format Compatibility Commands + // ============================================================================= + + /// Generate ZeroClaw-compatible tool schemas from all available QuickJS tools. + /// This bridges the gap between QuickJS runtime and OpenAI function calling format. + #[tauri::command] + pub async fn runtime_get_tool_schemas( + engine: State<'_, Arc>, + ) -> Result, String> { + log::info!("🔧 [RUNTIME] Generating ZeroClaw-compatible tool schemas"); + + let tools = engine.all_tools(); + log::info!("🔧 [RUNTIME] Found {} tools from engine", tools.len()); + + let mut schemas = Vec::new(); + + for (skill_id, tool) in tools { + // Extract tool information from ToolDefinition struct + let description = if tool.description.is_empty() { + "No description available".to_string() + } else { + tool.description.clone() + }; + + let tool_name = format!("{}_{}", skill_id, tool.name); + log::info!("🔧 [RUNTIME] Processing tool: {}", tool_name); + + // Convert input schema to OpenAI-compatible format + let openai_parameters = convert_to_openai_schema(tool.input_schema)?; + + let schema = ZeroClawToolSchema { + type_field: "function".to_string(), + function: ZeroClawFunction { + name: tool_name, + description, + parameters: openai_parameters, + }, + }; + + schemas.push(schema); + } + + log::info!("🔧 [RUNTIME] Generated {} ZeroClaw tool schemas", schemas.len()); + + // Log tools that contain 'notion' or 'gmail' for debugging + let gmail_notion_tools: Vec = schemas.iter() + .map(|s| &s.function.name) + .filter(|name| name.to_lowercase().contains("gmail") || name.to_lowercase().contains("notion")) + .cloned() + .collect(); + log::info!("🔧 [RUNTIME] Gmail/Notion tools found: {:?}", gmail_notion_tools); + Ok(schemas) + } + + /// Execute a specific tool based on agent decision with enhanced validation. + /// This wraps the existing runtime_call_tool with ZeroClaw format compatibility. + #[tauri::command] + pub async fn runtime_execute_tool( + engine: State<'_, Arc>, + tool_id: String, + args: serde_json::Value, + ) -> Result { + let start_time = std::time::Instant::now(); + + log::info!("🔧 [RUNTIME] Executing ZeroClaw tool: {} with args: {}", tool_id, args); + + // Parse tool_id to get skill_id and tool_name (format: "skill_id_tool_name") + let (skill_id, tool_name) = match parse_tool_id(&tool_id) { + Ok((skill, tool)) => { + log::info!("🔧 [RUNTIME] Parsed tool_id: skill_id='{}', tool_name='{}'", skill, tool); + (skill, tool) + } + Err(e) => { + log::error!("🔧 [RUNTIME] Failed to parse tool_id '{}': {}", tool_id, e); + let execution_time = start_time.elapsed().as_millis() as u64; + return Ok(ZeroClawToolResult { + success: false, + output: String::new(), + error: Some(format!("Invalid tool ID format: {}", e)), + execution_time: Some(execution_time), + }); + } + }; + + // Log runtime state before execution + log::info!("🔧 [RUNTIME] Attempting to call tool '{}' on skill '{}'", tool_name, skill_id); + + // Get available skills for debugging + let skills = engine.list_skills(); + log::info!("🔧 [RUNTIME] Available skills: {:?}", skills.iter().map(|s| &s.skill_id).collect::>()); + + // Check if the specific skill exists + if let Some(skill) = skills.iter().find(|s| s.skill_id == skill_id) { + log::info!("🔧 [RUNTIME] Found skill '{}' with state: {:?}, tools: {:?}", + skill_id, skill.state, skill.tools.iter().map(|t| &t.name).collect::>()); + } else { + log::error!("🔧 [RUNTIME] Skill '{}' not found in runtime!", skill_id); + } + + // Execute the tool using the existing command + log::info!("🔧 [RUNTIME] Calling engine.call_tool('{}', '{}', {})", skill_id, tool_name, args); + + match engine.call_tool(&skill_id, &tool_name, args).await { + Ok(result) => { + let execution_time = start_time.elapsed().as_millis() as u64; + log::info!("🔧 [RUNTIME] Tool execution completed in {}ms, is_error: {}", execution_time, result.is_error); + + if result.is_error { + let error_message = result.content + .iter() + .filter(|c| matches!(c, crate::runtime::types::ToolContent::Text { .. })) + .map(|c| match c { + crate::runtime::types::ToolContent::Text { text } => text.as_str(), + _ => "", + }) + .collect::>() + .join("\n"); + + log::error!("🔧 [RUNTIME] Tool execution failed with error: {}", error_message); + + Ok(ZeroClawToolResult { + success: false, + output: String::new(), + error: Some(error_message), + execution_time: Some(execution_time), + }) + } else { + let output = result.content + .iter() + .map(|c| match c { + crate::runtime::types::ToolContent::Text { text } => text.clone(), + crate::runtime::types::ToolContent::Json { data } => { + serde_json::to_string(data).unwrap_or_else(|_| "Invalid JSON".to_string()) + } + }) + .collect::>() + .join("\n"); + + log::info!("ZeroClaw tool execution completed in {}ms", execution_time); + + Ok(ZeroClawToolResult { + success: true, + output, + error: None, + execution_time: Some(execution_time), + }) + } + } + Err(e) => { + let execution_time = start_time.elapsed().as_millis() as u64; + log::error!("🔧 [RUNTIME] Engine call_tool failed: {}", e); + + Ok(ZeroClawToolResult { + success: false, + output: String::new(), + error: Some(e), + execution_time: Some(execution_time), + }) + } + } + } + + // Helper function to parse tool_id format: "skill_id_tool_name" + pub fn parse_tool_id(tool_id: &str) -> Result<(String, String), String> { + // Find the first underscore to separate skill_id from tool_name + if let Some(underscore_pos) = tool_id.find('_') { + let skill_id = tool_id[..underscore_pos].to_string(); + let tool_name = tool_id[underscore_pos + 1..].to_string(); + + if skill_id.is_empty() || tool_name.is_empty() { + return Err("Tool ID must be in format 'skill_id_tool_name'".to_string()); + } + + Ok((skill_id, tool_name)) + } else { + Err("Tool ID must contain an underscore separator".to_string()) + } + } + + // Helper function to convert MCP schema to OpenAI function calling format + pub fn convert_to_openai_schema(mcp_schema: serde_json::Value) -> Result { + // If it's already in OpenAI format, return as-is + if mcp_schema.is_object() && mcp_schema.get("type").is_some() { + return Ok(mcp_schema); + } + + // Convert basic MCP schema to OpenAI format + Ok(serde_json::json!({ + "type": "object", + "properties": mcp_schema.get("properties").cloned().unwrap_or_else(|| serde_json::json!({})), + "required": mcp_schema.get("required").cloned().unwrap_or_else(|| serde_json::json!([])) + })) + } } // ============================================================================= @@ -379,6 +602,19 @@ mod mobile { pub async fn runtime_skill_data_dir(_skill_id: String) -> Result { Err(MOBILE_ERROR.to_string()) } + + #[tauri::command] + pub async fn runtime_get_tool_schemas() -> Result, String> { + Ok(vec![]) + } + + #[tauri::command] + pub async fn runtime_execute_tool( + _tool_id: String, + _args: serde_json::Value, + ) -> Result { + Err(MOBILE_ERROR.to_string()) + } } // ============================================================================= @@ -432,3 +668,256 @@ pub use desktop::*; #[cfg(any(target_os = "android", target_os = "ios"))] pub use mobile::*; + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + mod desktop_tests { + use super::*; + use crate::runtime::qjs_engine::RuntimeEngine; + + #[tokio::test] + async fn test_runtime_get_tool_schemas_format() { + // Note: This test requires a properly initialized RuntimeEngine + // In a real test environment, you would mock the engine or use a test instance + + // For now, we'll test the struct format and serialization + let schema = ZeroClawToolSchema { + type_field: "function".to_string(), + function: ZeroClawFunction { + name: "test_tool".to_string(), + description: "A test tool".to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Test message" + } + }, + "required": ["message"] + }) + } + }; + + // Test serialization + let json = serde_json::to_string(&schema).expect("Should serialize to JSON"); + assert!(json.contains("function")); + assert!(json.contains("test_tool")); + assert!(json.contains("A test tool")); + + // Test deserialization + let deserialized: ZeroClawToolSchema = serde_json::from_str(&json) + .expect("Should deserialize from JSON"); + assert_eq!(deserialized.type_field, "function"); + assert_eq!(deserialized.function.name, "test_tool"); + } + + #[tokio::test] + async fn test_zeroclaw_tool_result_format() { + let result = ZeroClawToolResult { + success: true, + output: "Test output".to_string(), + error: None, + execution_time: Some(1500) + }; + + // Test serialization + let json = serde_json::to_string(&result).expect("Should serialize to JSON"); + assert!(json.contains("true")); + assert!(json.contains("Test output")); + assert!(json.contains("1500")); + + // Test error case + let error_result = ZeroClawToolResult { + success: false, + output: String::new(), + error: Some("Tool not found".to_string()), + execution_time: Some(100) + }; + + let error_json = serde_json::to_string(&error_result).expect("Should serialize error"); + assert!(json.contains("false") || error_json.contains("false")); + assert!(error_json.contains("Tool not found")); + } + + #[test] + fn test_parse_tool_id_valid_formats() { + // Test valid tool ID formats + let (skill_id, tool_name) = desktop::parse_tool_id("github_list_issues") + .expect("Should parse valid tool ID"); + assert_eq!(skill_id, "github"); + assert_eq!(tool_name, "list_issues"); + + let (skill_id, tool_name) = desktop::parse_tool_id("notion_create_page") + .expect("Should parse valid tool ID"); + assert_eq!(skill_id, "notion"); + assert_eq!(tool_name, "create_page"); + + // Test complex skill names (first underscore separates skill_id from tool_name) + let (skill_id, tool_name) = desktop::parse_tool_id("complex_skill_name_tool_function") + .expect("Should parse complex tool ID"); + assert_eq!(skill_id, "complex"); + assert_eq!(tool_name, "skill_name_tool_function"); + } + + #[test] + fn test_parse_tool_id_invalid_formats() { + // Test invalid formats + assert!(desktop::parse_tool_id("nounderscore").is_err(), "Should fail for no underscore"); + assert!(desktop::parse_tool_id("_empty_skill").is_err(), "Should fail for empty skill ID"); + assert!(desktop::parse_tool_id("empty_tool_").is_err(), "Should fail for empty tool name"); + assert!(desktop::parse_tool_id("").is_err(), "Should fail for empty string"); + } + + #[test] + fn test_convert_to_openai_schema() { + // Test MCP schema to OpenAI conversion + let mcp_schema = serde_json::json!({ + "properties": { + "owner": {"type": "string"}, + "repo": {"type": "string"} + }, + "required": ["owner", "repo"] + }); + + let openai_schema = desktop::convert_to_openai_schema(mcp_schema) + .expect("Should convert MCP to OpenAI schema"); + + assert_eq!(openai_schema["type"], "object"); + assert!(openai_schema["properties"].is_object()); + assert!(openai_schema["required"].is_array()); + + // Test already OpenAI format (should pass through) + let existing_openai = serde_json::json!({ + "type": "object", + "properties": {"test": {"type": "string"}}, + "required": ["test"] + }); + + let result = desktop::convert_to_openai_schema(existing_openai.clone()) + .expect("Should handle existing OpenAI format"); + assert_eq!(result, existing_openai); + } + + #[test] + fn test_zeroclaw_format_compliance() { + // Test that our ZeroClaw format matches expected OpenAI structure + let schema = ZeroClawToolSchema { + type_field: "function".to_string(), + function: ZeroClawFunction { + name: "github_list_issues".to_string(), + description: "List GitHub issues for a repository".to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "owner": {"type": "string", "description": "Repository owner"}, + "repo": {"type": "string", "description": "Repository name"}, + "state": {"type": "string", "enum": ["open", "closed", "all"], "default": "open"} + }, + "required": ["owner", "repo"] + }) + } + }; + + // Serialize and check format + let json = serde_json::to_value(&schema).expect("Should serialize"); + + // Check OpenAI compatibility + assert_eq!(json["type"], "function"); + assert!(json["function"].is_object()); + assert!(json["function"]["name"].is_string()); + assert!(json["function"]["description"].is_string()); + assert!(json["function"]["parameters"].is_object()); + + // Check parameter schema + let params = &json["function"]["parameters"]; + assert_eq!(params["type"], "object"); + assert!(params["properties"].is_object()); + assert!(params["required"].is_array()); + } + } + + #[cfg(any(target_os = "android", target_os = "ios"))] + mod mobile_tests { + use super::*; + + #[tokio::test] + async fn test_mobile_stub_runtime_get_tool_schemas() { + let result = mobile::runtime_get_tool_schemas().await; + + // Mobile should return empty list with helpful error + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not available on mobile")); + } + + #[tokio::test] + async fn test_mobile_stub_runtime_execute_tool() { + let result = mobile::runtime_execute_tool( + "test_tool".to_string(), + "{}".to_string() + ).await; + + // Mobile should return error + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not available on mobile")); + } + } + + #[test] + fn test_zeroclaw_struct_defaults() { + // Test that ZeroClaw structs can be created with serde_json + let tool_schema: ZeroClawToolSchema = serde_json::from_value(serde_json::json!({ + "type": "function", + "function": { + "name": "test", + "description": "test", + "parameters": {} + } + })).expect("Should deserialize from JSON"); + + assert_eq!(tool_schema.type_field, "function"); + assert_eq!(tool_schema.function.name, "test"); + + // Test tool result + let tool_result: ZeroClawToolResult = serde_json::from_value(serde_json::json!({ + "success": true, + "output": "result", + "error": null, + "execution_time": 1000 + })).expect("Should deserialize tool result"); + + assert!(tool_result.success); + assert_eq!(tool_result.output, "result"); + assert_eq!(tool_result.execution_time, Some(1000)); + } + + #[test] + fn test_error_handling_structures() { + // Test that error scenarios can be properly serialized + let error_result = ZeroClawToolResult { + success: false, + output: String::new(), + error: Some("Connection timeout".to_string()), + execution_time: Some(30000) // 30 second timeout + }; + + let json = serde_json::to_string(&error_result).expect("Should serialize error"); + assert!(json.contains("false")); + assert!(json.contains("Connection timeout")); + assert!(json.contains("30000")); + + // Test deserialization back + let parsed: ZeroClawToolResult = serde_json::from_str(&json) + .expect("Should parse error result"); + assert!(!parsed.success); + assert_eq!(parsed.error, Some("Connection timeout".to_string())); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0944fe168..99697ca9d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -142,6 +142,8 @@ macro_rules! common_handlers { runtime_get_skill_state, runtime_call_tool, runtime_all_tools, + runtime_get_tool_schemas, + runtime_execute_tool, runtime_broadcast_event, // Runtime enable/disable + KV commands runtime_enable_skill, @@ -751,6 +753,8 @@ pub fn run() { runtime_get_skill_state, runtime_call_tool, runtime_all_tools, + runtime_get_tool_schemas, + runtime_execute_tool, runtime_broadcast_event, // Runtime enable/disable + KV commands runtime_enable_skill, @@ -870,6 +874,8 @@ pub fn run() { runtime_get_skill_state, runtime_call_tool, runtime_all_tools, + runtime_get_tool_schemas, + runtime_execute_tool, runtime_broadcast_event, // Runtime enable/disable + KV commands runtime_enable_skill, diff --git a/src/pages/Conversations.tsx b/src/pages/Conversations.tsx index 656900e26..070505376 100644 --- a/src/pages/Conversations.tsx +++ b/src/pages/Conversations.tsx @@ -120,7 +120,10 @@ const Conversations = () => { const notionPages = useAppSelector(state => state.notion.pages); const notionSummaries = useAppSelector(state => state.notion.summaries); const notionWorkspaceName = useAppSelector( - state => (state.skills.skillStates?.notion as Record | undefined)?.workspaceName as string | null ?? null + state => + ((state.skills.skillStates?.notion as Record | undefined)?.workspaceName as + | string + | null) ?? null ); const [showPurgeConfirm, setShowPurgeConfirm] = useState(false); @@ -347,7 +350,7 @@ const Conversations = () => { const chatMessages: ChatMessage[] = [ ...historySnapshot.map(m => ({ - role: (m.sender === 'user' ? 'user' : 'assistant') as 'user' | 'assistant', + role: (m.sender === 'user' ? 'user' : 'assistant') as ChatMessage['role'], content: m.content, })), { role: 'user' as const, content: processedUserContent }, diff --git a/src/services/__tests__/agentLoop.test.ts b/src/services/__tests__/agentLoop.test.ts new file mode 100644 index 000000000..663fee8d1 --- /dev/null +++ b/src/services/__tests__/agentLoop.test.ts @@ -0,0 +1,541 @@ +import { describe, test, expect, beforeEach, vi, type Mock } from 'vitest'; +import { AgentLoopService } from '../agentLoop'; +import { AgentToolRegistry } from '../agentToolRegistry'; +import { apiClient } from '../apiClient'; +import type { + AgentToolSchema, + AgentExecutionResult, + AgentToolExecution, + AgentExecutionOptions +} from '../../types/agent'; + +// Mock dependencies +vi.mock('../agentToolRegistry'); +vi.mock('../apiClient'); + +describe('AgentLoopService', () => { + let service: AgentLoopService; + const mockToolRegistry = AgentToolRegistry as vi.MockedClass; + const mockApiClient = apiClient as { post: Mock }; + + const mockToolSchemas: AgentToolSchema[] = [ + { + type: "function", + function: { + name: "github_list_issues", + description: "List GitHub issues for a repository", + parameters: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" } + }, + required: ["owner", "repo"] + } + } + }, + { + type: "function", + function: { + name: "notion_create_page", + description: "Create a new Notion page", + parameters: { + type: "object", + properties: { + title: { type: "string", description: "Page title" }, + content: { type: "string", description: "Page content" } + }, + required: ["title"] + } + } + } + ]; + + beforeEach(() => { + service = AgentLoopService.getInstance(); + vi.clearAllMocks(); + + // Setup default mock implementations + const mockRegistryInstance = { + loadToolSchemas: vi.fn(), + executeTool: vi.fn() + }; + + mockToolRegistry.getInstance.mockReturnValue(mockRegistryInstance as any); + mockRegistryInstance.loadToolSchemas.mockResolvedValue(mockToolSchemas); + }); + + describe('executeTask', () => { + test('should execute simple task without tool calls', async () => { + const mockResponse = { + choices: [{ + message: { + role: 'assistant' as const, + content: 'Hello! How can I help you today?', + tool_calls: undefined + }, + finish_reason: 'stop' as const + }], + usage: { + prompt_tokens: 20, + completion_tokens: 10, + total_tokens: 30 + } + }; + + mockApiClient.post.mockResolvedValue({ data: mockResponse }); + + const result = await service.executeTask( + 'Hello', + 'conv_123', + { maxIterations: 5, timeoutMs: 30000 } + ); + + expect(result.status).toBe('completed'); + expect(result.finalResponse).toBe('Hello! How can I help you today?'); + expect(result.iterations).toBe(1); + expect(result.toolExecutions).toHaveLength(0); + expect(result.executionTime).toBeGreaterThan(0); + + // Verify API call format + expect(mockApiClient.post).toHaveBeenCalledWith( + '/api/v1/conversations/conv_123/messages', + expect.objectContaining({ + model: expect.any(String), + messages: expect.arrayContaining([ + expect.objectContaining({ + role: 'user', + content: 'Hello' + }) + ]), + tools: mockToolSchemas, + tool_choice: 'auto' + }) + ); + }); + + test('should execute task with single tool call', async () => { + const mockToolCallResponse = { + choices: [{ + message: { + role: 'assistant' as const, + content: null, + tool_calls: [{ + id: 'call_123', + type: 'function' as const, + function: { + name: 'github_list_issues', + arguments: '{"owner":"user","repo":"test"}' + } + }] + }, + finish_reason: 'tool_calls' as const + }] + }; + + const mockFinalResponse = { + choices: [{ + message: { + role: 'assistant' as const, + content: 'I found 3 open issues in your repository.', + tool_calls: undefined + }, + finish_reason: 'stop' as const + }], + usage: { + prompt_tokens: 50, + completion_tokens: 20, + total_tokens: 70 + } + }; + + const mockToolExecution: AgentToolExecution = { + id: 'exec_123', + toolName: 'list_issues', + skillId: 'github', + arguments: '{"owner":"user","repo":"test"}', + status: 'success', + startTime: Date.now() - 1500, + endTime: Date.now(), + executionTimeMs: 1500, + result: '{"issues":[{"title":"Bug fix","number":1}]}' + }; + + // Setup mocks + const mockRegistryInstance = mockToolRegistry.getInstance(); + mockRegistryInstance.executeTool.mockResolvedValue(mockToolExecution); + + mockApiClient.post + .mockResolvedValueOnce({ data: mockToolCallResponse }) + .mockResolvedValueOnce({ data: mockFinalResponse }); + + const result = await service.executeTask( + 'Show me GitHub issues', + 'conv_123' + ); + + expect(result.status).toBe('completed'); + expect(result.finalResponse).toBe('I found 3 open issues in your repository.'); + expect(result.iterations).toBe(2); + expect(result.toolExecutions).toHaveLength(1); + expect(result.toolExecutions[0].toolName).toBe('list_issues'); + expect(result.toolExecutions[0].status).toBe('success'); + + // Verify tool execution was called with correct parameters + expect(mockRegistryInstance.executeTool).toHaveBeenCalledWith( + 'github', + 'list_issues', + '{"owner":"user","repo":"test"}' + ); + }); + + test('should handle multiple tool calls in sequence', async () => { + const mockFirstToolCallResponse = { + choices: [{ + message: { + role: 'assistant' as const, + content: null, + tool_calls: [{ + id: 'call_1', + type: 'function' as const, + function: { + name: 'github_list_issues', + arguments: '{"owner":"user","repo":"test"}' + } + }] + }, + finish_reason: 'tool_calls' as const + }] + }; + + const mockSecondToolCallResponse = { + choices: [{ + message: { + role: 'assistant' as const, + content: null, + tool_calls: [{ + id: 'call_2', + type: 'function' as const, + function: { + name: 'notion_create_page', + arguments: '{"title":"Issues Summary"}' + } + }] + }, + finish_reason: 'tool_calls' as const + }] + }; + + const mockFinalResponse = { + choices: [{ + message: { + role: 'assistant' as const, + content: 'I created a summary page with your GitHub issues.', + tool_calls: undefined + }, + finish_reason: 'stop' as const + }] + }; + + const mockToolExecution1: AgentToolExecution = { + id: 'exec_1', + toolName: 'list_issues', + skillId: 'github', + arguments: '{"owner":"user","repo":"test"}', + status: 'success', + startTime: Date.now() - 2000, + endTime: Date.now() - 1000, + executionTimeMs: 1000, + result: '{"issues":[{"title":"Bug fix","number":1}]}' + }; + + const mockToolExecution2: AgentToolExecution = { + id: 'exec_2', + toolName: 'create_page', + skillId: 'notion', + arguments: '{"title":"Issues Summary"}', + status: 'success', + startTime: Date.now() - 800, + endTime: Date.now(), + executionTimeMs: 800, + result: '{"page_id":"page_123"}' + }; + + // Setup mocks + const mockRegistryInstance = mockToolRegistry.getInstance(); + mockRegistryInstance.executeTool + .mockResolvedValueOnce(mockToolExecution1) + .mockResolvedValueOnce(mockToolExecution2); + + mockApiClient.post + .mockResolvedValueOnce({ data: mockFirstToolCallResponse }) + .mockResolvedValueOnce({ data: mockSecondToolCallResponse }) + .mockResolvedValueOnce({ data: mockFinalResponse }); + + const result = await service.executeTask( + 'Get GitHub issues and create a summary page', + 'conv_123', + { maxIterations: 5 } + ); + + expect(result.status).toBe('completed'); + expect(result.iterations).toBe(3); + expect(result.toolExecutions).toHaveLength(2); + expect(result.toolExecutions[0].skillId).toBe('github'); + expect(result.toolExecutions[1].skillId).toBe('notion'); + }); + + test('should handle tool execution timeout', async () => { + const result = await service.executeTask( + 'Test timeout', + 'conv_123', + { maxIterations: 1, timeoutMs: 100 } // Very short timeout + ); + + // The timeout logic depends on how it's implemented in the actual service + // This test may need adjustment based on the actual implementation + expect(result.status).toBe('timeout'); + expect(result.error).toContain('timeout'); + }); + + test('should respect maximum iterations limit', async () => { + const mockToolCallResponse = { + choices: [{ + message: { + role: 'assistant' as const, + tool_calls: [{ + id: 'call_1', + type: 'function' as const, + function: { name: 'github_list_issues', arguments: '{}' } + }] + }, + finish_reason: 'tool_calls' as const + }] + }; + + // Mock to always return tool calls (infinite loop scenario) + mockApiClient.post.mockResolvedValue({ data: mockToolCallResponse }); + + const mockToolExecution: AgentToolExecution = { + id: 'exec_1', + toolName: 'list_issues', + skillId: 'github', + arguments: '{}', + status: 'success', + startTime: Date.now() - 100, + endTime: Date.now(), + executionTimeMs: 100, + result: '{}' + }; + + const mockRegistryInstance = mockToolRegistry.getInstance(); + mockRegistryInstance.executeTool.mockResolvedValue(mockToolExecution); + + const result = await service.executeTask( + 'Infinite loop test', + 'conv_123', + { maxIterations: 2, timeoutMs: 10000 } + ); + + expect(result.status).toBe('max_iterations'); + expect(result.iterations).toBe(2); + expect(result.error).toContain('maximum iterations'); + }); + + test('should handle tool execution error gracefully', async () => { + const mockToolCallResponse = { + choices: [{ + message: { + role: 'assistant' as const, + tool_calls: [{ + id: 'call_1', + type: 'function' as const, + function: { + name: 'invalid_tool', + arguments: '{}' + } + }] + }, + finish_reason: 'tool_calls' as const + }] + }; + + const mockErrorResponse = { + choices: [{ + message: { + role: 'assistant' as const, + content: 'I encountered an error while executing the tool.', + tool_calls: undefined + }, + finish_reason: 'stop' as const + }] + }; + + const mockToolExecution: AgentToolExecution = { + id: 'exec_1', + toolName: 'invalid_tool', + skillId: 'unknown', + arguments: '{}', + status: 'error', + startTime: Date.now() - 100, + endTime: Date.now(), + executionTimeMs: 100, + errorMessage: 'Tool not found' + }; + + const mockRegistryInstance = mockToolRegistry.getInstance(); + mockRegistryInstance.executeTool.mockResolvedValue(mockToolExecution); + + mockApiClient.post + .mockResolvedValueOnce({ data: mockToolCallResponse }) + .mockResolvedValueOnce({ data: mockErrorResponse }); + + const result = await service.executeTask( + 'Test error handling', + 'conv_123' + ); + + expect(result.status).toBe('completed'); + expect(result.toolExecutions).toHaveLength(1); + expect(result.toolExecutions[0].status).toBe('error'); + expect(result.toolExecutions[0].errorMessage).toBe('Tool not found'); + }); + + test('should handle API client errors', async () => { + mockApiClient.post.mockRejectedValue(new Error('Network error')); + + const result = await service.executeTask( + 'Test API error', + 'conv_123' + ); + + expect(result.status).toBe('error'); + expect(result.error).toContain('Network error'); + expect(result.iterations).toBe(0); + expect(result.toolExecutions).toHaveLength(0); + }); + + test('should parse tool name from function name correctly', async () => { + const mockToolCallResponse = { + choices: [{ + message: { + role: 'assistant' as const, + tool_calls: [{ + id: 'call_1', + type: 'function' as const, + function: { + name: 'github_list_issues', // Should parse to skillId=github, toolName=list_issues + arguments: '{"owner":"user","repo":"test"}' + } + }] + }, + finish_reason: 'tool_calls' as const + }] + }; + + const mockFinalResponse = { + choices: [{ + message: { + role: 'assistant' as const, + content: 'Done', + tool_calls: undefined + }, + finish_reason: 'stop' as const + }] + }; + + const mockToolExecution: AgentToolExecution = { + id: 'exec_1', + toolName: 'list_issues', + skillId: 'github', + arguments: '{"owner":"user","repo":"test"}', + status: 'success', + startTime: Date.now() - 100, + endTime: Date.now(), + executionTimeMs: 100, + result: '{}' + }; + + const mockRegistryInstance = mockToolRegistry.getInstance(); + mockRegistryInstance.executeTool.mockResolvedValue(mockToolExecution); + + mockApiClient.post + .mockResolvedValueOnce({ data: mockToolCallResponse }) + .mockResolvedValueOnce({ data: mockFinalResponse }); + + await service.executeTask('Test tool parsing', 'conv_123'); + + // Verify correct parsing of skill ID and tool name + expect(mockRegistryInstance.executeTool).toHaveBeenCalledWith( + 'github', + 'list_issues', + '{"owner":"user","repo":"test"}' + ); + }); + }); + + describe('singleton behavior', () => { + test('should return the same instance', () => { + const instance1 = AgentLoopService.getInstance(); + const instance2 = AgentLoopService.getInstance(); + + expect(instance1).toBe(instance2); + }); + }); + + describe('task execution options', () => { + test('should use default options when none provided', async () => { + const mockResponse = { + choices: [{ + message: { + role: 'assistant' as const, + content: 'Test response' + }, + finish_reason: 'stop' as const + }] + }; + + mockApiClient.post.mockResolvedValue({ data: mockResponse }); + + const result = await service.executeTask('Test', 'conv_123'); + + // Should complete successfully with defaults + expect(result.status).toBe('completed'); + expect(result.executionTime).toBeGreaterThan(0); + }); + + test('should respect custom execution options', async () => { + const mockResponse = { + choices: [{ + message: { + role: 'assistant' as const, + content: 'Test response' + }, + finish_reason: 'stop' as const + }] + }; + + mockApiClient.post.mockResolvedValue({ data: mockResponse }); + + const customOptions: AgentExecutionOptions = { + maxIterations: 3, + timeoutMs: 5000, + model: 'gpt-3.5-turbo', + temperature: 0.7 + }; + + const result = await service.executeTask('Test', 'conv_123', customOptions); + + expect(result.status).toBe('completed'); + + // Verify custom options were passed to API + expect(mockApiClient.post).toHaveBeenCalledWith( + '/api/v1/conversations/conv_123/messages', + expect.objectContaining({ + model: 'gpt-3.5-turbo', + temperature: 0.7 + }) + ); + }); + }); +}); \ No newline at end of file diff --git a/src/services/__tests__/agentToolRegistry.test.ts b/src/services/__tests__/agentToolRegistry.test.ts new file mode 100644 index 000000000..3d3251a4e --- /dev/null +++ b/src/services/__tests__/agentToolRegistry.test.ts @@ -0,0 +1,367 @@ +import { describe, test, expect, beforeEach, vi, type Mock } from 'vitest'; +import { AgentToolRegistry } from '../agentToolRegistry'; +import { invoke } from '@tauri-apps/api/core'; + +// Mock Tauri invoke +vi.mock('@tauri-apps/api/core'); + +describe('AgentToolRegistry', () => { + let service: AgentToolRegistry; + const mockInvoke = invoke as Mock; + + beforeEach(() => { + service = AgentToolRegistry.getInstance(); + vi.clearAllMocks(); + service.clearCache(); // Clear cache between tests + }); + + describe('loadToolSchemas', () => { + test('should load tool schemas from Tauri using ZeroClaw format', async () => { + const mockSchemas = [ + { + type: "function", + function: { + name: "github_list_issues", + description: "List GitHub issues for a repository", + parameters: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + repo: { type: "string", description: "Repository name" } + }, + required: ["owner", "repo"] + } + } + }, + { + type: "function", + function: { + name: "notion_create_page", + description: "Create a new Notion page", + parameters: { + type: "object", + properties: { + title: { type: "string", description: "Page title" }, + content: { type: "string", description: "Page content" } + }, + required: ["title"] + } + } + } + ]; + + mockInvoke.mockResolvedValue(mockSchemas); + + const schemas = await service.loadToolSchemas(); + + expect(schemas).toHaveLength(2); + expect(schemas[0].function.name).toBe("github_list_issues"); + expect(schemas[1].function.name).toBe("notion_create_page"); + expect(mockInvoke).toHaveBeenCalledWith('runtime_get_tool_schemas'); + }); + + test('should cache tool schemas to avoid repeated calls', async () => { + const mockSchemas = [ + { + type: "function", + function: { + name: "test_tool", + description: "Test tool", + parameters: { type: "object", properties: {} } + } + } + ]; + + mockInvoke.mockResolvedValue(mockSchemas); + + // First call + const schemas1 = await service.loadToolSchemas(); + // Second call + const schemas2 = await service.loadToolSchemas(); + + expect(schemas1).toEqual(schemas2); + // Should only invoke Tauri once due to caching (TTL = 5 minutes) + expect(mockInvoke).toHaveBeenCalledTimes(1); + }); + + test('should force reload when requested', async () => { + const mockSchemas = [ + { + type: "function", + function: { + name: "test_tool", + description: "Test tool", + parameters: { type: "object", properties: {} } + } + } + ]; + + mockInvoke.mockResolvedValue(mockSchemas); + + // First call + await service.loadToolSchemas(); + // Force reload + await service.loadToolSchemas(true); + + // Should invoke Tauri twice + expect(mockInvoke).toHaveBeenCalledTimes(2); + }); + + test('should handle empty tool schema response', async () => { + mockInvoke.mockResolvedValue([]); + + const schemas = await service.loadToolSchemas(); + + expect(schemas).toHaveLength(0); + expect(mockInvoke).toHaveBeenCalledWith('runtime_get_tool_schemas'); + }); + + test('should throw error when Tauri command fails', async () => { + const errorMessage = 'Failed to load tool schemas'; + mockInvoke.mockRejectedValue(new Error(errorMessage)); + + await expect(service.loadToolSchemas()).rejects.toThrow(`Failed to load tool schemas: Error: ${errorMessage}`); + }); + }); + + describe('executeTool', () => { + test('should execute tool using ZeroClaw format with success', async () => { + const mockResult = { + success: true, + output: '{"issues": [{"title": "Bug fix", "number": 1}]}', + error: null, + execution_time: 1500 + }; + + mockInvoke.mockResolvedValue(mockResult); + + const result = await service.executeTool( + 'github', + 'list_issues', + '{"owner":"user","repo":"test"}' + ); + + expect(result.status).toBe('success'); + expect(result.result).toBe(mockResult.output); + expect(result.executionTimeMs).toBe(1500); + expect(result.toolName).toBe('list_issues'); + expect(result.skillId).toBe('github'); + + // Verify correct tool_id format and arguments + expect(mockInvoke).toHaveBeenCalledWith('runtime_execute_tool', { + toolId: 'github_list_issues', + arguments: '{"owner":"user","repo":"test"}' + }); + }); + + test('should handle tool execution failure', async () => { + const mockResult = { + success: false, + output: '', + error: 'Tool not found: invalid_tool', + execution_time: 100 + }; + + mockInvoke.mockResolvedValue(mockResult); + + const result = await service.executeTool( + 'invalid', + 'tool', + '{}' + ); + + expect(result.status).toBe('error'); + expect(result.errorMessage).toBe('Tool not found: invalid_tool'); + expect(result.result).toBe('Tool not found: invalid_tool'); + expect(result.executionTimeMs).toBe(100); + }); + + test('should handle tool execution without execution_time', async () => { + const mockResult = { + success: true, + output: 'Success', + error: null + // No execution_time provided + }; + + mockInvoke.mockResolvedValue(mockResult); + + const startTime = Date.now(); + const result = await service.executeTool('test', 'tool', '{}'); + const endTime = Date.now(); + + expect(result.status).toBe('success'); + expect(result.executionTimeMs).toBeGreaterThan(0); + expect(result.executionTimeMs).toBeLessThanOrEqual(endTime - startTime + 10); // Allow small margin + }); + + test('should handle Tauri invoke exception', async () => { + const errorMessage = 'Network error'; + mockInvoke.mockRejectedValue(new Error(errorMessage)); + + const result = await service.executeTool('test', 'tool', '{}'); + + expect(result.status).toBe('error'); + expect(result.errorMessage).toBe(errorMessage); + expect(result.result).toBe(errorMessage); + expect(result.executionTimeMs).toBeGreaterThan(0); + }); + + test('should generate unique execution IDs', async () => { + const mockResult = { + success: true, + output: 'test', + error: null, + execution_time: 100 + }; + + mockInvoke.mockResolvedValue(mockResult); + + const result1 = await service.executeTool('test', 'tool1', '{}'); + const result2 = await service.executeTool('test', 'tool2', '{}'); + + expect(result1.id).not.toBe(result2.id); + expect(result1.id).toMatch(/^exec_\d+_[a-z0-9]+$/); + expect(result2.id).toMatch(/^exec_\d+_[a-z0-9]+$/); + }); + }); + + describe('tool management methods', () => { + beforeEach(async () => { + const mockSchemas = [ + { + type: "function", + function: { + name: "github_list_issues", + description: "List GitHub issues", + parameters: { type: "object", properties: {} } + } + }, + { + type: "function", + function: { + name: "github_create_issue", + description: "Create GitHub issue", + parameters: { type: "object", properties: {} } + } + }, + { + type: "function", + function: { + name: "notion_create_page", + description: "Create Notion page", + parameters: { type: "object", properties: {} } + } + } + ]; + + mockInvoke.mockResolvedValue(mockSchemas); + await service.loadToolSchemas(); + }); + + test('getToolByName should find tool by name', () => { + const tool = service.getToolByName('github_list_issues'); + + expect(tool).toBeDefined(); + expect(tool?.function.name).toBe('github_list_issues'); + expect(tool?.function.description).toBe('List GitHub issues'); + }); + + test('getToolByName should return undefined for non-existent tool', () => { + const tool = service.getToolByName('non_existent_tool'); + + expect(tool).toBeUndefined(); + }); + + test('getAllTools should return all loaded tools', () => { + const tools = service.getAllTools(); + + expect(tools).toHaveLength(3); + expect(tools.map(t => t.function.name)).toEqual([ + 'github_list_issues', + 'github_create_issue', + 'notion_create_page' + ]); + }); + + test('getToolsBySkill should organize tools by skill ID', () => { + const toolsBySkill = service.getToolsBySkill(); + + expect(toolsBySkill).toHaveProperty('github'); + expect(toolsBySkill).toHaveProperty('notion'); + expect(toolsBySkill.github).toHaveLength(2); + expect(toolsBySkill.notion).toHaveLength(1); + + expect(toolsBySkill.github.map(t => t.function.name)).toEqual([ + 'github_list_issues', + 'github_create_issue' + ]); + expect(toolsBySkill.notion[0].function.name).toBe('notion_create_page'); + }); + + test('getToolStats should return accurate statistics', () => { + const stats = service.getToolStats(); + + expect(stats.totalTools).toBe(3); + expect(stats.skillCount).toBe(2); + expect(stats.categories).toHaveProperty('GitHub', 2); + expect(stats.categories).toHaveProperty('Notion', 1); + }); + }); + + describe('helper methods', () => { + test('extractSkillIdFromToolName should parse skill ID correctly', () => { + // Use reflection to access private method + const extractMethod = (service as any).extractSkillIdFromToolName.bind(service); + + expect(extractMethod('github_list_issues')).toBe('github'); + expect(extractMethod('notion_create_page')).toBe('notion'); + expect(extractMethod('complex_skill_name_tool_name')).toBe('complex_skill_name_tool'); + expect(extractMethod('invalid_format')).toBe('invalid'); + expect(extractMethod('no_underscore')).toBeNull(); + }); + + test('extractCategoryFromSkillId should categorize skills correctly', () => { + // Use reflection to access private method + const extractMethod = (service as any).extractCategoryFromSkillId.bind(service); + + expect(extractMethod('github')).toBe('GitHub'); + expect(extractMethod('github_enterprise')).toBe('GitHub'); + expect(extractMethod('notion')).toBe('Notion'); + expect(extractMethod('telegram')).toBe('Telegram'); + expect(extractMethod('gmail')).toBe('Email'); + expect(extractMethod('calendar')).toBe('Calendar'); + expect(extractMethod('slack')).toBe('Slack'); + expect(extractMethod('crypto_wallet')).toBe('Crypto'); + expect(extractMethod('unknown_skill')).toBe('Other'); + }); + }); + + describe('clearCache', () => { + test('should clear cached tool schemas', async () => { + const mockSchemas = [ + { + type: "function", + function: { + name: "test_tool", + description: "Test tool", + parameters: { type: "object", properties: {} } + } + } + ]; + + mockInvoke.mockResolvedValue(mockSchemas); + + // Load schemas + await service.loadToolSchemas(); + expect(mockInvoke).toHaveBeenCalledTimes(1); + + // Clear cache + service.clearCache(); + + // Load again - should call Tauri again + await service.loadToolSchemas(); + expect(mockInvoke).toHaveBeenCalledTimes(2); + }); + }); +}); \ No newline at end of file diff --git a/src/services/agentToolRegistry.ts b/src/services/agentToolRegistry.ts new file mode 100644 index 000000000..edd803c1e --- /dev/null +++ b/src/services/agentToolRegistry.ts @@ -0,0 +1,275 @@ +/** + * Agent Tool Registry Service + * + * Builds on top of the existing skill system to provide agent-compatible + * tool discovery and execution. Uses ZeroClaw format compatibility commands: + * - runtime_get_tool_schemas: Get all tools in OpenAI-compatible format + * - runtime_execute_tool: Execute a tool with enhanced validation and timing + */ + +import { invoke } from '@tauri-apps/api/core'; +import type { + AgentToolSchema, + AgentToolExecution, + IAgentToolRegistry +} from '../types/agent'; + +// ZeroClaw format types from Rust +interface ZeroClawToolSchema { + type: string; + function: { + name: string; + description: string; + parameters: any; + }; +} + +interface ZeroClawToolResult { + success: boolean; + output: string; + error?: string; + execution_time?: number; +} + +export class AgentToolRegistry implements IAgentToolRegistry { + private static instance: AgentToolRegistry; + private toolSchemas: AgentToolSchema[] = []; + private lastLoadTime = 0; + private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes + + static getInstance(): AgentToolRegistry { + if (!this.instance) { + this.instance = new AgentToolRegistry(); + } + return this.instance; + } + + /** + * Load tool schemas from the skill system using ZeroClaw format + */ + async loadToolSchemas(forceReload = false): Promise { + const now = Date.now(); + + // Return cached tools if still fresh + if (!forceReload && this.toolSchemas.length > 0 && (now - this.lastLoadTime) < this.CACHE_TTL) { + return this.toolSchemas; + } + + try { + console.log('🔧 Loading tool schemas from skill system (ZeroClaw format)...'); + + // Call ZeroClaw format command to get tools in OpenAI-compatible format + const zeroClawTools = await invoke('runtime_get_tool_schemas'); + + console.log(`🔧 Loaded ${zeroClawTools.length} tools in ZeroClaw format`); + + // Tools are already in OpenAI format, just map to our interface + this.toolSchemas = zeroClawTools.map(tool => ({ + type: tool.type, + function: { + name: tool.function.name, + description: tool.function.description, + parameters: tool.function.parameters + } + })); + + this.lastLoadTime = now; + + console.log(`✅ Tool registry updated: ${this.toolSchemas.length} tools available`); + + return this.toolSchemas; + } catch (error) { + console.error('❌ Failed to load tool schemas:', error); + throw new Error(`Failed to load tool schemas: ${error}`); + } + } + + /** + * Execute a tool using ZeroClaw format with enhanced validation + */ + async executeTool( + skillId: string, + toolName: string, + toolArguments: string + ): Promise { + const startTime = Date.now(); + const executionId = `exec_${startTime}_${Math.random().toString(36).substr(2, 9)}`; + + // Create tool ID in format expected by runtime_execute_tool + const toolId = `${skillId}_${toolName}`; + + console.log(`🚀 [TOOL EXECUTION START] Executing tool: ${toolId}`); + console.log(`📝 [ARGUMENTS] Raw arguments:`, { + arguments: toolArguments, + type: typeof toolArguments, + length: toolArguments?.length, + isString: typeof toolArguments === 'string', + parsed: (() => { + try { + return typeof toolArguments === 'string' ? JSON.parse(toolArguments) : toolArguments; + } catch (e) { + return 'Failed to parse: ' + e; + } + })() + }); + + const execution: AgentToolExecution = { + id: executionId, + toolName, + skillId, + arguments: toolArguments, + status: 'running', + startTime + }; + + try { + // Call ZeroClaw format command with enhanced validation and timing + console.log(`🔧 [BEFORE INVOKE] Calling runtime_execute_tool with:`); + console.log(` toolId: "${toolId}"`); + console.log(` args: ${toolArguments}`); + console.log(` args type: ${typeof toolArguments}`); + + const result = await invoke('runtime_execute_tool', { + toolId: toolId, // Use camelCase as expected by current Rust version + args: toolArguments // Use "args" instead of "arguments" + }); + + console.log(`🔧 [AFTER INVOKE] Tool execution result:`, result); + + execution.endTime = Date.now(); + // Use execution time from Rust if available, otherwise calculate locally + execution.executionTimeMs = result.execution_time || (execution.endTime - execution.startTime); + + if (!result.success) { + execution.status = 'error'; + execution.errorMessage = result.error || 'Unknown error occurred'; + execution.result = execution.errorMessage; + + console.log(`❌ Tool execution failed: ${toolName} (${execution.executionTimeMs}ms)`); + console.log(`❌ Error:`, execution.errorMessage); + } else { + execution.status = 'success'; + execution.result = result.output; + + console.log(`✅ Tool execution completed: ${toolName} (${execution.executionTimeMs}ms)`); + } + + return execution; + + } catch (error) { + execution.endTime = Date.now(); + execution.executionTimeMs = execution.endTime - execution.startTime; + execution.status = 'error'; + execution.errorMessage = error instanceof Error ? error.message : String(error); + execution.result = execution.errorMessage; + + console.error(`❌ Tool execution error: ${toolName}`, error); + + return execution; + } + } + + /** + * Get a specific tool by name + */ + getToolByName(toolName: string): AgentToolSchema | undefined { + return this.toolSchemas.find(tool => tool.function.name === toolName); + } + + /** + * Get all available tools + */ + getAllTools(): AgentToolSchema[] { + return [...this.toolSchemas]; + } + + /** + * Get tools organized by skill + */ + getToolsBySkill(): Record { + const toolsBySkill: Record = {}; + + for (const tool of this.toolSchemas) { + // Extract skill ID from tool name (format: skillId_toolName) + const skillId = this.extractSkillIdFromToolName(tool.function.name) || 'unknown'; + + if (!toolsBySkill[skillId]) { + toolsBySkill[skillId] = []; + } + toolsBySkill[skillId].push(tool); + } + + return toolsBySkill; + } + + /** + * Get tool execution statistics + */ + getToolStats(): { + totalTools: number; + skillCount: number; + categories: Record; + } { + const categories: Record = {}; + const skills = new Set(); + + for (const tool of this.toolSchemas) { + const skillId = this.extractSkillIdFromToolName(tool.function.name) || 'unknown'; + skills.add(skillId); + + // Categorize by skill name + const category = this.extractCategoryFromSkillId(skillId); + categories[category] = (categories[category] || 0) + 1; + } + + return { + totalTools: this.toolSchemas.length, + skillCount: skills.size, + categories + }; + } + + /** + * Clear the tool registry cache + */ + clearCache(): void { + this.toolSchemas = []; + this.lastLoadTime = 0; + console.log('🔧 Tool registry cache cleared'); + } + + // ============================================================================= + // Private Helper Methods + // ============================================================================= + + /** + * Extract skill ID from tool name (format: skillId_toolName) + */ + private extractSkillIdFromToolName(toolName: string): string | null { + const underscoreIndex = toolName.lastIndexOf('_'); + if (underscoreIndex === -1) { + return null; + } + return toolName.substring(0, underscoreIndex); + } + + /** + * Extract category name from skill ID for organization + */ + private extractCategoryFromSkillId(skillId: string): string { + // Common skill naming patterns + if (skillId.includes('github') || skillId.includes('git')) return 'GitHub'; + if (skillId.includes('notion')) return 'Notion'; + if (skillId.includes('telegram') || skillId.includes('tg')) return 'Telegram'; + if (skillId.includes('email') || skillId.includes('gmail')) return 'Email'; + if (skillId.includes('calendar')) return 'Calendar'; + if (skillId.includes('slack')) return 'Slack'; + if (skillId.includes('discord')) return 'Discord'; + if (skillId.includes('twitter') || skillId.includes('x')) return 'Social'; + if (skillId.includes('file') || skillId.includes('fs')) return 'File System'; + if (skillId.includes('crypto') || skillId.includes('blockchain')) return 'Crypto'; + if (skillId.includes('ai') || skillId.includes('ml')) return 'AI/ML'; + + return 'Other'; + } +} \ No newline at end of file diff --git a/src/services/api/inferenceApi.ts b/src/services/api/inferenceApi.ts index 070e619ad..ac11a0258 100644 --- a/src/services/api/inferenceApi.ts +++ b/src/services/api/inferenceApi.ts @@ -4,25 +4,23 @@ import { apiClient } from '../apiClient'; export type ChatRole = 'system' | 'user' | 'assistant' | 'tool'; -export interface ChatMessage { - role: ChatRole; - content: string; - /** tool_call_id for role=tool messages */ - tool_call_id?: string; - /** tool_calls emitted by the assistant */ - tool_calls?: ToolCall[]; +export interface ToolCall { + id: string; + type: 'function'; + function: { name: string; arguments: string }; } -// ── Tool calling types (OpenAI-compatible) ─────────────────────────────────── +export interface ChatMessage { + role: ChatRole; + content: string | null; + tool_calls?: ToolCall[]; + tool_call_id?: string; +} export interface ToolFunction { name: string; description: string; - parameters: { - type: 'object'; - properties: Record; - required?: string[]; - }; + parameters: any; } export interface Tool { @@ -30,23 +28,14 @@ export interface Tool { function: ToolFunction; } -export interface ToolCall { - id: string; - type: 'function'; - function: { - name: string; - arguments: string; - }; -} - export interface ChatCompletionRequest { model: string; messages: ChatMessage[]; + tools?: Tool[]; + tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } }; stream?: boolean; temperature?: number; max_tokens?: number; - tools?: Tool[]; - tool_choice?: 'none' | 'auto' | 'required'; } export interface TextCompletionRequest { @@ -116,16 +105,12 @@ export const inferenceApi = { }, /** POST /openai/v1/chat/completions — create a chat completion */ - createChatCompletion: async ( - body: ChatCompletionRequest - ): Promise => { + createChatCompletion: async (body: ChatCompletionRequest): Promise => { return apiClient.post('/openai/v1/chat/completions', body); }, /** POST /openai/v1/completions — create a text completion */ - createCompletion: async ( - body: TextCompletionRequest - ): Promise => { + createCompletion: async (body: TextCompletionRequest): Promise => { return apiClient.post('/openai/v1/completions', body); }, }; diff --git a/src/services/daemonHealthService.ts b/src/services/daemonHealthService.ts index 8d009f890..a5d033d9d 100644 --- a/src/services/daemonHealthService.ts +++ b/src/services/daemonHealthService.ts @@ -31,7 +31,7 @@ export class DaemonHealthService { // console.log('[DaemonHealth] Setting up alphahuman:health event listener'); this.healthEventListener = await listen('alphahuman:health', event => { - console.log('[DaemonHealth] Received health event:', event.payload); + // console.log('[DaemonHealth] Received health event:', event.payload); const healthSnapshot = this.parseHealthSnapshot(event.payload); if (healthSnapshot) { diff --git a/src/store/index.ts b/src/store/index.ts index 3d642173a..6e659e00b 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -53,6 +53,7 @@ const threadPersistConfig = { whitelist: ['panelWidth', 'lastViewedAt', 'threads', 'messagesByThreadId', 'selectedThreadId'], }; + const persistedAuthReducer = persistReducer(authPersistConfig, authReducer); const persistedAiReducer = persistReducer(aiPersistConfig, aiReducer); const persistedSkillsReducer = persistReducer(skillsPersistConfig, skillsReducer); diff --git a/src/store/threadSlice.ts b/src/store/threadSlice.ts index 890262256..4c8c8747c 100644 --- a/src/store/threadSlice.ts +++ b/src/store/threadSlice.ts @@ -4,6 +4,7 @@ import { threadApi } from '../services/api/threadApi'; import type { Thread, ThreadMessage } from '../types/thread'; import { injectAll } from '../lib/ai/injector'; import type { Message } from '../lib/ai/providers/interface'; +import type { RootState } from './index'; interface ThreadState { // Existing local data (will be persisted) @@ -125,7 +126,7 @@ export const sendMessage = createAsyncThunk( // 3. Send to API with processed message (disable injection in threadApi to avoid double injection) const data = await threadApi.sendMessage(processedMessage, threadId, { injectSoul: false }); - // 3. For now, we'll handle AI response via the existing inference API + // 4. For now, we'll handle AI response via the existing inference API // The AI response will be added separately via addInferenceResponse return data;