mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<String>,
|
||||
pub execution_time: Option<u64>,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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<RuntimeEngine>>,
|
||||
) -> Result<Vec<ZeroClawToolSchema>, 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<RuntimeEngine>>,
|
||||
tool_id: String,
|
||||
args: serde_json::Value,
|
||||
) -> Result<ZeroClawToolResult, String> {
|
||||
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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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<serde_json::Value, String> {
|
||||
// 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<String, String> {
|
||||
Err(MOBILE_ERROR.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn runtime_get_tool_schemas() -> Result<Vec<ZeroClawToolSchema>, String> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn runtime_execute_tool(
|
||||
_tool_id: String,
|
||||
_args: serde_json::Value,
|
||||
) -> Result<ZeroClawToolResult, String> {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<svg className="w-4 h-4 text-canvas-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
case 'running':
|
||||
return (
|
||||
<div className="w-4 h-4 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
|
||||
);
|
||||
case 'success':
|
||||
return (
|
||||
<svg className="w-4 h-4 text-sage-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<svg className="w-4 h-4 text-coral-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="w-4 h-4 bg-canvas-300 rounded-full" />
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const ToolExecutionItem = memo<{ toolExecution: AgentToolExecution }>(({ toolExecution }) => {
|
||||
const duration = toolExecution.executionTimeMs || (toolExecution.endTime ? toolExecution.endTime - toolExecution.startTime : null);
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 p-3 bg-canvas-50 rounded-lg border border-canvas-200">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{getStatusIcon(toolExecution.status)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-canvas-900">{toolExecution.toolName}</span>
|
||||
<span className="text-xs text-canvas-500 bg-canvas-200 px-2 py-0.5 rounded">
|
||||
{toolExecution.skillId}
|
||||
</span>
|
||||
{duration && (
|
||||
<span className="text-xs text-canvas-500">
|
||||
{formatDuration(duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Arguments */}
|
||||
{toolExecution.arguments && (
|
||||
<div className="mb-2">
|
||||
<span className="text-xs font-medium text-canvas-600 block mb-1">Arguments:</span>
|
||||
<pre className="text-xs text-canvas-700 bg-canvas-100 p-2 rounded border overflow-x-auto">
|
||||
{JSON.stringify(JSON.parse(toolExecution.arguments), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{toolExecution.result && (
|
||||
<div className="mb-2">
|
||||
<span className="text-xs font-medium text-canvas-600 block mb-1">Result:</span>
|
||||
<div className="text-sm text-canvas-800 bg-white p-2 rounded border">
|
||||
{toolExecution.result}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{toolExecution.errorMessage && (
|
||||
<div className="mb-2">
|
||||
<span className="text-xs font-medium text-coral-600 block mb-1">Error:</span>
|
||||
<div className="text-sm text-coral-700 bg-coral-50 p-2 rounded border border-coral-200">
|
||||
{toolExecution.errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ToolExecutionItem.displayName = 'ToolExecutionItem';
|
||||
|
||||
const AgentExecutionPanel = memo<AgentExecutionPanelProps>(({
|
||||
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 (
|
||||
<div className={`border border-canvas-200 rounded-lg bg-white ${className}`}>
|
||||
<div className="p-4 border-b border-canvas-200">
|
||||
<h3 className="text-sm font-semibold text-canvas-900">Agent Execution Details</h3>
|
||||
</div>
|
||||
|
||||
<div className={`overflow-y-auto`} style={{ maxHeight }}>
|
||||
{/* Active Execution */}
|
||||
{activeExecution && (
|
||||
<div className="p-4 border-b border-canvas-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-medium text-canvas-800">Current Execution</h4>
|
||||
<span className="text-xs text-canvas-500">
|
||||
Running for {formatDuration(Date.now() - activeExecution.startTime)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="text-xs text-canvas-600 mb-1">Progress:</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-canvas-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${(activeExecution.currentIteration / activeExecution.maxIterations) * 100}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-canvas-600 font-medium">
|
||||
{activeExecution.currentIteration}/{activeExecution.maxIterations}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tool Executions */}
|
||||
{sortedToolExecutions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-canvas-600 mb-2">
|
||||
Tool Executions ({sortedToolExecutions.length}):
|
||||
</div>
|
||||
{sortedToolExecutions.map(toolExecution => (
|
||||
<ToolExecutionItem key={toolExecution.id} toolExecution={toolExecution} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution History */}
|
||||
{recentHistory.length > 0 && (
|
||||
<div className="p-4">
|
||||
<h4 className="text-sm font-medium text-canvas-800 mb-3">Recent Executions</h4>
|
||||
<div className="space-y-2">
|
||||
{recentHistory.map(entry => (
|
||||
<div
|
||||
key={entry.executionId}
|
||||
className="flex items-center justify-between p-2 bg-canvas-50 rounded border"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
entry.result.status === 'completed' ? 'bg-sage-500' :
|
||||
entry.result.status === 'error' ? 'bg-coral-500' : 'bg-amber-500'
|
||||
}`} />
|
||||
<span className="text-xs text-canvas-700 font-medium">
|
||||
{entry.result.status}
|
||||
</span>
|
||||
<span className="text-xs text-canvas-500">
|
||||
{entry.result.toolExecutions.length} tools
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-canvas-500">
|
||||
{formatDuration(entry.duration)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!activeExecution && recentHistory.length === 0 && (
|
||||
<div className="p-6 text-center text-canvas-500">
|
||||
<svg className="w-8 h-8 mx-auto mb-2 text-canvas-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<p className="text-sm">No agent executions yet</p>
|
||||
<p className="text-xs mt-1">Send a message to start an agent task</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AgentExecutionPanel.displayName = 'AgentExecutionPanel';
|
||||
|
||||
export default AgentExecutionPanel;
|
||||
@@ -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<AgentStatusIndicatorProps>(({ 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 (
|
||||
<div className={`flex items-center gap-2 text-canvas-600 ${className}`}>
|
||||
<div className="w-2 h-2 bg-sage-500 rounded-full"></div>
|
||||
<span className="text-xs font-medium">Agent Ready</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={`flex items-center gap-3 ${className}`}>
|
||||
{/* Status indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${getStatusColor()}`}></div>
|
||||
<span className="text-xs font-medium text-canvas-700">{getStatusText()}</span>
|
||||
</div>
|
||||
|
||||
{/* Tool execution info */}
|
||||
{toolCount > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-canvas-600">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
<span>{toolCount} tools</span>
|
||||
{runningTools > 0 && (
|
||||
<span className="text-primary-600">• {runningTools} running</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution time */}
|
||||
<div className="text-xs text-canvas-500">
|
||||
{Math.floor((Date.now() - activeExecution.startTime) / 1000)}s
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AgentStatusIndicator.displayName = 'AgentStatusIndicator';
|
||||
|
||||
export default AgentStatusIndicator;
|
||||
@@ -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<AgentToggleProps>(({
|
||||
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 (
|
||||
<div className={`flex items-center gap-3 ${className}`}>
|
||||
{/* Toggle Switch */}
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={isDisabled}
|
||||
className={`
|
||||
relative inline-flex items-center ${sizeClasses.container} rounded-full
|
||||
transition-colors duration-200 ease-in-out
|
||||
${agentMode
|
||||
? 'bg-primary-500 hover:bg-primary-600'
|
||||
: 'bg-canvas-300 hover:bg-canvas-400'
|
||||
}
|
||||
${isDisabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2'
|
||||
}
|
||||
`}
|
||||
aria-label={`${agentMode ? 'Disable' : 'Enable'} agent mode`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
${sizeClasses.toggle} inline-block rounded-full bg-white shadow-sm
|
||||
transform transition-transform duration-200 ease-in-out
|
||||
${agentMode ? sizeClasses.translate : 'translate-x-0.5'}
|
||||
`}
|
||||
/>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Label and Status */}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-medium ${agentMode ? 'text-canvas-900' : 'text-canvas-600'}`}>
|
||||
Agent Mode
|
||||
</span>
|
||||
|
||||
{agentMode && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-primary-100 text-primary-700 rounded-full">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Configuration hint */}
|
||||
{agentMode && !activeExecution && (
|
||||
<div className="text-xs text-canvas-500 mt-0.5">
|
||||
{agentConfig.maxIterations ? `Max ${agentConfig.maxIterations} iterations` : 'Default settings'}
|
||||
{agentConfig.allowedSkills && agentConfig.allowedSkills.length > 0 &&
|
||||
` • ${agentConfig.allowedSkills.length} skills allowed`
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active execution status */}
|
||||
{activeExecution && (
|
||||
<div className="text-xs text-primary-600 mt-0.5 font-medium">
|
||||
Running iteration {activeExecution.currentIteration}/{activeExecution.maxIterations}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AgentToggle.displayName = 'AgentToggle';
|
||||
|
||||
export default AgentToggle;
|
||||
@@ -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';
|
||||
@@ -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)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<AgentToggle threadId={selectedThread.id} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
@@ -739,6 +743,19 @@ const Conversations = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Status and Execution Panel */}
|
||||
<div className="flex-shrink-0">
|
||||
<AgentStatusIndicator
|
||||
threadId={selectedThread.id}
|
||||
className="px-4 py-2 border-t border-white/10"
|
||||
/>
|
||||
<AgentExecutionPanel
|
||||
threadId={selectedThread.id}
|
||||
className="border-t border-white/10"
|
||||
maxHeight="200px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message Input */}
|
||||
<div className="flex-shrink-0 border-t border-white/10 px-4 py-3">
|
||||
{/* Model selector */}
|
||||
|
||||
@@ -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<typeof AgentToolRegistry>;
|
||||
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
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, AbortController>();
|
||||
|
||||
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<AgentExecutionResult> {
|
||||
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<AgentChatResponse>(
|
||||
`/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<AgentToolExecution> {
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<AgentToolSchema[]> {
|
||||
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<ZeroClawToolSchema[]>('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<AgentToolExecution> {
|
||||
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<ZeroClawToolResult>('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<string, AgentToolSchema[]> {
|
||||
const toolsBySkill: Record<string, AgentToolSchema[]> = {};
|
||||
|
||||
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<string, number>;
|
||||
} {
|
||||
const categories: Record<string, number> = {};
|
||||
const skills = new Set<string>();
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -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<typeof configureStore>;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string | undefined>
|
||||
) => {
|
||||
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<AgentExecution>
|
||||
) => {
|
||||
const execution = action.payload;
|
||||
state.activeExecutions[execution.id] = execution;
|
||||
},
|
||||
|
||||
updateActiveExecution: (
|
||||
state,
|
||||
action: PayloadAction<Partial<AgentExecution> & { id: string }>
|
||||
) => {
|
||||
const { id, ...updates } = action.payload;
|
||||
if (state.activeExecutions[id]) {
|
||||
Object.assign(state.activeExecutions[id], updates);
|
||||
}
|
||||
},
|
||||
|
||||
removeActiveExecution: (
|
||||
state,
|
||||
action: PayloadAction<string>
|
||||
) => {
|
||||
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<AgentToolExecution>;
|
||||
}>
|
||||
) => {
|
||||
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<AgentExecutionHistoryEntry>
|
||||
) => {
|
||||
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<string, AgentToolSchema[]> = {};
|
||||
|
||||
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<string>();
|
||||
const categories: Record<string, number> = {};
|
||||
|
||||
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;
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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<string, AgentToolParameter>;
|
||||
required?: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentToolParameter {
|
||||
type: 'string' | 'number' | 'boolean' | 'array' | 'object';
|
||||
description?: string;
|
||||
enum?: string[];
|
||||
items?: AgentToolParameter;
|
||||
properties?: Record<string, AgentToolParameter>;
|
||||
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<string, boolean>;
|
||||
|
||||
// Active agent executions
|
||||
activeExecutions: Record<string, AgentExecution>;
|
||||
|
||||
// Agent execution history (persisted)
|
||||
executionHistory: AgentExecutionHistoryEntry[];
|
||||
|
||||
// Agent configuration per thread (persisted)
|
||||
configByThreadId: Record<string, AgentExecutionOptions>;
|
||||
|
||||
// Tool registry cache (derived from skills)
|
||||
toolRegistry: {
|
||||
tools: AgentToolSchema[];
|
||||
lastUpdated: number;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
// UI state (not persisted)
|
||||
ui: {
|
||||
showExecutionDetails: Record<string, boolean>;
|
||||
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<AgentToolSchema[]>;
|
||||
executeTool(skillId: string, toolName: string, toolArguments: string): Promise<AgentToolExecution>;
|
||||
getToolByName(toolName: string): AgentToolSchema | undefined;
|
||||
getAllTools(): AgentToolSchema[];
|
||||
getToolsBySkill(): Record<string, AgentToolSchema[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent loop service interface
|
||||
*/
|
||||
export interface IAgentLoop {
|
||||
executeTask(
|
||||
userMessage: string,
|
||||
threadId: string,
|
||||
options?: AgentExecutionOptions
|
||||
): Promise<AgentExecutionResult>;
|
||||
|
||||
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<string, any>;
|
||||
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';
|
||||
Reference in New Issue
Block a user