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..e305d8140 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,164 @@ 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!("Generating ZeroClaw-compatible tool schemas"); + + let tools = engine.all_tools(); + 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() + }; + + // 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: format!("{}_{}", skill_id, tool.name), + description, + parameters: openai_parameters, + }, + }; + + schemas.push(schema); + } + + log::info!("Generated {} ZeroClaw tool schemas", schemas.len()); + 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!("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)) => (skill, tool), + Err(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), + }); + } + }; + + // Execute the tool using the existing command + match engine.call_tool(&skill_id, &tool_name, args).await { + Ok(result) => { + let execution_time = start_time.elapsed().as_millis() as u64; + + 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"); + + 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!("ZeroClaw tool execution 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 +565,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 +631,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/components/agent/AgentExecutionPanel.tsx b/src/components/agent/AgentExecutionPanel.tsx new file mode 100644 index 000000000..54ada33a5 --- /dev/null +++ b/src/components/agent/AgentExecutionPanel.tsx @@ -0,0 +1,236 @@ +/** + * Agent Execution Panel Component + * + * Detailed view of agent execution progress, tool executions, and results. + * Expandable panel that shows real-time execution details. + */ + +import { memo, useMemo } from 'react'; +import { useAppSelector } from '../../store/hooks'; +import { + selectActiveExecutionForThread, + selectExecutionHistoryForThread, + selectAgentModeForThread +} from '../../store/agentSlice'; +import type { AgentToolExecution } from '../../types/agent'; + +interface AgentExecutionPanelProps { + threadId: string; + className?: string; + maxHeight?: string; +} + +const formatDuration = (ms: number): string => { + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`; +}; + +const getStatusIcon = (status: string) => { + switch (status) { + case 'pending': + return ( + + + + ); + case 'running': + return ( +
+ ); + case 'success': + return ( + + + + ); + case 'error': + return ( + + + + ); + default: + return ( +
+ ); + } +}; + +const ToolExecutionItem = memo<{ toolExecution: AgentToolExecution }>(({ toolExecution }) => { + const duration = toolExecution.executionTimeMs || (toolExecution.endTime ? toolExecution.endTime - toolExecution.startTime : null); + + return ( +
+
+ {getStatusIcon(toolExecution.status)} +
+ +
+
+ {toolExecution.toolName} + + {toolExecution.skillId} + + {duration && ( + + {formatDuration(duration)} + + )} +
+ + {/* Arguments */} + {toolExecution.arguments && ( +
+ Arguments: +
+              {JSON.stringify(JSON.parse(toolExecution.arguments), null, 2)}
+            
+
+ )} + + {/* Result */} + {toolExecution.result && ( +
+ Result: +
+ {toolExecution.result} +
+
+ )} + + {/* Error */} + {toolExecution.errorMessage && ( +
+ Error: +
+ {toolExecution.errorMessage} +
+
+ )} +
+
+ ); +}); + +ToolExecutionItem.displayName = 'ToolExecutionItem'; + +const AgentExecutionPanel = memo(({ + threadId, + className = '', + maxHeight = '400px' +}) => { + const agentMode = useAppSelector(state => selectAgentModeForThread(state, threadId)); + const activeExecution = useAppSelector(state => selectActiveExecutionForThread(state, threadId)); + const executionHistory = useAppSelector(state => selectExecutionHistoryForThread(state, threadId)); + + const sortedToolExecutions = useMemo(() => { + if (!activeExecution) return []; + return [...activeExecution.toolExecutions].sort((a, b) => a.startTime - b.startTime); + }, [activeExecution]); + + const recentHistory = useMemo(() => { + return executionHistory.slice(0, 3); // Show last 3 completed executions + }, [executionHistory]); + + if (!agentMode) { + return null; + } + + return ( +
+
+

Agent Execution Details

+
+ +
+ {/* Active Execution */} + {activeExecution && ( +
+
+

Current Execution

+ + Running for {formatDuration(Date.now() - activeExecution.startTime)} + +
+ +
+
Progress:
+
+
+
+
+ + {activeExecution.currentIteration}/{activeExecution.maxIterations} + +
+
+ + {/* Tool Executions */} + {sortedToolExecutions.length > 0 && ( +
+
+ Tool Executions ({sortedToolExecutions.length}): +
+ {sortedToolExecutions.map(toolExecution => ( + + ))} +
+ )} +
+ )} + + {/* Execution History */} + {recentHistory.length > 0 && ( +
+

Recent Executions

+
+ {recentHistory.map(entry => ( +
+
+
+ + {entry.result.status} + + + {entry.result.toolExecutions.length} tools + +
+
+ {formatDuration(entry.duration)} +
+
+ ))} +
+
+ )} + + {/* Empty State */} + {!activeExecution && recentHistory.length === 0 && ( +
+ + + +

No agent executions yet

+

Send a message to start an agent task

+
+ )} +
+
+ ); +}); + +AgentExecutionPanel.displayName = 'AgentExecutionPanel'; + +export default AgentExecutionPanel; \ No newline at end of file diff --git a/src/components/agent/AgentStatusIndicator.tsx b/src/components/agent/AgentStatusIndicator.tsx new file mode 100644 index 000000000..87e4d9238 --- /dev/null +++ b/src/components/agent/AgentStatusIndicator.tsx @@ -0,0 +1,96 @@ +/** + * Agent Status Indicator Component + * + * Shows the current status of agent execution within thread UI. + * Displays real-time agent activity, tool executions, and completion status. + */ + +import { memo } from 'react'; +import { useAppSelector } from '../../store/hooks'; +import { selectActiveExecutionForThread, selectAgentModeForThread } from '../../store/agentSlice'; + +interface AgentStatusIndicatorProps { + threadId: string; + className?: string; +} + +const AgentStatusIndicator = memo(({ threadId, className = '' }) => { + const agentMode = useAppSelector(state => selectAgentModeForThread(state, threadId)); + const activeExecution = useAppSelector(state => selectActiveExecutionForThread(state, threadId)); + + // Don't render if agent mode is disabled + if (!agentMode) { + return null; + } + + // No active execution + if (!activeExecution) { + return ( +
+
+ Agent Ready +
+ ); + } + + const getStatusColor = () => { + switch (activeExecution.status) { + case 'initializing': + return 'bg-amber-500'; + case 'running': + return 'bg-primary-500 animate-pulse'; + case 'completing': + return 'bg-sage-500'; + default: + return 'bg-canvas-400'; + } + }; + + const getStatusText = () => { + switch (activeExecution.status) { + case 'initializing': + return 'Starting...'; + case 'running': + return `Iteration ${activeExecution.currentIteration}/${activeExecution.maxIterations}`; + case 'completing': + return 'Finishing...'; + default: + return 'Agent Active'; + } + }; + + const toolCount = activeExecution.toolExecutions.length; + const runningTools = activeExecution.toolExecutions.filter(t => t.status === 'running').length; + + return ( +
+ {/* Status indicator */} +
+
+ {getStatusText()} +
+ + {/* Tool execution info */} + {toolCount > 0 && ( +
+ + + + {toolCount} tools + {runningTools > 0 && ( + • {runningTools} running + )} +
+ )} + + {/* Execution time */} +
+ {Math.floor((Date.now() - activeExecution.startTime) / 1000)}s +
+
+ ); +}); + +AgentStatusIndicator.displayName = 'AgentStatusIndicator'; + +export default AgentStatusIndicator; \ No newline at end of file diff --git a/src/components/agent/AgentToggle.tsx b/src/components/agent/AgentToggle.tsx new file mode 100644 index 000000000..7f648d62a --- /dev/null +++ b/src/components/agent/AgentToggle.tsx @@ -0,0 +1,162 @@ +/** + * Agent Toggle Component + * + * Toggle switch to enable/disable agent mode for a thread. + * Shows agent status and allows configuration when enabled. + */ + +import { memo, useCallback, useState } from 'react'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { + selectAgentModeForThread, + selectAgentConfigForThread, + selectActiveExecutionForThread, + setAgentModeForThread, + loadAgentTools +} from '../../store/agentSlice'; + +interface AgentToggleProps { + threadId: string; + className?: string; + size?: 'sm' | 'md' | 'lg'; +} + +const AgentToggle = memo(({ + threadId, + className = '', + size = 'md' +}) => { + const dispatch = useAppDispatch(); + const agentMode = useAppSelector(state => selectAgentModeForThread(state, threadId)); + const agentConfig = useAppSelector(state => selectAgentConfigForThread(state, threadId)); + const activeExecution = useAppSelector(state => selectActiveExecutionForThread(state, threadId)); + const [isLoading, setIsLoading] = useState(false); + + const handleToggle = useCallback(async () => { + if (activeExecution) { + // Can't disable while agent is running + return; + } + + setIsLoading(true); + + try { + const newMode = !agentMode; + + // Enable agent mode + if (newMode) { + // Load tools when enabling agent mode + await dispatch(loadAgentTools()).unwrap(); + } + + dispatch(setAgentModeForThread({ + threadId, + enabled: newMode + })); + } catch (error) { + console.error('Failed to toggle agent mode:', error); + } finally { + setIsLoading(false); + } + }, [dispatch, threadId, agentMode, activeExecution]); + + const getSizeClasses = () => { + switch (size) { + case 'sm': + return { + container: 'w-8 h-5', + toggle: 'w-3 h-3', + translate: 'translate-x-3' + }; + case 'lg': + return { + container: 'w-12 h-7', + toggle: 'w-5 h-5', + translate: 'translate-x-5' + }; + default: // md + return { + container: 'w-10 h-6', + toggle: 'w-4 h-4', + translate: 'translate-x-4' + }; + } + }; + + const sizeClasses = getSizeClasses(); + const isDisabled = isLoading || Boolean(activeExecution); + + return ( +
+ {/* Toggle Switch */} + + + {/* Label and Status */} +
+
+ + Agent Mode + + + {agentMode && ( + + Active + + )} +
+ + {/* Configuration hint */} + {agentMode && !activeExecution && ( +
+ {agentConfig.maxIterations ? `Max ${agentConfig.maxIterations} iterations` : 'Default settings'} + {agentConfig.allowedSkills && agentConfig.allowedSkills.length > 0 && + ` • ${agentConfig.allowedSkills.length} skills allowed` + } +
+ )} + + {/* Active execution status */} + {activeExecution && ( +
+ Running iteration {activeExecution.currentIteration}/{activeExecution.maxIterations} +
+ )} +
+
+ ); +}); + +AgentToggle.displayName = 'AgentToggle'; + +export default AgentToggle; \ No newline at end of file diff --git a/src/components/agent/index.ts b/src/components/agent/index.ts new file mode 100644 index 000000000..3305c8fdb --- /dev/null +++ b/src/components/agent/index.ts @@ -0,0 +1,13 @@ +/** + * Agent Components Export Index + * + * Centralized exports for all agent-related UI components. + */ + +export { default as AgentStatusIndicator } from './AgentStatusIndicator'; +export { default as AgentToggle } from './AgentToggle'; +export { default as AgentExecutionPanel } from './AgentExecutionPanel'; + +export type { default as AgentStatusIndicatorProps } from './AgentStatusIndicator'; +export type { default as AgentToggleProps } from './AgentToggle'; +export type { default as AgentExecutionPanelProps } from './AgentExecutionPanel'; \ No newline at end of file diff --git a/src/pages/Conversations.tsx b/src/pages/Conversations.tsx index 79a19f0fe..182642c75 100644 --- a/src/pages/Conversations.tsx +++ b/src/pages/Conversations.tsx @@ -12,6 +12,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import { inferenceApi, type ModelInfo } from '../services/api/inferenceApi'; import { injectAll } from '../lib/ai/injector'; import type { Message } from '../lib/ai/providers/interface'; +import { AgentToggle, AgentStatusIndicator, AgentExecutionPanel } from '../components/agent'; import { useAppDispatch, useAppSelector } from '../store/hooks'; import { addInferenceResponse, @@ -595,6 +596,9 @@ const Conversations = () => { Created {formatRelativeTime(selectedThread.createdAt)}

+
+ +
{/* Messages */} @@ -739,6 +743,19 @@ const Conversations = () => {
)} + {/* Agent Status and Execution Panel */} +
+ + +
+ {/* Message Input */}
{/* Model selector */} 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/agentLoop.ts b/src/services/agentLoop.ts new file mode 100644 index 000000000..231f344a8 --- /dev/null +++ b/src/services/agentLoop.ts @@ -0,0 +1,388 @@ +/** + * Agent Loop Service + * + * Orchestrates autonomous agent task execution by: + * 1. Loading tools from the existing skill system + * 2. Sending requests to the backend (which proxies to AI providers) + * 3. Executing tool calls using the skill system + * 4. Managing conversation state and iteration + */ + +import { AgentToolRegistry } from './agentToolRegistry'; +import { apiClient } from './apiClient'; +import type { + AgentExecutionOptions, + AgentExecutionResult, + AgentToolExecution, + AgentChatRequest, + AgentChatResponse, + OpenAIMessage, + OpenAITool, + IAgentLoop +} from '../types/agent'; + +export class AgentLoop implements IAgentLoop { + private static instance: AgentLoop; + private toolRegistry: AgentToolRegistry; + private activeExecutions = new Map(); + + constructor() { + this.toolRegistry = AgentToolRegistry.getInstance(); + } + + static getInstance(): AgentLoop { + if (!this.instance) { + this.instance = new AgentLoop(); + } + return this.instance; + } + + /** + * Execute an agent task autonomously + */ + async executeTask( + userMessage: string, + threadId: string, + options: AgentExecutionOptions = {} + ): Promise { + const { + maxIterations = 10, + timeoutMs = 300000, // 5 minutes + requireApproval = false, + allowedSkills, + blockedTools = [], + retryFailedTools = false + } = options; + + const executionId = `agent_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const abortController = new AbortController(); + this.activeExecutions.set(executionId, abortController); + + const startTime = Date.now(); + const toolExecutions: AgentToolExecution[] = []; + let iterations = 0; + + try { + console.log(`🤖 Starting agent task execution (${executionId})`); + console.log(`📝 User message: "${userMessage}"`); + console.log(`⚙️ Options:`, { maxIterations, timeoutMs, allowedSkills, blockedTools }); + + // Set up timeout + const timeoutId = setTimeout(() => { + console.log(`⏰ Agent execution timeout (${timeoutMs}ms)`); + abortController.abort(); + }, timeoutMs); + + try { + // Load available tools from skill system + console.log('🔧 Loading available tools from skills...'); + const toolSchemas = await this.toolRegistry.loadToolSchemas(); + + // Filter tools based on configuration + const availableTools = this.filterTools(toolSchemas, allowedSkills, blockedTools); + console.log(`🛠️ Agent has access to ${availableTools.length} tools from ${toolSchemas.length} total`); + + // Convert to OpenAI format for backend compatibility + const tools = availableTools.map(this.convertToOpenAITool); + + // Initialize conversation with user message + const messages: OpenAIMessage[] = [ + { + role: 'user', + content: userMessage + } + ]; + + let finalResponse: string | undefined; + + // Agent iteration loop + while (iterations < maxIterations && !abortController.signal.aborted) { + iterations++; + console.log(`🔄 Agent iteration ${iterations}/${maxIterations}`); + + try { + // Send request to backend (which proxies to AI provider) + const request: AgentChatRequest = { + model: 'gpt-4', // Backend will handle the actual model + messages: [...messages], + tools, + tool_choice: 'auto', + temperature: 0.7, + max_tokens: 4096 + }; + + console.log('📤 Sending request to backend proxy...'); + const response = await apiClient.post( + `/api/v1/conversations/${threadId}/messages`, + request, + { + signal: abortController.signal + } + ); + + const assistantMessage = response.data.choices[0]?.message; + if (!assistantMessage) { + throw new Error('No response from AI provider'); + } + + console.log(`📥 Received response: ${assistantMessage.tool_calls?.length || 0} tool calls`); + + // Add assistant message to conversation + messages.push(assistantMessage); + + // Check if AI wants to call tools + if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) { + console.log(`🛠️ Executing ${assistantMessage.tool_calls.length} tool calls...`); + + // Execute each tool call + for (const toolCall of assistantMessage.tool_calls) { + if (abortController.signal.aborted) { + break; + } + + const execution = await this.executeSingleTool( + toolCall, + availableTools, + requireApproval, + abortController.signal + ); + + toolExecutions.push(execution); + + // Add tool result to conversation + messages.push({ + role: 'tool', + content: execution.result || execution.errorMessage || 'No result', + tool_call_id: toolCall.id + }); + + console.log(`✅ Tool result added to conversation: ${execution.status}`); + } + + // Continue to next iteration to let AI process tool results + continue; + } else { + // AI provided final response + finalResponse = assistantMessage.content || ''; + console.log('✅ Agent task completed with final response'); + break; + } + + } catch (error) { + console.error(`❌ Error in agent iteration ${iterations}:`, error); + + if (abortController.signal.aborted) { + clearTimeout(timeoutId); + return { + status: 'timeout', + executionId, + iterations, + toolExecutions, + executionTime: Date.now() - startTime, + error: 'Execution timed out' + }; + } + + clearTimeout(timeoutId); + return { + status: 'error', + executionId, + iterations, + toolExecutions, + executionTime: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error) + }; + } + } + + clearTimeout(timeoutId); + + // Check if we hit max iterations + if (iterations >= maxIterations && !finalResponse) { + console.log('⚠️ Agent reached maximum iterations without completion'); + return { + status: 'max_iterations', + executionId, + iterations, + toolExecutions, + executionTime: Date.now() - startTime, + error: 'Maximum iterations reached without completion' + }; + } + + const executionTime = Date.now() - startTime; + console.log(`🎉 Agent execution completed successfully in ${executionTime}ms`); + console.log(`📊 Stats: ${iterations} iterations, ${toolExecutions.length} tool executions`); + + return { + status: 'completed', + executionId, + finalResponse, + iterations, + toolExecutions, + executionTime, + metadata: { + toolsAvailable: availableTools.length, + skillsInvolved: [...new Set(toolExecutions.map(te => te.skillId))] + } + }; + + } finally { + clearTimeout(timeoutId); + } + + } catch (error) { + console.error('❌ Agent execution failed:', error); + + return { + status: 'error', + executionId, + iterations, + toolExecutions, + executionTime: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error) + }; + } finally { + this.activeExecutions.delete(executionId); + } + } + + /** + * Cancel an active agent execution + */ + cancelExecution(executionId: string): boolean { + const controller = this.activeExecutions.get(executionId); + if (controller) { + controller.abort(); + this.activeExecutions.delete(executionId); + console.log(`🛑 Cancelled agent execution: ${executionId}`); + return true; + } + return false; + } + + /** + * Get list of active execution IDs + */ + getActiveExecutions(): string[] { + return Array.from(this.activeExecutions.keys()); + } + + /** + * Get execution status (placeholder - would need Redux integration) + */ + getExecutionStatus(executionId: string): null { + // This would typically integrate with Redux state + // For now, just return null + return null; + } + + // ============================================================================= + // Private Helper Methods + // ============================================================================= + + /** + * Execute a single tool call + */ + private async executeSingleTool( + toolCall: any, + availableTools: any[], + requireApproval: boolean, + signal: AbortSignal + ): Promise { + const startTime = Date.now(); + + try { + // Find the tool and its associated skill + const toolSchema = availableTools.find(t => t.function.name === toolCall.function.name); + if (!toolSchema) { + return { + id: toolCall.id, + toolName: toolCall.function.name, + skillId: 'unknown', + arguments: toolCall.function.arguments, + status: 'error', + startTime, + endTime: Date.now(), + errorMessage: `Tool not found: ${toolCall.function.name}` + }; + } + + const skillId = (toolSchema.function as any).skillId; + + console.log(`🔧 Executing tool: ${skillId}.${toolCall.function.name}`); + + // TODO: Implement approval workflow if requireApproval is true + + // Execute the tool using the existing skill system + const result = await this.toolRegistry.executeTool( + skillId, + toolCall.function.name, + toolCall.function.arguments + ); + + console.log(`✅ Tool execution ${result.status}: ${toolCall.function.name}`); + + return { + ...result, + id: toolCall.id // Use the tool call ID from the AI + }; + + } catch (error) { + const endTime = Date.now(); + console.error(`❌ Tool execution error: ${toolCall.function.name}`, error); + + return { + id: toolCall.id, + toolName: toolCall.function.name, + skillId: 'unknown', + arguments: toolCall.function.arguments, + status: 'error', + startTime, + endTime, + executionTimeMs: endTime - startTime, + errorMessage: error instanceof Error ? error.message : String(error) + }; + } + } + + /** + * Filter tools based on allowed skills and blocked tools + */ + private filterTools( + toolSchemas: any[], + allowedSkills?: string[], + blockedTools: string[] = [] + ): any[] { + return toolSchemas.filter(tool => { + const skillId = (tool.function as any).skillId; + const toolName = tool.function.name; + + // Check if tool is blocked + if (blockedTools.includes(toolName)) { + return false; + } + + // Check if skill is allowed (if allowedSkills is specified) + if (allowedSkills && allowedSkills.length > 0) { + return allowedSkills.includes(skillId); + } + + return true; + }); + } + + /** + * Convert agent tool schema to OpenAI tool format + */ + private convertToOpenAITool(toolSchema: any): OpenAITool { + return { + type: 'function', + function: { + name: toolSchema.function.name, + description: toolSchema.function.description, + parameters: toolSchema.function.parameters + } + }; + } +} \ No newline at end of file diff --git a/src/services/agentToolRegistry.ts b/src/services/agentToolRegistry.ts new file mode 100644 index 000000000..2de9d47bb --- /dev/null +++ b/src/services/agentToolRegistry.ts @@ -0,0 +1,256 @@ +/** + * 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(`🚀 Executing tool: ${toolId}`); + console.log(`📝 Arguments:`, toolArguments); + + const execution: AgentToolExecution = { + id: executionId, + toolName, + skillId, + arguments: toolArguments, + status: 'running', + startTime + }; + + try { + // Call ZeroClaw format command with enhanced validation and timing + const result = await invoke('runtime_execute_tool', { + toolId, + arguments: toolArguments + }); + + 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/store/__tests__/agentSlice.test.ts b/src/store/__tests__/agentSlice.test.ts new file mode 100644 index 000000000..0d4197422 --- /dev/null +++ b/src/store/__tests__/agentSlice.test.ts @@ -0,0 +1,496 @@ +import { describe, test, expect, beforeEach, vi } from 'vitest'; +import { configureStore } from '@reduxjs/toolkit'; +import agentReducer, { + setAgentMode, + startAgentExecution, + updateExecutionProgress, + completeAgentExecution, + cancelAgentExecution, + setToolRegistry, + clearExecutionHistory, + executeAgentTask, + loadAgentTools, + cancelAgentExecutionThunk, + type AgentState +} from '../agentSlice'; +import type { + AgentExecutionResult, + AgentToolExecution, + AgentToolSchema +} from '../../types/agent'; + +// Mock dependencies +vi.mock('../../services/agentLoop'); +vi.mock('../../services/agentToolRegistry'); + +describe('agentSlice', () => { + let store: ReturnType; + + beforeEach(() => { + store = configureStore({ + reducer: { + agent: agentReducer + } + }); + }); + + describe('synchronous actions', () => { + test('setAgentMode should toggle agent mode', () => { + expect(store.getState().agent.isAgentMode).toBe(false); + + store.dispatch(setAgentMode(true)); + expect(store.getState().agent.isAgentMode).toBe(true); + + store.dispatch(setAgentMode(false)); + expect(store.getState().agent.isAgentMode).toBe(false); + }); + + test('startAgentExecution should initialize execution state', () => { + const executionId = 'exec_123'; + const threadId = 'thread_456'; + const userMessage = 'Help me with GitHub issues'; + + store.dispatch(startAgentExecution({ executionId, threadId, userMessage })); + + const state = store.getState().agent; + expect(state.currentExecution).toEqual({ + id: executionId, + threadId, + userMessage, + status: 'running', + iterations: 0, + toolExecutions: [], + startTime: expect.any(Number), + executionTime: 0 + }); + expect(state.executionHistory).toHaveLength(1); + expect(state.executionHistory[0].id).toBe(executionId); + }); + + test('updateExecutionProgress should update current execution', () => { + const executionId = 'exec_123'; + const threadId = 'thread_456'; + + // Start execution first + store.dispatch(startAgentExecution({ + executionId, + threadId, + userMessage: 'Test' + })); + + const toolExecution: AgentToolExecution = { + id: 'tool_exec_1', + toolName: 'list_issues', + skillId: 'github', + arguments: '{"owner":"user","repo":"test"}', + status: 'running', + startTime: Date.now() + }; + + store.dispatch(updateExecutionProgress({ + executionId, + iteration: 1, + toolExecution + })); + + const state = store.getState().agent; + expect(state.currentExecution?.iterations).toBe(1); + expect(state.currentExecution?.toolExecutions).toHaveLength(1); + expect(state.currentExecution?.toolExecutions[0]).toEqual(toolExecution); + }); + + test('updateExecutionProgress should update existing tool execution', () => { + const executionId = 'exec_123'; + const threadId = 'thread_456'; + + // Start execution + store.dispatch(startAgentExecution({ + executionId, + threadId, + userMessage: 'Test' + })); + + const toolExecution: AgentToolExecution = { + id: 'tool_exec_1', + toolName: 'list_issues', + skillId: 'github', + arguments: '{}', + status: 'running', + startTime: Date.now() + }; + + // Add tool execution + store.dispatch(updateExecutionProgress({ + executionId, + iteration: 1, + toolExecution + })); + + // Update the same tool execution with completion + const updatedToolExecution: AgentToolExecution = { + ...toolExecution, + status: 'success', + endTime: Date.now(), + executionTimeMs: 1500, + result: '{"issues":[]}' + }; + + store.dispatch(updateExecutionProgress({ + executionId, + iteration: 1, + toolExecution: updatedToolExecution + })); + + const state = store.getState().agent; + expect(state.currentExecution?.toolExecutions).toHaveLength(1); + expect(state.currentExecution?.toolExecutions[0].status).toBe('success'); + expect(state.currentExecution?.toolExecutions[0].result).toBe('{"issues":[]}'); + }); + + test('completeAgentExecution should finalize execution', () => { + const executionId = 'exec_123'; + const threadId = 'thread_456'; + + // Start execution first + store.dispatch(startAgentExecution({ + executionId, + threadId, + userMessage: 'Test' + })); + + const completionData = { + executionId, + status: 'completed' as const, + finalResponse: 'Task completed successfully', + totalExecutionTime: 5000 + }; + + store.dispatch(completeAgentExecution(completionData)); + + const state = store.getState().agent; + expect(state.currentExecution?.status).toBe('completed'); + expect(state.currentExecution?.finalResponse).toBe('Task completed successfully'); + expect(state.currentExecution?.executionTime).toBe(5000); + expect(state.lastExecutionId).toBe(executionId); + + // Execution should be updated in history + const historyItem = state.executionHistory.find(item => item.id === executionId); + expect(historyItem?.status).toBe('completed'); + expect(historyItem?.finalResponse).toBe('Task completed successfully'); + }); + + test('cancelAgentExecution should cancel current execution', () => { + const executionId = 'exec_123'; + const threadId = 'thread_456'; + + // Start execution first + store.dispatch(startAgentExecution({ + executionId, + threadId, + userMessage: 'Test' + })); + + store.dispatch(cancelAgentExecution({ + executionId, + reason: 'User cancelled' + })); + + const state = store.getState().agent; + expect(state.currentExecution?.status).toBe('cancelled'); + expect(state.currentExecution?.error).toBe('User cancelled'); + }); + + test('setToolRegistry should update available tools', () => { + const mockTools: AgentToolSchema[] = [ + { + type: "function", + function: { + name: "github_list_issues", + description: "List GitHub issues", + parameters: { + type: "object", + properties: { + owner: { type: "string" }, + repo: { type: "string" } + }, + required: ["owner", "repo"] + } + } + } + ]; + + store.dispatch(setToolRegistry({ + tools: mockTools, + lastUpdated: Date.now() + })); + + const state = store.getState().agent; + expect(state.toolRegistry.tools).toEqual(mockTools); + expect(state.toolRegistry.lastUpdated).toBeDefined(); + }); + + test('clearExecutionHistory should reset history', () => { + const executionId = 'exec_123'; + const threadId = 'thread_456'; + + // Add some history first + store.dispatch(startAgentExecution({ + executionId, + threadId, + userMessage: 'Test' + })); + + expect(store.getState().agent.executionHistory).toHaveLength(1); + + store.dispatch(clearExecutionHistory()); + + expect(store.getState().agent.executionHistory).toHaveLength(0); + }); + }); + + describe('async thunks', () => { + test('executeAgentTask.pending should set loading state', () => { + const action = { type: executeAgentTask.pending.type }; + const state = agentReducer(undefined, action); + + expect(state.isLoading).toBe(true); + expect(state.error).toBeNull(); + }); + + test('executeAgentTask.fulfilled should handle successful execution', () => { + const mockResult: AgentExecutionResult = { + status: 'completed', + executionId: 'exec_123', + finalResponse: 'Task completed', + iterations: 2, + toolExecutions: [], + executionTime: 3000 + }; + + const action = { + type: executeAgentTask.fulfilled.type, + payload: mockResult + }; + + const state = agentReducer(undefined, action); + + expect(state.isLoading).toBe(false); + expect(state.error).toBeNull(); + expect(state.lastExecutionId).toBe('exec_123'); + }); + + test('executeAgentTask.rejected should handle execution error', () => { + const action = { + type: executeAgentTask.rejected.type, + error: { message: 'Execution failed' } + }; + + const state = agentReducer(undefined, action); + + expect(state.isLoading).toBe(false); + expect(state.error).toBe('Execution failed'); + }); + + test('loadAgentTools.fulfilled should update tool registry', () => { + const mockTools: AgentToolSchema[] = [ + { + type: "function", + function: { + name: "test_tool", + description: "Test tool", + parameters: { type: "object", properties: {} } + } + } + ]; + + const action = { + type: loadAgentTools.fulfilled.type, + payload: mockTools + }; + + const state = agentReducer(undefined, action); + + expect(state.toolRegistry.tools).toEqual(mockTools); + expect(state.toolRegistry.isLoaded).toBe(true); + expect(state.toolRegistry.lastUpdated).toBeDefined(); + }); + + test('loadAgentTools.rejected should handle tool loading error', () => { + const action = { + type: loadAgentTools.rejected.type, + error: { message: 'Failed to load tools' } + }; + + const state = agentReducer(undefined, action); + + expect(state.toolRegistry.isLoaded).toBe(false); + expect(state.toolRegistry.error).toBe('Failed to load tools'); + }); + + test('cancelAgentExecutionThunk.fulfilled should cancel execution', () => { + // First set up an execution + const initialState: AgentState = { + isAgentMode: false, + isLoading: false, + error: null, + currentExecution: { + id: 'exec_123', + threadId: 'thread_456', + userMessage: 'Test', + status: 'running', + iterations: 1, + toolExecutions: [], + startTime: Date.now(), + executionTime: 0 + }, + executionHistory: [{ + id: 'exec_123', + threadId: 'thread_456', + userMessage: 'Test', + status: 'running', + iterations: 1, + toolExecutions: [], + startTime: Date.now(), + executionTime: 0 + }], + lastExecutionId: null, + toolRegistry: { + tools: [], + isLoaded: false, + lastUpdated: null, + error: null + }, + configByThreadId: {} + }; + + const action = { + type: cancelAgentExecutionThunk.fulfilled.type, + payload: { executionId: 'exec_123' } + }; + + const state = agentReducer(initialState, action); + + expect(state.currentExecution?.status).toBe('cancelled'); + expect(state.executionHistory[0].status).toBe('cancelled'); + }); + }); + + describe('selectors and derived state', () => { + test('should maintain execution history chronologically', () => { + const execution1 = { + executionId: 'exec_1', + threadId: 'thread_1', + userMessage: 'First task' + }; + + const execution2 = { + executionId: 'exec_2', + threadId: 'thread_1', + userMessage: 'Second task' + }; + + store.dispatch(startAgentExecution(execution1)); + store.dispatch(startAgentExecution(execution2)); + + const state = store.getState().agent; + expect(state.executionHistory).toHaveLength(2); + expect(state.executionHistory[0].id).toBe('exec_1'); + expect(state.executionHistory[1].id).toBe('exec_2'); + }); + + test('should track tool execution statistics', () => { + const executionId = 'exec_123'; + + store.dispatch(startAgentExecution({ + executionId, + threadId: 'thread_1', + userMessage: 'Test' + })); + + // Add multiple tool executions + const toolExecution1: AgentToolExecution = { + id: 'tool_1', + toolName: 'list_issues', + skillId: 'github', + arguments: '{}', + status: 'success', + startTime: Date.now() - 2000, + endTime: Date.now() - 1000, + executionTimeMs: 1000 + }; + + const toolExecution2: AgentToolExecution = { + id: 'tool_2', + toolName: 'create_page', + skillId: 'notion', + arguments: '{}', + status: 'success', + startTime: Date.now() - 1000, + endTime: Date.now(), + executionTimeMs: 1000 + }; + + store.dispatch(updateExecutionProgress({ + executionId, + iteration: 1, + toolExecution: toolExecution1 + })); + + store.dispatch(updateExecutionProgress({ + executionId, + iteration: 2, + toolExecution: toolExecution2 + })); + + const state = store.getState().agent; + expect(state.currentExecution?.toolExecutions).toHaveLength(2); + expect(state.currentExecution?.iterations).toBe(2); + }); + }); + + describe('error handling', () => { + test('should handle invalid execution updates gracefully', () => { + // Try to update non-existent execution + store.dispatch(updateExecutionProgress({ + executionId: 'non_existent', + iteration: 1, + toolExecution: { + id: 'tool_1', + toolName: 'test', + skillId: 'test', + arguments: '{}', + status: 'running', + startTime: Date.now() + } + })); + + // Should not crash and current execution should remain null + const state = store.getState().agent; + expect(state.currentExecution).toBeNull(); + }); + + test('should handle completion of non-existent execution gracefully', () => { + store.dispatch(completeAgentExecution({ + executionId: 'non_existent', + status: 'completed', + finalResponse: 'Done', + totalExecutionTime: 1000 + })); + + // Should not crash + const state = store.getState().agent; + expect(state.currentExecution).toBeNull(); + }); + }); + + describe('thread-specific configuration', () => { + test('should store and retrieve thread-specific config', () => { + const initialState = store.getState().agent; + expect(initialState.configByThreadId).toEqual({}); + + // Test that the structure exists for future configuration + // Note: actual config setting would require a specific action + expect(typeof initialState.configByThreadId).toBe('object'); + }); + }); +}); \ No newline at end of file diff --git a/src/store/agentSlice.ts b/src/store/agentSlice.ts new file mode 100644 index 000000000..775faa5e8 --- /dev/null +++ b/src/store/agentSlice.ts @@ -0,0 +1,433 @@ +/** + * Agent Redux slice for managing agent execution state + * + * Extends AlphaHuman's existing Redux pattern to handle agent task execution, + * tool executions, and agent configuration per thread. + */ + +import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit'; + +import { AgentLoop } from '../services/agentLoop'; +import { AgentToolRegistry } from '../services/agentToolRegistry'; +import type { + AgentState, + AgentExecution, + AgentExecutionResult, + AgentExecutionOptions, + AgentExecutionHistoryEntry, + AgentToolExecution, + AgentToolSchema +} from '../types/agent'; + +// ============================================================================= +// Async Thunks +// ============================================================================= + +/** + * Execute an agent task autonomously + */ +export const executeAgentTask = createAsyncThunk( + 'agent/executeTask', + async ( + params: { + userMessage: string; + threadId: string; + options?: AgentExecutionOptions; + }, + { getState, rejectWithValue } + ) => { + try { + const agentLoop = AgentLoop.getInstance(); + const result = await agentLoop.executeTask( + params.userMessage, + params.threadId, + params.options + ); + + return { + threadId: params.threadId, + userMessage: params.userMessage, + result, + timestamp: Date.now() + }; + } catch (error) { + return rejectWithValue( + error instanceof Error ? error.message : String(error) + ); + } + } +); + +/** + * Load available tools from the skill system + */ +export const loadAgentTools = createAsyncThunk( + 'agent/loadTools', + async (forceReload = false, { rejectWithValue }) => { + try { + const registry = AgentToolRegistry.getInstance(); + const tools = await registry.loadToolSchemas(forceReload); + return tools; + } catch (error) { + return rejectWithValue( + error instanceof Error ? error.message : String(error) + ); + } + } +); + +/** + * Cancel an active agent execution + */ +export const cancelAgentExecution = createAsyncThunk( + 'agent/cancelExecution', + async (executionId: string, { rejectWithValue }) => { + try { + const agentLoop = AgentLoop.getInstance(); + const cancelled = agentLoop.cancelExecution(executionId); + + if (!cancelled) { + throw new Error('Execution not found or already completed'); + } + + return executionId; + } catch (error) { + return rejectWithValue( + error instanceof Error ? error.message : String(error) + ); + } + } +); + +// ============================================================================= +// Initial State +// ============================================================================= + +const initialState: AgentState = { + agentModeByThreadId: {}, + activeExecutions: {}, + executionHistory: [], + configByThreadId: {}, + toolRegistry: { + tools: [], + lastUpdated: 0, + loading: false + }, + ui: { + showExecutionDetails: {}, + selectedExecution: undefined + } +}; + +// ============================================================================= +// Slice Definition +// ============================================================================= + +const agentSlice = createSlice({ + name: 'agent', + initialState, + reducers: { + // Agent mode management + setAgentModeForThread: ( + state, + action: PayloadAction<{ threadId: string; enabled: boolean }> + ) => { + const { threadId, enabled } = action.payload; + state.agentModeByThreadId[threadId] = enabled; + }, + + // Agent configuration management + setAgentConfigForThread: ( + state, + action: PayloadAction<{ threadId: string; config: AgentExecutionOptions }> + ) => { + const { threadId, config } = action.payload; + state.configByThreadId[threadId] = config; + }, + + // UI state management + toggleExecutionDetails: ( + state, + action: PayloadAction<{ executionId: string }> + ) => { + const { executionId } = action.payload; + state.ui.showExecutionDetails[executionId] = !state.ui.showExecutionDetails[executionId]; + }, + + setSelectedExecution: ( + state, + action: PayloadAction + ) => { + state.ui.selectedExecution = action.payload; + }, + + // Tool registry cache management + clearToolRegistry: (state) => { + state.toolRegistry = { + tools: [], + lastUpdated: 0, + loading: false + }; + }, + + // Execution tracking (for real-time updates) + addActiveExecution: ( + state, + action: PayloadAction + ) => { + const execution = action.payload; + state.activeExecutions[execution.id] = execution; + }, + + updateActiveExecution: ( + state, + action: PayloadAction & { id: string }> + ) => { + const { id, ...updates } = action.payload; + if (state.activeExecutions[id]) { + Object.assign(state.activeExecutions[id], updates); + } + }, + + removeActiveExecution: ( + state, + action: PayloadAction + ) => { + const executionId = action.payload; + delete state.activeExecutions[executionId]; + }, + + // Tool execution updates + addToolExecution: ( + state, + action: PayloadAction<{ executionId: string; toolExecution: AgentToolExecution }> + ) => { + const { executionId, toolExecution } = action.payload; + if (state.activeExecutions[executionId]) { + state.activeExecutions[executionId].toolExecutions.push(toolExecution); + state.activeExecutions[executionId].lastUpdate = Date.now(); + } + }, + + updateToolExecution: ( + state, + action: PayloadAction<{ + executionId: string; + toolExecutionId: string; + updates: Partial; + }> + ) => { + const { executionId, toolExecutionId, updates } = action.payload; + const execution = state.activeExecutions[executionId]; + + if (execution) { + const toolExecution = execution.toolExecutions.find(te => te.id === toolExecutionId); + if (toolExecution) { + Object.assign(toolExecution, updates); + execution.lastUpdate = Date.now(); + } + } + }, + + // Execution history management + addExecutionToHistory: ( + state, + action: PayloadAction + ) => { + state.executionHistory.unshift(action.payload); + + // Keep only last 100 executions + if (state.executionHistory.length > 100) { + state.executionHistory = state.executionHistory.slice(0, 100); + } + }, + + clearExecutionHistory: (state) => { + state.executionHistory = []; + } + }, + + extraReducers: (builder) => { + // Execute agent task + builder + .addCase(executeAgentTask.pending, (state, action) => { + const { userMessage, threadId } = action.meta.arg; + const executionId = `agent_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + const execution: AgentExecution = { + id: executionId, + threadId, + userMessage, + status: 'initializing', + currentIteration: 0, + maxIterations: action.meta.arg.options?.maxIterations || 10, + toolExecutions: [], + startTime: Date.now(), + lastUpdate: Date.now() + }; + + state.activeExecutions[executionId] = execution; + }) + .addCase(executeAgentTask.fulfilled, (state, action) => { + const { threadId, userMessage, result, timestamp } = action.payload; + + // Find the execution by thread and message + const execution = Object.values(state.activeExecutions).find( + exec => exec.threadId === threadId && exec.userMessage === userMessage + ); + + if (execution) { + // Move from active to history + const historyEntry: AgentExecutionHistoryEntry = { + executionId: execution.id, + threadId, + userMessage, + result, + timestamp, + duration: Date.now() - execution.startTime + }; + + state.executionHistory.unshift(historyEntry); + delete state.activeExecutions[execution.id]; + + // Keep only last 100 executions + if (state.executionHistory.length > 100) { + state.executionHistory = state.executionHistory.slice(0, 100); + } + } + }) + .addCase(executeAgentTask.rejected, (state, action) => { + // Remove failed execution from active list + const rejectedExecution = Object.values(state.activeExecutions).find( + exec => exec.userMessage === action.meta.arg.userMessage + ); + + if (rejectedExecution) { + delete state.activeExecutions[rejectedExecution.id]; + } + }); + + // Load agent tools + builder + .addCase(loadAgentTools.pending, (state) => { + state.toolRegistry.loading = true; + }) + .addCase(loadAgentTools.fulfilled, (state, action) => { + state.toolRegistry.tools = action.payload; + state.toolRegistry.lastUpdated = Date.now(); + state.toolRegistry.loading = false; + state.toolRegistry.error = undefined; + }) + .addCase(loadAgentTools.rejected, (state, action) => { + state.toolRegistry.loading = false; + state.toolRegistry.error = action.payload as string; + }); + + // Cancel agent execution + builder + .addCase(cancelAgentExecution.fulfilled, (state, action) => { + const executionId = action.payload; + if (state.activeExecutions[executionId]) { + state.activeExecutions[executionId].status = 'completing'; + } + }); + } +}); + +// ============================================================================= +// Actions Export +// ============================================================================= + +export const { + setAgentModeForThread, + setAgentConfigForThread, + toggleExecutionDetails, + setSelectedExecution, + clearToolRegistry, + addActiveExecution, + updateActiveExecution, + removeActiveExecution, + addToolExecution, + updateToolExecution, + addExecutionToHistory, + clearExecutionHistory +} = agentSlice.actions; + +// ============================================================================= +// Selectors +// ============================================================================= + +export const selectAgentModeForThread = (state: { agent: AgentState }, threadId: string) => + state.agent.agentModeByThreadId[threadId] || false; + +export const selectAgentConfigForThread = (state: { agent: AgentState }, threadId: string) => + state.agent.configByThreadId[threadId] || {}; + +export const selectActiveExecutions = (state: { agent: AgentState }) => + Object.values(state.agent.activeExecutions); + +export const selectActiveExecutionForThread = (state: { agent: AgentState }, threadId: string) => + Object.values(state.agent.activeExecutions).find(exec => exec.threadId === threadId); + +export const selectExecutionHistory = (state: { agent: AgentState }) => + state.agent.executionHistory; + +export const selectExecutionHistoryForThread = (state: { agent: AgentState }, threadId: string) => + state.agent.executionHistory.filter(entry => entry.threadId === threadId); + +export const selectToolRegistry = (state: { agent: AgentState }) => + state.agent.toolRegistry; + +export const selectAvailableTools = (state: { agent: AgentState }) => + state.agent.toolRegistry.tools; + +export const selectToolsByCategory = (state: { agent: AgentState }) => { + const toolsBySkill: Record = {}; + + for (const tool of state.agent.toolRegistry.tools) { + const skillId = (tool.function as any).skillId || 'unknown'; + if (!toolsBySkill[skillId]) { + toolsBySkill[skillId] = []; + } + toolsBySkill[skillId].push(tool); + } + + return toolsBySkill; +}; + +export const selectToolStats = (state: { agent: AgentState }) => { + const tools = state.agent.toolRegistry.tools; + const skillIds = new Set(); + const categories: Record = {}; + + for (const tool of tools) { + const skillId = (tool.function as any).skillId || 'unknown'; + skillIds.add(skillId); + + // Categorize by skill type + let category = 'Other'; + if (skillId.includes('github') || skillId.includes('git')) category = 'GitHub'; + else if (skillId.includes('notion')) category = 'Notion'; + else if (skillId.includes('telegram') || skillId.includes('tg')) category = 'Telegram'; + else if (skillId.includes('email') || skillId.includes('gmail')) category = 'Email'; + else if (skillId.includes('calendar')) category = 'Calendar'; + else if (skillId.includes('slack')) category = 'Slack'; + + categories[category] = (categories[category] || 0) + 1; + } + + return { + totalTools: tools.length, + skillCount: skillIds.size, + categories + }; +}; + +export const selectAgentUIState = (state: { agent: AgentState }) => + state.agent.ui; + +// ============================================================================= +// Reducer Export +// ============================================================================= + +export default agentSlice.reducer; \ No newline at end of file diff --git a/src/store/index.ts b/src/store/index.ts index 3d642173a..3d203447c 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -15,6 +15,7 @@ import storage from 'redux-persist/lib/storage'; import { setStoreForApiClient } from '../services/apiClient'; import { IS_DEV } from '../utils/config'; import { storeSession } from '../utils/tauriCommands'; +import agentReducer from './agentSlice'; import aiReducer from './aiSlice'; import authReducer, { setOnboardedForUser, setToken } from './authSlice'; import daemonReducer from './daemonSlice'; @@ -53,10 +54,18 @@ const threadPersistConfig = { whitelist: ['panelWidth', 'lastViewedAt', 'threads', 'messagesByThreadId', 'selectedThreadId'], }; +// Persist config for agent state (execution history, config, and agent mode) +const agentPersistConfig = { + key: 'agent', + storage, + whitelist: ['agentModeByThreadId', 'executionHistory', 'configByThreadId'], +}; + const persistedAuthReducer = persistReducer(authPersistConfig, authReducer); const persistedAiReducer = persistReducer(aiPersistConfig, aiReducer); const persistedSkillsReducer = persistReducer(skillsPersistConfig, skillsReducer); const persistedThreadReducer = persistReducer(threadPersistConfig, threadReducer); +const persistedAgentReducer = persistReducer(agentPersistConfig, agentReducer); /** * Middleware that syncs the JWT token to the Rust SESSION_SERVICE whenever @@ -102,6 +111,7 @@ export const store = configureStore({ thread: persistedThreadReducer, invite: inviteReducer, notion: notionReducer, + agent: persistedAgentReducer, }, middleware: getDefaultMiddleware => { const middleware = getDefaultMiddleware({ diff --git a/src/store/threadSlice.ts b/src/store/threadSlice.ts index d736e62f9..425bd0b7c 100644 --- a/src/store/threadSlice.ts +++ b/src/store/threadSlice.ts @@ -4,6 +4,8 @@ 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 { executeAgentTask, selectAgentModeForThread } from './agentSlice'; +import type { RootState } from './index'; interface ThreadState { // Existing local data (will be persisted) @@ -122,13 +124,39 @@ export const sendMessage = createAsyncThunk( // Continue with original message } - // 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. Check if agent mode is enabled for this thread + const state = getState() as RootState; + const agentMode = selectAgentModeForThread(state, threadId); - // 3. For now, we'll handle AI response via the existing inference API - // The AI response will be added separately via addInferenceResponse + if (agentMode) { + // Execute agent task instead of sending to inference API + console.log('🤖 Agent mode enabled - executing agent task'); - return data; + const agentResult = await dispatch(executeAgentTask({ + userMessage: message, + threadId, + options: state.agent.configByThreadId[threadId] || {} + })).unwrap(); + + // Add the agent's final response as an AI message with execution metadata + if (agentResult.result.finalResponse) { + dispatch(addInferenceResponse({ + content: agentResult.result.finalResponse, + agentExecutionId: agentResult.result.executionId, + toolExecutions: agentResult.result.toolExecutions + })); + } + + return agentResult; + } else { + // 4. Send to API with processed message (disable injection in threadApi to avoid double injection) + const data = await threadApi.sendMessage(processedMessage, threadId, { injectSoul: false }); + + // 5. For now, we'll handle AI response via the existing inference API + // The AI response will be added separately via addInferenceResponse + + return data; + } } catch (error) { // Remove optimistic user message on failure const state = (getState() as { thread: ThreadState }).thread; @@ -200,12 +228,17 @@ const threadSlice = createSlice({ createdAt: new Date().toISOString(), }); }, - addInferenceResponse: (state, action: { payload: { content: string } }) => { + addInferenceResponse: (state, action: { payload: { content: string; agentExecutionId?: string; toolExecutions?: any[] } }) => { const aiMessage: ThreadMessage = { id: `inference-${Date.now()}`, content: action.payload.content, type: 'text', - extraMetadata: {}, + extraMetadata: { + ...(action.payload.agentExecutionId && { + agentExecutionId: action.payload.agentExecutionId, + toolExecutions: action.payload.toolExecutions || [] + }) + }, sender: 'agent', createdAt: new Date().toISOString(), }; diff --git a/src/types/agent.ts b/src/types/agent.ts new file mode 100644 index 000000000..af3848355 --- /dev/null +++ b/src/types/agent.ts @@ -0,0 +1,379 @@ +/** + * Agent system types for AlphaHuman. + * Built on top of the existing skill system infrastructure. + */ + +import type { SkillToolDefinition } from '../lib/skills/types'; +import type { ThreadMessage, Thread } from './thread'; + +// ============================================================================= +// Agent Tool Types (extends skill tools) +// ============================================================================= + +/** + * Agent tool schema compatible with OpenAI function calling format + * and the existing skill tool system + */ +export interface AgentToolSchema { + type: 'function'; + function: { + name: string; + description: string; + parameters: { + type: 'object'; + properties: Record; + required?: string[]; + }; + }; +} + +export interface AgentToolParameter { + type: 'string' | 'number' | 'boolean' | 'array' | 'object'; + description?: string; + enum?: string[]; + items?: AgentToolParameter; + properties?: Record; + required?: string[]; + default?: any; + minimum?: number; + maximum?: number; + pattern?: string; +} + +/** + * Tool execution tracking for agent conversations + */ +export interface AgentToolExecution { + id: string; + toolName: string; + skillId: string; // Which skill provides this tool + arguments: string; // JSON string + result?: string; + status: AgentToolExecutionStatus; + startTime: number; + endTime?: number; + executionTimeMs?: number; + errorMessage?: string; + metadata?: { + retryCount?: number; + approvalRequired?: boolean; + approvalGranted?: boolean; + }; +} + +export type AgentToolExecutionStatus = + | 'pending' + | 'running' + | 'success' + | 'error' + | 'cancelled' + | 'timeout'; + +// ============================================================================= +// Agent Execution Types +// ============================================================================= + +/** + * Configuration options for agent task execution + */ +export interface AgentExecutionOptions { + maxIterations?: number; + timeoutMs?: number; + requireApproval?: boolean; + allowedSkills?: string[]; // Skill IDs that are allowed to execute + blockedTools?: string[]; // Specific tools that are blocked + retryFailedTools?: boolean; +} + +/** + * Result of an agent task execution + */ +export interface AgentExecutionResult { + status: AgentExecutionStatus; + executionId: string; + finalResponse?: string; + iterations: number; + toolExecutions: AgentToolExecution[]; + executionTime: number; + error?: string; + metadata?: { + tokensUsed?: number; + apiCalls?: number; + toolsAvailable?: number; + skillsInvolved?: string[]; + }; +} + +export type AgentExecutionStatus = + | 'completed' + | 'timeout' + | 'error' + | 'max_iterations' + | 'cancelled' + | 'blocked'; + +/** + * Active agent execution tracking + */ +export interface AgentExecution { + id: string; + threadId: string; + userMessage: string; + status: 'initializing' | 'running' | 'completing'; + currentIteration: number; + maxIterations: number; + toolExecutions: AgentToolExecution[]; + startTime: number; + lastUpdate: number; + abortController?: AbortController; +} + +// ============================================================================= +// OpenAI API Compatibility Types +// ============================================================================= + +/** + * OpenAI-compatible message format for backend communication + */ +export interface OpenAIMessage { + role: 'system' | 'user' | 'assistant' | 'tool'; + content: string | null; + name?: string; + tool_calls?: OpenAIToolCall[]; + tool_call_id?: string; +} + +export interface OpenAIToolCall { + id: string; + type: 'function'; + function: { + name: string; + arguments: string; // JSON string + }; +} + +export interface OpenAITool { + type: 'function'; + function: { + name: string; + description: string; + parameters: any; // JSON Schema + }; +} + +/** + * Chat completion request sent to backend + */ +export interface AgentChatRequest { + model: string; + messages: OpenAIMessage[]; + tools?: OpenAITool[]; + tool_choice?: 'auto' | 'none' | 'required'; + temperature?: number; + max_tokens?: number; +} + +/** + * Chat completion response from backend + */ +export interface AgentChatResponse { + id: string; + object: 'chat.completion'; + created: number; + model: string; + choices: AgentChatChoice[]; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + +export interface AgentChatChoice { + index: number; + message: OpenAIMessage; + finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter'; +} + +// ============================================================================= +// Thread System Integration +// ============================================================================= + +/** + * Enhanced thread message with agent execution metadata + */ +export interface AgentThreadMessage extends ThreadMessage { + // Existing ThreadMessage fields remain the same + // Enhanced extraMetadata for agent tracking + extraMetadata: ThreadMessage['extraMetadata'] & { + agentExecutionId?: string; + toolExecutions?: AgentToolExecution[]; + iterationNumber?: number; + agentStatus?: AgentExecutionStatus; + }; +} + +/** + * Enhanced thread with agent mode capability + */ +export interface AgentThread extends Thread { + // Existing Thread fields remain the same + // Additional agent-specific metadata + agentMode?: boolean; + lastAgentExecution?: string; + agentConfig?: AgentExecutionOptions; +} + +// ============================================================================= +// Redux State Types +// ============================================================================= + +/** + * Agent Redux state that integrates with existing skill system + */ +export interface AgentState { + // Agent mode enabled per thread + agentModeByThreadId: Record; + + // Active agent executions + activeExecutions: Record; + + // Agent execution history (persisted) + executionHistory: AgentExecutionHistoryEntry[]; + + // Agent configuration per thread (persisted) + configByThreadId: Record; + + // Tool registry cache (derived from skills) + toolRegistry: { + tools: AgentToolSchema[]; + lastUpdated: number; + loading: boolean; + error?: string; + }; + + // UI state (not persisted) + ui: { + showExecutionDetails: Record; + selectedExecution?: string; + }; +} + +export interface AgentExecutionHistoryEntry { + executionId: string; + threadId: string; + userMessage: string; + result: AgentExecutionResult; + timestamp: number; + duration: number; +} + +// ============================================================================= +// Service Interface Types +// ============================================================================= + +/** + * Tool registry service interface + */ +export interface IAgentToolRegistry { + loadToolSchemas(forceReload?: boolean): Promise; + executeTool(skillId: string, toolName: string, toolArguments: string): Promise; + getToolByName(toolName: string): AgentToolSchema | undefined; + getAllTools(): AgentToolSchema[]; + getToolsBySkill(): Record; +} + +/** + * Agent loop service interface + */ +export interface IAgentLoop { + executeTask( + userMessage: string, + threadId: string, + options?: AgentExecutionOptions + ): Promise; + + cancelExecution(executionId: string): boolean; + getActiveExecutions(): string[]; + getExecutionStatus(executionId: string): AgentExecution | null; +} + +// ============================================================================= +// Event Types +// ============================================================================= + +/** + * Agent execution events for real-time UI updates + */ +export type AgentEvent = + | AgentExecutionStartedEvent + | AgentIterationStartedEvent + | AgentToolExecutionStartedEvent + | AgentToolExecutionCompletedEvent + | AgentExecutionCompletedEvent + | AgentExecutionErrorEvent; + +export interface AgentExecutionStartedEvent { + type: 'AGENT_EXECUTION_STARTED'; + executionId: string; + threadId: string; + userMessage: string; + timestamp: number; +} + +export interface AgentIterationStartedEvent { + type: 'AGENT_ITERATION_STARTED'; + executionId: string; + iteration: number; + timestamp: number; +} + +export interface AgentToolExecutionStartedEvent { + type: 'AGENT_TOOL_EXECUTION_STARTED'; + executionId: string; + toolExecution: AgentToolExecution; + timestamp: number; +} + +export interface AgentToolExecutionCompletedEvent { + type: 'AGENT_TOOL_EXECUTION_COMPLETED'; + executionId: string; + toolExecution: AgentToolExecution; + timestamp: number; +} + +export interface AgentExecutionCompletedEvent { + type: 'AGENT_EXECUTION_COMPLETED'; + executionId: string; + result: AgentExecutionResult; + timestamp: number; +} + +export interface AgentExecutionErrorEvent { + type: 'AGENT_EXECUTION_ERROR'; + executionId: string; + error: AgentError; + timestamp: number; +} + +// ============================================================================= +// Error Types +// ============================================================================= + +export interface AgentError extends Error { + type: AgentErrorType; + code?: string; + details?: Record; + retryable?: boolean; +} + +export type AgentErrorType = + | 'TOOL_EXECUTION_ERROR' + | 'TOOL_NOT_FOUND' + | 'SKILL_NOT_AVAILABLE' + | 'AGENT_TIMEOUT' + | 'MAX_ITERATIONS_EXCEEDED' + | 'API_ERROR' + | 'VALIDATION_ERROR' + | 'NETWORK_ERROR' + | 'UNKNOWN_ERROR'; \ No newline at end of file