Merge remote-tracking branch 'substream/develop' into feat/set-tool-call

This commit is contained in:
M3gA-Mind
2026-03-10 03:29:31 +05:30
11 changed files with 1704 additions and 34 deletions
+2
View File
@@ -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
+489
View File
@@ -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,201 @@ mod desktop {
.to_string_lossy()
.to_string())
}
// =============================================================================
// ZeroClaw Format Compatibility Commands
// =============================================================================
/// Generate ZeroClaw-compatible tool schemas from all available QuickJS tools.
/// This bridges the gap between QuickJS runtime and OpenAI function calling format.
#[tauri::command]
pub async fn runtime_get_tool_schemas(
engine: State<'_, Arc<RuntimeEngine>>,
) -> Result<Vec<ZeroClawToolSchema>, String> {
log::info!("🔧 [RUNTIME] Generating ZeroClaw-compatible tool schemas");
let tools = engine.all_tools();
log::info!("🔧 [RUNTIME] Found {} tools from engine", tools.len());
let mut schemas = Vec::new();
for (skill_id, tool) in tools {
// Extract tool information from ToolDefinition struct
let description = if tool.description.is_empty() {
"No description available".to_string()
} else {
tool.description.clone()
};
let tool_name = format!("{}_{}", skill_id, tool.name);
log::info!("🔧 [RUNTIME] Processing tool: {}", tool_name);
// Convert input schema to OpenAI-compatible format
let openai_parameters = convert_to_openai_schema(tool.input_schema)?;
let schema = ZeroClawToolSchema {
type_field: "function".to_string(),
function: ZeroClawFunction {
name: tool_name,
description,
parameters: openai_parameters,
},
};
schemas.push(schema);
}
log::info!("🔧 [RUNTIME] Generated {} ZeroClaw tool schemas", schemas.len());
// Log tools that contain 'notion' or 'gmail' for debugging
let gmail_notion_tools: Vec<String> = schemas.iter()
.map(|s| &s.function.name)
.filter(|name| name.to_lowercase().contains("gmail") || name.to_lowercase().contains("notion"))
.cloned()
.collect();
log::info!("🔧 [RUNTIME] Gmail/Notion tools found: {:?}", gmail_notion_tools);
Ok(schemas)
}
/// Execute a specific tool based on agent decision with enhanced validation.
/// This wraps the existing runtime_call_tool with ZeroClaw format compatibility.
#[tauri::command]
pub async fn runtime_execute_tool(
engine: State<'_, Arc<RuntimeEngine>>,
tool_id: String,
args: serde_json::Value,
) -> Result<ZeroClawToolResult, String> {
let start_time = std::time::Instant::now();
log::info!("🔧 [RUNTIME] Executing ZeroClaw tool: {} with args: {}", tool_id, args);
// Parse tool_id to get skill_id and tool_name (format: "skill_id_tool_name")
let (skill_id, tool_name) = match parse_tool_id(&tool_id) {
Ok((skill, tool)) => {
log::info!("🔧 [RUNTIME] Parsed tool_id: skill_id='{}', tool_name='{}'", skill, tool);
(skill, tool)
}
Err(e) => {
log::error!("🔧 [RUNTIME] Failed to parse tool_id '{}': {}", tool_id, e);
let execution_time = start_time.elapsed().as_millis() as u64;
return Ok(ZeroClawToolResult {
success: false,
output: String::new(),
error: Some(format!("Invalid tool ID format: {}", e)),
execution_time: Some(execution_time),
});
}
};
// Log runtime state before execution
log::info!("🔧 [RUNTIME] Attempting to call tool '{}' on skill '{}'", tool_name, skill_id);
// Get available skills for debugging
let skills = engine.list_skills();
log::info!("🔧 [RUNTIME] Available skills: {:?}", skills.iter().map(|s| &s.skill_id).collect::<Vec<_>>());
// Check if the specific skill exists
if let Some(skill) = skills.iter().find(|s| s.skill_id == skill_id) {
log::info!("🔧 [RUNTIME] Found skill '{}' with state: {:?}, tools: {:?}",
skill_id, skill.state, skill.tools.iter().map(|t| &t.name).collect::<Vec<_>>());
} else {
log::error!("🔧 [RUNTIME] Skill '{}' not found in runtime!", skill_id);
}
// Execute the tool using the existing command
log::info!("🔧 [RUNTIME] Calling engine.call_tool('{}', '{}', {})", skill_id, tool_name, args);
match engine.call_tool(&skill_id, &tool_name, args).await {
Ok(result) => {
let execution_time = start_time.elapsed().as_millis() as u64;
log::info!("🔧 [RUNTIME] Tool execution completed in {}ms, is_error: {}", execution_time, result.is_error);
if result.is_error {
let error_message = result.content
.iter()
.filter(|c| matches!(c, crate::runtime::types::ToolContent::Text { .. }))
.map(|c| match c {
crate::runtime::types::ToolContent::Text { text } => text.as_str(),
_ => "",
})
.collect::<Vec<_>>()
.join("\n");
log::error!("🔧 [RUNTIME] Tool execution failed with error: {}", error_message);
Ok(ZeroClawToolResult {
success: false,
output: String::new(),
error: Some(error_message),
execution_time: Some(execution_time),
})
} else {
let output = result.content
.iter()
.map(|c| match c {
crate::runtime::types::ToolContent::Text { text } => text.clone(),
crate::runtime::types::ToolContent::Json { data } => {
serde_json::to_string(data).unwrap_or_else(|_| "Invalid JSON".to_string())
}
})
.collect::<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!("🔧 [RUNTIME] Engine call_tool failed: {}", e);
Ok(ZeroClawToolResult {
success: false,
output: String::new(),
error: Some(e),
execution_time: Some(execution_time),
})
}
}
}
// Helper function to parse tool_id format: "skill_id_tool_name"
pub fn parse_tool_id(tool_id: &str) -> Result<(String, String), String> {
// Find the first underscore to separate skill_id from tool_name
if let Some(underscore_pos) = tool_id.find('_') {
let skill_id = tool_id[..underscore_pos].to_string();
let tool_name = tool_id[underscore_pos + 1..].to_string();
if skill_id.is_empty() || tool_name.is_empty() {
return Err("Tool ID must be in format 'skill_id_tool_name'".to_string());
}
Ok((skill_id, tool_name))
} else {
Err("Tool ID must contain an underscore separator".to_string())
}
}
// Helper function to convert MCP schema to OpenAI function calling format
pub fn convert_to_openai_schema(mcp_schema: serde_json::Value) -> Result<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 +602,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 +668,256 @@ pub use desktop::*;
#[cfg(any(target_os = "android", target_os = "ios"))]
pub use mobile::*;
// =============================================================================
// Tests
// =============================================================================
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
mod desktop_tests {
use super::*;
use crate::runtime::qjs_engine::RuntimeEngine;
#[tokio::test]
async fn test_runtime_get_tool_schemas_format() {
// Note: This test requires a properly initialized RuntimeEngine
// In a real test environment, you would mock the engine or use a test instance
// For now, we'll test the struct format and serialization
let schema = ZeroClawToolSchema {
type_field: "function".to_string(),
function: ZeroClawFunction {
name: "test_tool".to_string(),
description: "A test tool".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "Test message"
}
},
"required": ["message"]
})
}
};
// Test serialization
let json = serde_json::to_string(&schema).expect("Should serialize to JSON");
assert!(json.contains("function"));
assert!(json.contains("test_tool"));
assert!(json.contains("A test tool"));
// Test deserialization
let deserialized: ZeroClawToolSchema = serde_json::from_str(&json)
.expect("Should deserialize from JSON");
assert_eq!(deserialized.type_field, "function");
assert_eq!(deserialized.function.name, "test_tool");
}
#[tokio::test]
async fn test_zeroclaw_tool_result_format() {
let result = ZeroClawToolResult {
success: true,
output: "Test output".to_string(),
error: None,
execution_time: Some(1500)
};
// Test serialization
let json = serde_json::to_string(&result).expect("Should serialize to JSON");
assert!(json.contains("true"));
assert!(json.contains("Test output"));
assert!(json.contains("1500"));
// Test error case
let error_result = ZeroClawToolResult {
success: false,
output: String::new(),
error: Some("Tool not found".to_string()),
execution_time: Some(100)
};
let error_json = serde_json::to_string(&error_result).expect("Should serialize error");
assert!(json.contains("false") || error_json.contains("false"));
assert!(error_json.contains("Tool not found"));
}
#[test]
fn test_parse_tool_id_valid_formats() {
// Test valid tool ID formats
let (skill_id, tool_name) = desktop::parse_tool_id("github_list_issues")
.expect("Should parse valid tool ID");
assert_eq!(skill_id, "github");
assert_eq!(tool_name, "list_issues");
let (skill_id, tool_name) = desktop::parse_tool_id("notion_create_page")
.expect("Should parse valid tool ID");
assert_eq!(skill_id, "notion");
assert_eq!(tool_name, "create_page");
// Test complex skill names (first underscore separates skill_id from tool_name)
let (skill_id, tool_name) = desktop::parse_tool_id("complex_skill_name_tool_function")
.expect("Should parse complex tool ID");
assert_eq!(skill_id, "complex");
assert_eq!(tool_name, "skill_name_tool_function");
}
#[test]
fn test_parse_tool_id_invalid_formats() {
// Test invalid formats
assert!(desktop::parse_tool_id("nounderscore").is_err(), "Should fail for no underscore");
assert!(desktop::parse_tool_id("_empty_skill").is_err(), "Should fail for empty skill ID");
assert!(desktop::parse_tool_id("empty_tool_").is_err(), "Should fail for empty tool name");
assert!(desktop::parse_tool_id("").is_err(), "Should fail for empty string");
}
#[test]
fn test_convert_to_openai_schema() {
// Test MCP schema to OpenAI conversion
let mcp_schema = serde_json::json!({
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"}
},
"required": ["owner", "repo"]
});
let openai_schema = desktop::convert_to_openai_schema(mcp_schema)
.expect("Should convert MCP to OpenAI schema");
assert_eq!(openai_schema["type"], "object");
assert!(openai_schema["properties"].is_object());
assert!(openai_schema["required"].is_array());
// Test already OpenAI format (should pass through)
let existing_openai = serde_json::json!({
"type": "object",
"properties": {"test": {"type": "string"}},
"required": ["test"]
});
let result = desktop::convert_to_openai_schema(existing_openai.clone())
.expect("Should handle existing OpenAI format");
assert_eq!(result, existing_openai);
}
#[test]
fn test_zeroclaw_format_compliance() {
// Test that our ZeroClaw format matches expected OpenAI structure
let schema = ZeroClawToolSchema {
type_field: "function".to_string(),
function: ZeroClawFunction {
name: "github_list_issues".to_string(),
description: "List GitHub issues for a repository".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"owner": {"type": "string", "description": "Repository owner"},
"repo": {"type": "string", "description": "Repository name"},
"state": {"type": "string", "enum": ["open", "closed", "all"], "default": "open"}
},
"required": ["owner", "repo"]
})
}
};
// Serialize and check format
let json = serde_json::to_value(&schema).expect("Should serialize");
// Check OpenAI compatibility
assert_eq!(json["type"], "function");
assert!(json["function"].is_object());
assert!(json["function"]["name"].is_string());
assert!(json["function"]["description"].is_string());
assert!(json["function"]["parameters"].is_object());
// Check parameter schema
let params = &json["function"]["parameters"];
assert_eq!(params["type"], "object");
assert!(params["properties"].is_object());
assert!(params["required"].is_array());
}
}
#[cfg(any(target_os = "android", target_os = "ios"))]
mod mobile_tests {
use super::*;
#[tokio::test]
async fn test_mobile_stub_runtime_get_tool_schemas() {
let result = mobile::runtime_get_tool_schemas().await;
// Mobile should return empty list with helpful error
assert!(result.is_err());
assert!(result.unwrap_err().contains("not available on mobile"));
}
#[tokio::test]
async fn test_mobile_stub_runtime_execute_tool() {
let result = mobile::runtime_execute_tool(
"test_tool".to_string(),
"{}".to_string()
).await;
// Mobile should return error
assert!(result.is_err());
assert!(result.unwrap_err().contains("not available on mobile"));
}
}
#[test]
fn test_zeroclaw_struct_defaults() {
// Test that ZeroClaw structs can be created with serde_json
let tool_schema: ZeroClawToolSchema = serde_json::from_value(serde_json::json!({
"type": "function",
"function": {
"name": "test",
"description": "test",
"parameters": {}
}
})).expect("Should deserialize from JSON");
assert_eq!(tool_schema.type_field, "function");
assert_eq!(tool_schema.function.name, "test");
// Test tool result
let tool_result: ZeroClawToolResult = serde_json::from_value(serde_json::json!({
"success": true,
"output": "result",
"error": null,
"execution_time": 1000
})).expect("Should deserialize tool result");
assert!(tool_result.success);
assert_eq!(tool_result.output, "result");
assert_eq!(tool_result.execution_time, Some(1000));
}
#[test]
fn test_error_handling_structures() {
// Test that error scenarios can be properly serialized
let error_result = ZeroClawToolResult {
success: false,
output: String::new(),
error: Some("Connection timeout".to_string()),
execution_time: Some(30000) // 30 second timeout
};
let json = serde_json::to_string(&error_result).expect("Should serialize error");
assert!(json.contains("false"));
assert!(json.contains("Connection timeout"));
assert!(json.contains("30000"));
// Test deserialization back
let parsed: ZeroClawToolResult = serde_json::from_str(&json)
.expect("Should parse error result");
assert!(!parsed.success);
assert_eq!(parsed.error, Some("Connection timeout".to_string()));
}
}
+6
View File
@@ -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,
+5 -2
View File
@@ -120,7 +120,10 @@ const Conversations = () => {
const notionPages = useAppSelector(state => state.notion.pages);
const notionSummaries = useAppSelector(state => state.notion.summaries);
const notionWorkspaceName = useAppSelector(
state => (state.skills.skillStates?.notion as Record<string, unknown> | undefined)?.workspaceName as string | null ?? null
state =>
((state.skills.skillStates?.notion as Record<string, unknown> | undefined)?.workspaceName as
| string
| null) ?? null
);
const [showPurgeConfirm, setShowPurgeConfirm] = useState(false);
@@ -347,7 +350,7 @@ const Conversations = () => {
const chatMessages: ChatMessage[] = [
...historySnapshot.map(m => ({
role: (m.sender === 'user' ? 'user' : 'assistant') as 'user' | 'assistant',
role: (m.sender === 'user' ? 'user' : 'assistant') as ChatMessage['role'],
content: m.content,
})),
{ role: 'user' as const, content: processedUserContent },
+541
View File
@@ -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);
});
});
});
+275
View File
@@ -0,0 +1,275 @@
/**
* Agent Tool Registry Service
*
* Builds on top of the existing skill system to provide agent-compatible
* tool discovery and execution. Uses ZeroClaw format compatibility commands:
* - runtime_get_tool_schemas: Get all tools in OpenAI-compatible format
* - runtime_execute_tool: Execute a tool with enhanced validation and timing
*/
import { invoke } from '@tauri-apps/api/core';
import type {
AgentToolSchema,
AgentToolExecution,
IAgentToolRegistry
} from '../types/agent';
// ZeroClaw format types from Rust
interface ZeroClawToolSchema {
type: string;
function: {
name: string;
description: string;
parameters: any;
};
}
interface ZeroClawToolResult {
success: boolean;
output: string;
error?: string;
execution_time?: number;
}
export class AgentToolRegistry implements IAgentToolRegistry {
private static instance: AgentToolRegistry;
private toolSchemas: AgentToolSchema[] = [];
private lastLoadTime = 0;
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
static getInstance(): AgentToolRegistry {
if (!this.instance) {
this.instance = new AgentToolRegistry();
}
return this.instance;
}
/**
* Load tool schemas from the skill system using ZeroClaw format
*/
async loadToolSchemas(forceReload = false): Promise<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(`🚀 [TOOL EXECUTION START] Executing tool: ${toolId}`);
console.log(`📝 [ARGUMENTS] Raw arguments:`, {
arguments: toolArguments,
type: typeof toolArguments,
length: toolArguments?.length,
isString: typeof toolArguments === 'string',
parsed: (() => {
try {
return typeof toolArguments === 'string' ? JSON.parse(toolArguments) : toolArguments;
} catch (e) {
return 'Failed to parse: ' + e;
}
})()
});
const execution: AgentToolExecution = {
id: executionId,
toolName,
skillId,
arguments: toolArguments,
status: 'running',
startTime
};
try {
// Call ZeroClaw format command with enhanced validation and timing
console.log(`🔧 [BEFORE INVOKE] Calling runtime_execute_tool with:`);
console.log(` toolId: "${toolId}"`);
console.log(` args: ${toolArguments}`);
console.log(` args type: ${typeof toolArguments}`);
const result = await invoke<ZeroClawToolResult>('runtime_execute_tool', {
toolId: toolId, // Use camelCase as expected by current Rust version
args: toolArguments // Use "args" instead of "arguments"
});
console.log(`🔧 [AFTER INVOKE] Tool execution result:`, result);
execution.endTime = Date.now();
// Use execution time from Rust if available, otherwise calculate locally
execution.executionTimeMs = result.execution_time || (execution.endTime - execution.startTime);
if (!result.success) {
execution.status = 'error';
execution.errorMessage = result.error || 'Unknown error occurred';
execution.result = execution.errorMessage;
console.log(`❌ Tool execution failed: ${toolName} (${execution.executionTimeMs}ms)`);
console.log(`❌ Error:`, execution.errorMessage);
} else {
execution.status = 'success';
execution.result = result.output;
console.log(`✅ Tool execution completed: ${toolName} (${execution.executionTimeMs}ms)`);
}
return execution;
} catch (error) {
execution.endTime = Date.now();
execution.executionTimeMs = execution.endTime - execution.startTime;
execution.status = 'error';
execution.errorMessage = error instanceof Error ? error.message : String(error);
execution.result = execution.errorMessage;
console.error(`❌ Tool execution error: ${toolName}`, error);
return execution;
}
}
/**
* Get a specific tool by name
*/
getToolByName(toolName: string): AgentToolSchema | undefined {
return this.toolSchemas.find(tool => tool.function.name === toolName);
}
/**
* Get all available tools
*/
getAllTools(): AgentToolSchema[] {
return [...this.toolSchemas];
}
/**
* Get tools organized by skill
*/
getToolsBySkill(): Record<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';
}
}
+15 -30
View File
@@ -4,25 +4,23 @@ import { apiClient } from '../apiClient';
export type ChatRole = 'system' | 'user' | 'assistant' | 'tool';
export interface ChatMessage {
role: ChatRole;
content: string;
/** tool_call_id for role=tool messages */
tool_call_id?: string;
/** tool_calls emitted by the assistant */
tool_calls?: ToolCall[];
export interface ToolCall {
id: string;
type: 'function';
function: { name: string; arguments: string };
}
// ── Tool calling types (OpenAI-compatible) ───────────────────────────────────
export interface ChatMessage {
role: ChatRole;
content: string | null;
tool_calls?: ToolCall[];
tool_call_id?: string;
}
export interface ToolFunction {
name: string;
description: string;
parameters: {
type: 'object';
properties: Record<string, unknown>;
required?: string[];
};
parameters: any;
}
export interface Tool {
@@ -30,23 +28,14 @@ export interface Tool {
function: ToolFunction;
}
export interface ToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string;
};
}
export interface ChatCompletionRequest {
model: string;
messages: ChatMessage[];
tools?: Tool[];
tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } };
stream?: boolean;
temperature?: number;
max_tokens?: number;
tools?: Tool[];
tool_choice?: 'none' | 'auto' | 'required';
}
export interface TextCompletionRequest {
@@ -116,16 +105,12 @@ export const inferenceApi = {
},
/** POST /openai/v1/chat/completions — create a chat completion */
createChatCompletion: async (
body: ChatCompletionRequest
): Promise<ChatCompletionResponse> => {
createChatCompletion: async (body: ChatCompletionRequest): Promise<ChatCompletionResponse> => {
return apiClient.post<ChatCompletionResponse>('/openai/v1/chat/completions', body);
},
/** POST /openai/v1/completions — create a text completion */
createCompletion: async (
body: TextCompletionRequest
): Promise<TextCompletionResponse> => {
createCompletion: async (body: TextCompletionRequest): Promise<TextCompletionResponse> => {
return apiClient.post<TextCompletionResponse>('/openai/v1/completions', body);
},
};
+1 -1
View File
@@ -31,7 +31,7 @@ export class DaemonHealthService {
// console.log('[DaemonHealth] Setting up alphahuman:health event listener');
this.healthEventListener = await listen<unknown>('alphahuman:health', event => {
console.log('[DaemonHealth] Received health event:', event.payload);
// console.log('[DaemonHealth] Received health event:', event.payload);
const healthSnapshot = this.parseHealthSnapshot(event.payload);
if (healthSnapshot) {
+1
View File
@@ -53,6 +53,7 @@ const threadPersistConfig = {
whitelist: ['panelWidth', 'lastViewedAt', 'threads', 'messagesByThreadId', 'selectedThreadId'],
};
const persistedAuthReducer = persistReducer(authPersistConfig, authReducer);
const persistedAiReducer = persistReducer(aiPersistConfig, aiReducer);
const persistedSkillsReducer = persistReducer(skillsPersistConfig, skillsReducer);
+2 -1
View File
@@ -4,6 +4,7 @@ import { threadApi } from '../services/api/threadApi';
import type { Thread, ThreadMessage } from '../types/thread';
import { injectAll } from '../lib/ai/injector';
import type { Message } from '../lib/ai/providers/interface';
import type { RootState } from './index';
interface ThreadState {
// Existing local data (will be persisted)
@@ -125,7 +126,7 @@ export const sendMessage = createAsyncThunk(
// 3. Send to API with processed message (disable injection in threadApi to avoid double injection)
const data = await threadApi.sendMessage(processedMessage, threadId, { injectSoul: false });
// 3. For now, we'll handle AI response via the existing inference API
// 4. For now, we'll handle AI response via the existing inference API
// The AI response will be added separately via addInferenceResponse
return data;