From ae187de9d1f2ffed9f6a871f3f67cbe72356856d Mon Sep 17 00:00:00 2001
From: cyrus
Date: Tue, 10 Mar 2026 00:40:00 +0530
Subject: [PATCH] feat: introduce agent system types, execution panel, and
service tests
- Added new `AgentSystem` types to support enhanced agent execution, tool tracking, API compatibility, and integration with thread system.
- Developed `AgentExecutionPanel` React component for detailed real-time monitoring of agent execution status and history.
- Created comprehensive tests for `AgentLoopService` covering execution flow, tool integration, error handling, and timeout scenarios.
---
.gitignore | 2 +
src-tauri/src/commands/runtime.rs | 452 +++++++++++++++
src-tauri/src/lib.rs | 6 +
src/components/agent/AgentExecutionPanel.tsx | 236 ++++++++
src/components/agent/AgentStatusIndicator.tsx | 96 ++++
src/components/agent/AgentToggle.tsx | 162 ++++++
src/components/agent/index.ts | 13 +
src/pages/Conversations.tsx | 17 +
src/services/__tests__/agentLoop.test.ts | 541 ++++++++++++++++++
.../__tests__/agentToolRegistry.test.ts | 367 ++++++++++++
src/services/agentLoop.ts | 388 +++++++++++++
src/services/agentToolRegistry.ts | 256 +++++++++
src/store/__tests__/agentSlice.test.ts | 496 ++++++++++++++++
src/store/agentSlice.ts | 433 ++++++++++++++
src/store/index.ts | 10 +
src/store/threadSlice.ts | 47 +-
src/types/agent.ts | 379 ++++++++++++
17 files changed, 3894 insertions(+), 7 deletions(-)
create mode 100644 src/components/agent/AgentExecutionPanel.tsx
create mode 100644 src/components/agent/AgentStatusIndicator.tsx
create mode 100644 src/components/agent/AgentToggle.tsx
create mode 100644 src/components/agent/index.ts
create mode 100644 src/services/__tests__/agentLoop.test.ts
create mode 100644 src/services/__tests__/agentToolRegistry.test.ts
create mode 100644 src/services/agentLoop.ts
create mode 100644 src/services/agentToolRegistry.ts
create mode 100644 src/store/__tests__/agentSlice.test.ts
create mode 100644 src/store/agentSlice.ts
create mode 100644 src/types/agent.ts
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 (
+
+ );
+ }
+
+ 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 */}
+
+
+ {/* 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 */}
+
+
+
+ {/* Loading indicator */}
+ {isLoading && (
+
+ )}
+
+
+ {/* 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