Merge pull request #38 from graycyrus/develop

fixes: conversation fixes
This commit is contained in:
Cyrus Gray
2026-03-27 18:14:38 +05:30
committed by GitHub
95 changed files with 1721 additions and 1149 deletions
+6
View File
@@ -471,6 +471,12 @@ Key updates from recent commits (cd9ebcd to current):
## Key Patterns
- **MANDATORY: Pre-completion checks**: Before considering ANY task complete, ALWAYS run these checks and fix all errors:
1. `npx prettier --check .` (formatting)
2. `npx eslint .` (lint)
3. `npx tsc --noEmit` (TypeScript)
4. `cargo fmt --manifest-path src-tauri/Cargo.toml` (Rust formatting, if Rust files were changed)
5. `cargo check --manifest-path src-tauri/Cargo.toml` (Rust compilation, if Rust files were changed)
- **Code Quality**: ESLint and Prettier enforce code standards with Husky hooks. Use type-only imports (`import type`) and consolidate imports from same modules.
- **No dynamic imports**: All imports must be static `import` statements at the top of the file. Do not use `await import()` or `import().then()` inside functions or code blocks. Use try/catch around Tauri API calls for non-Tauri environments instead.
- **No localStorage**: Avoid `localStorage` and `sessionStorage`; use Redux (and persist) for app state. Remove any direct usage when working on affected code.
+1 -1
View File
@@ -4,12 +4,12 @@
//! encrypted at rest using AES-256-GCM. Keys are derived from a user
//! password via Argon2id.
use aes_gcm::aead::rand_core::RngCore;
use aes_gcm::{
aead::{Aead, KeyInit, OsRng},
Aes256Gcm, Nonce,
};
use argon2::{self, Algorithm, Argon2, Params, Version};
use aes_gcm::aead::rand_core::RngCore;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
+5 -7
View File
@@ -80,14 +80,10 @@ enum Command {
},
/// Encrypt a secret
Encrypt {
plaintext: String,
},
Encrypt { plaintext: String },
/// Decrypt a secret
Decrypt {
ciphertext: String,
},
Decrypt { ciphertext: String },
/// Toggle browser allow-all runtime flag
BrowserAllowAll {
@@ -324,7 +320,9 @@ fn load_config() -> Result<openhuman::openhuman::config::Config, String> {
.map_err(|e| format!("failed to load config: {e}"))
}
fn status_value(status: openhuman::openhuman::service::ServiceStatus) -> Result<serde_json::Value, String> {
fn status_value(
status: openhuman::openhuman::service::ServiceStatus,
) -> Result<serde_json::Value, String> {
serde_json::to_value(status).map_err(|e| format!("failed to serialize service status: {e}"))
}
-1
View File
@@ -8,7 +8,6 @@ use std::sync::Arc;
pub static SESSION_SERVICE: Lazy<Arc<SessionService>> =
Lazy::new(|| Arc::new(SessionService::new()));
/// Get the current authentication state
#[tauri::command]
pub fn get_auth_state() -> AuthState {
+94 -49
View File
@@ -341,9 +341,7 @@ fn is_read_tool(name: &str) -> bool {
/// Tool names are namespaced as `{skill_id}__{tool_name}`.
/// Read-only tools are excluded — their data comes from the memory layer (Step 2 context recall).
#[cfg(not(any(target_os = "android", target_os = "ios")))]
fn discover_tools(
engine: &crate::runtime::qjs_engine::RuntimeEngine,
) -> Vec<serde_json::Value> {
fn discover_tools(engine: &crate::runtime::qjs_engine::RuntimeEngine) -> Vec<serde_json::Value> {
engine
.all_tools()
.into_iter()
@@ -436,15 +434,9 @@ pub async fn chat_send(
chat_state_arc.remove(&thread_id_clone);
if let Err(e) = result {
let _ = app_clone.emit(
"chat:error",
ChatErrorEvent {
thread_id: thread_id_clone,
message: e,
error_type: "inference".to_string(),
round: None,
},
);
// chat_send_inner already emits chat:error for known error paths.
// Only log here to avoid duplicate error events.
log::error!("[chat] chat_send_inner failed: {e}");
}
});
@@ -501,15 +493,9 @@ pub async fn chat_send(
chat_state_arc.remove(&thread_id_clone);
if let Err(e) = result {
let _ = app_clone.emit(
"chat:error",
ChatErrorEvent {
thread_id: thread_id_clone,
message: e,
error_type: "inference".to_string(),
round: None,
},
);
// chat_send_mobile already emits chat:error for known error paths.
// Only log here to avoid duplicate error events.
log::error!("[chat] chat_send_mobile failed: {e}");
}
});
@@ -546,7 +532,19 @@ async fn chat_send_inner(
let client = reqwest::Client::builder()
.use_rustls_tls()
.build()
.map_err(|e| format!("Failed to build HTTP client: {}", e))?;
.map_err(|e| {
let msg = format!("Failed to build HTTP client: {}", e);
let _ = app.emit(
"chat:error",
ChatErrorEvent {
thread_id: thread_id.to_string(),
message: msg.clone(),
error_type: "network".to_string(),
round: None,
},
);
msg
})?;
// ── Step 1: Load AI context ─────────────────────────────────────────
let openclaw_context = load_openclaw_context(app);
@@ -583,7 +581,11 @@ async fn chat_send_inner(
.map(|(skill_id, _)| skill_id)
.collect();
log::info!("[chat] Recalling skill contexts for {} skill(s): {:?}", skill_ids.len(), skill_ids);
log::info!(
"[chat] Recalling skill contexts for {} skill(s): {:?}",
skill_ids.len(),
skill_ids
);
let mut skill_contexts: Vec<String> = Vec::new();
for sid in &skill_ids {
@@ -932,10 +934,19 @@ async fn chat_send_inner(
}
// Parse the completion response
let completion: ChatCompletionResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse inference response: {}", e))?;
let completion: ChatCompletionResponse = response.json().await.map_err(|e| {
let msg = format!("Failed to parse inference response: {}", e);
let _ = app.emit(
"chat:error",
ChatErrorEvent {
thread_id: thread_id.to_string(),
message: msg.clone(),
error_type: "inference".to_string(),
round: Some(round),
},
);
msg
})?;
// Accumulate token usage
if let Some(ref usage) = completion.usage {
@@ -943,10 +954,19 @@ async fn chat_send_inner(
total_output_tokens += usage.completion_tokens;
}
let choice = completion
.choices
.first()
.ok_or_else(|| "No choices in inference response".to_string())?;
let choice = completion.choices.first().ok_or_else(|| {
let msg = "No choices in inference response".to_string();
let _ = app.emit(
"chat:error",
ChatErrorEvent {
thread_id: thread_id.to_string(),
message: msg.clone(),
error_type: "inference".to_string(),
round: Some(round),
},
);
msg
})?;
log::info!(
"[chat] Round {} — finish_reason={:?}, tool_calls={}",
@@ -989,9 +1009,8 @@ async fn chat_send_inner(
let (skill_id, tool_name) = parse_tool_name(&tc.function.name);
let args_value: serde_json::Value =
serde_json::from_str(&tc.function.arguments)
.unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
let args_value: serde_json::Value = serde_json::from_str(&tc.function.arguments)
.unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
// Emit tool_call event before executing
let _ = app.emit(
@@ -1056,9 +1075,7 @@ async fn chat_send_inner(
.content
.iter()
.filter_map(|c| match c {
crate::runtime::types::ToolContent::Text { text } => {
Some(text.as_str())
}
crate::runtime::types::ToolContent::Text { text } => Some(text.as_str()),
crate::runtime::types::ToolContent::Json { .. } => None,
})
.collect::<Vec<&str>>()
@@ -1067,9 +1084,7 @@ async fn chat_send_inner(
// Check for JSON error pattern (matching TS behaviour)
let (final_tool_str, final_success) = if !tool_result.is_error {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&tool_content) {
if let Some(error_str) =
parsed.get("error").and_then(|e| e.as_str())
{
if let Some(error_str) = parsed.get("error").and_then(|e| e.as_str()) {
(format!("Error: {}", error_str), false)
} else {
(tool_content.clone(), true)
@@ -1162,7 +1177,19 @@ async fn chat_send_mobile(
let client = reqwest::Client::builder()
.use_rustls_tls()
.build()
.map_err(|e| format!("Failed to build HTTP client: {}", e))?;
.map_err(|e| {
let msg = format!("Failed to build HTTP client: {}", e);
let _ = app.emit(
"chat:error",
ChatErrorEvent {
thread_id: thread_id.to_string(),
message: msg.clone(),
error_type: "network".to_string(),
round: None,
},
);
msg
})?;
// ── Step 1: Load AI context ─────────────────────────────────────────
let openclaw_context = load_openclaw_context(app);
@@ -1307,20 +1334,38 @@ async fn chat_send_mobile(
return Err(msg);
}
let completion: ChatCompletionResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse inference response: {}", e))?;
let completion: ChatCompletionResponse = response.json().await.map_err(|e| {
let msg = format!("Failed to parse inference response: {}", e);
let _ = app.emit(
"chat:error",
ChatErrorEvent {
thread_id: thread_id.to_string(),
message: msg.clone(),
error_type: "inference".to_string(),
round: None,
},
);
msg
})?;
let (total_input_tokens, total_output_tokens) = completion
.usage
.map(|u| (u.prompt_tokens, u.completion_tokens))
.unwrap_or((0, 0));
let choice = completion
.choices
.first()
.ok_or_else(|| "No choices in inference response".to_string())?;
let choice = completion.choices.first().ok_or_else(|| {
let msg = "No choices in inference response".to_string();
let _ = app.emit(
"chat:error",
ChatErrorEvent {
thread_id: thread_id.to_string(),
message: msg.clone(),
error_type: "inference".to_string(),
round: None,
},
);
msg
})?;
let full_response = choice.message.content.clone().unwrap_or_default();
+16 -8
View File
@@ -86,7 +86,10 @@ pub async fn init_memory_client(
jwt_token: String,
state: tauri::State<'_, MemoryState>,
) -> Result<(), String> {
log::info!("[memory] init_memory_client: entry (token_present={})", !jwt_token.trim().is_empty());
log::info!(
"[memory] init_memory_client: entry (token_present={})",
!jwt_token.trim().is_empty()
);
let client = MemoryClient::from_token(jwt_token).map(Arc::new);
if client.is_none() {
log::warn!("[memory] init_memory_client: exit — empty token, memory layer disabled");
@@ -149,7 +152,10 @@ pub async fn memory_query(
.query_skill_context(&skill_id, &integration_id, &query, max_chunks.unwrap_or(10))
.await;
match &result {
Ok(ctx) => log::info!("[memory] memory_query: exit — ok (context_len={})", ctx.len()),
Ok(ctx) => log::info!(
"[memory] memory_query: exit — ok (context_len={})",
ctx.len()
),
Err(e) => log::warn!("[memory] memory_query: exit — error: {e}"),
}
result
@@ -218,9 +224,10 @@ pub async fn memory_query_namespace(
) -> Result<String, String> {
let client = state.0.lock().map_err(|e| e.to_string())?.clone();
match client {
Some(c) => c
.query_namespace_context(&namespace, &query, max_chunks.unwrap_or(10))
.await,
Some(c) => {
c.query_namespace_context(&namespace, &query, max_chunks.unwrap_or(10))
.await
}
None => Err("Memory layer not configured — JWT token not yet set".into()),
}
}
@@ -233,9 +240,10 @@ pub async fn memory_recall_namespace(
) -> Result<Option<String>, String> {
let client = state.0.lock().map_err(|e| e.to_string())?.clone();
match client {
Some(c) => c
.recall_namespace_context(&namespace, max_chunks.unwrap_or(10))
.await,
Some(c) => {
c.recall_namespace_context(&namespace, max_chunks.unwrap_or(10))
.await
}
None => Err("Memory layer not configured — JWT token not yet set".into()),
}
}
+2 -2
View File
@@ -2,9 +2,9 @@ pub mod auth;
pub mod chat;
pub mod memory;
pub mod model;
pub mod openhuman;
pub mod runtime;
pub mod socket;
pub mod openhuman;
pub mod unified_skills;
#[cfg(desktop)]
@@ -15,9 +15,9 @@ pub use auth::*;
pub use chat::{chat_cancel, chat_send};
pub use memory::*;
pub use model::*;
pub use openhuman::*;
pub use runtime::*;
pub use socket::*;
pub use openhuman::*;
#[cfg(desktop)]
pub use window::*;
+5 -6
View File
@@ -1,10 +1,10 @@
//! Tauri command proxies for the standalone openhuman core process.
use crate::openhuman::{doctor, hardware, integrations, migration, onboard, service};
use crate::core_server::{
BrowserSettingsUpdate, CommandResponse, ConfigSnapshot, GatewaySettingsUpdate,
MemorySettingsUpdate, ModelSettingsUpdate, RuntimeFlags, RuntimeSettingsUpdate,
};
use crate::openhuman::{doctor, hardware, integrations, migration, onboard, service};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use tauri::Manager;
@@ -347,7 +347,8 @@ pub async fn openhuman_hardware_introspect(
/// Return whether the local core agent server is reachable.
#[tauri::command]
pub async fn openhuman_agent_server_status() -> Result<CommandResponse<AgentServerStatus>, String> {
let url = std::env::var("OPENHUMAN_CORE_RPC_URL").unwrap_or_else(|_| DEFAULT_CORE_RPC_URL.to_string());
let url = std::env::var("OPENHUMAN_CORE_RPC_URL")
.unwrap_or_else(|_| DEFAULT_CORE_RPC_URL.to_string());
let running = crate::core_rpc::ping().await;
Ok(CommandResponse {
result: AgentServerStatus { running, url },
@@ -389,8 +390,7 @@ pub async fn openhuman_service_stop(
app: tauri::AppHandle,
) -> Result<CommandResponse<service::ServiceStatus>, String> {
let config = load_config_local().await?;
let status = service::stop(&config)
.map_err(|e| e.to_string())?;
let status = service::stop(&config).map_err(|e| e.to_string())?;
// Also stop any locally managed core process in this Tauri app.
if let Some(core) = app.try_state::<crate::core_process::CoreProcessHandle>() {
@@ -424,8 +424,7 @@ pub async fn openhuman_service_uninstall(
app: tauri::AppHandle,
) -> Result<CommandResponse<service::ServiceStatus>, String> {
let config = load_config_local().await?;
let status = service::uninstall(&config)
.map_err(|e| e.to_string())?;
let status = service::uninstall(&config).map_err(|e| e.to_string())?;
if let Some(core) = app.try_state::<crate::core_process::CoreProcessHandle>() {
let core_handle: crate::core_process::CoreProcessHandle = (*core).clone();
+113 -48
View File
@@ -9,10 +9,10 @@
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};
use std::collections::HashMap;
use std::sync::Arc;
use tauri::State;
// Desktop-only imports
#[cfg(not(any(target_os = "android", target_os = "ios")))]
@@ -329,15 +329,24 @@ mod desktop {
schemas.push(schema);
}
log::info!("🔧 [RUNTIME] Generated {} ZeroClaw tool schemas", schemas.len());
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()
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"))
.filter(|name| {
name.to_lowercase().contains("gmail") || name.to_lowercase().contains("notion")
})
.cloned()
.collect();
log::info!("🔧 [RUNTIME] Gmail/Notion tools found: {:?}", gmail_notion_tools);
log::info!(
"🔧 [RUNTIME] Gmail/Notion tools found: {:?}",
gmail_notion_tools
);
Ok(schemas)
}
@@ -351,12 +360,20 @@ mod desktop {
) -> Result<ZeroClawToolResult, String> {
let start_time = std::time::Instant::now();
log::info!("🔧 [RUNTIME] Executing ZeroClaw tool: {} with args: {}", tool_id, args);
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);
log::info!(
"🔧 [RUNTIME] Parsed tool_id: skill_id='{}', tool_name='{}'",
skill,
tool
);
(skill, tool)
}
Err(e) => {
@@ -372,30 +389,51 @@ mod desktop {
};
// Log runtime state before execution
log::info!("🔧 [RUNTIME] Attempting to call tool '{}' on skill '{}'", tool_name, skill_id);
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<_>>());
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<_>>());
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);
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);
log::info!(
"🔧 [RUNTIME] Tool execution completed in {}ms, is_error: {}",
execution_time,
result.is_error
);
if result.is_error {
let error_message = result.content
let error_message = result
.content
.iter()
.filter(|c| matches!(c, crate::runtime::types::ToolContent::Text { .. }))
.map(|c| match c {
@@ -405,7 +443,10 @@ mod desktop {
.collect::<Vec<_>>()
.join("\n");
log::error!("🔧 [RUNTIME] Tool execution failed with error: {}", error_message);
log::error!(
"🔧 [RUNTIME] Tool execution failed with error: {}",
error_message
);
Ok(ZeroClawToolResult {
success: false,
@@ -414,12 +455,14 @@ mod desktop {
execution_time: Some(execution_time),
})
} else {
let output = result.content
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())
serde_json::to_string(data)
.unwrap_or_else(|_| "Invalid JSON".to_string())
}
})
.collect::<Vec<_>>()
@@ -467,7 +510,9 @@ mod desktop {
}
// 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> {
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);
@@ -514,7 +559,9 @@ mod mobile {
}
#[tauri::command]
pub async fn runtime_get_skill_state(_skill_id: String) -> Result<Option<SkillSnapshot>, String> {
pub async fn runtime_get_skill_state(
_skill_id: String,
) -> Result<Option<SkillSnapshot>, String> {
Ok(None)
}
@@ -562,7 +609,10 @@ mod mobile {
}
#[tauri::command]
pub async fn runtime_skill_kv_get(_skill_id: String, _key: String) -> Result<serde_json::Value, String> {
pub async fn runtime_skill_kv_get(
_skill_id: String,
_key: String,
) -> Result<serde_json::Value, String> {
Err(MOBILE_ERROR.to_string())
}
@@ -585,7 +635,10 @@ mod mobile {
}
#[tauri::command]
pub async fn runtime_skill_data_read(_skill_id: String, _filename: String) -> Result<String, String> {
pub async fn runtime_skill_data_read(
_skill_id: String,
_filename: String,
) -> Result<String, String> {
Err(MOBILE_ERROR.to_string())
}
@@ -703,8 +756,8 @@ mod tests {
}
},
"required": ["message"]
})
}
}),
},
};
// Test serialization
@@ -714,8 +767,8 @@ mod tests {
assert!(json.contains("A test tool"));
// Test deserialization
let deserialized: ZeroClawToolSchema = serde_json::from_str(&json)
.expect("Should deserialize from JSON");
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");
}
@@ -726,7 +779,7 @@ mod tests {
success: true,
output: "Test output".to_string(),
error: None,
execution_time: Some(1500)
execution_time: Some(1500),
};
// Test serialization
@@ -740,7 +793,7 @@ mod tests {
success: false,
output: String::new(),
error: Some("Tool not found".to_string()),
execution_time: Some(100)
execution_time: Some(100),
};
let error_json = serde_json::to_string(&error_result).expect("Should serialize error");
@@ -751,13 +804,13 @@ mod tests {
#[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");
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");
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");
@@ -771,10 +824,22 @@ mod tests {
#[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");
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]
@@ -823,8 +888,8 @@ mod tests {
"state": {"type": "string", "enum": ["open", "closed", "all"], "default": "open"}
},
"required": ["owner", "repo"]
})
}
}),
},
};
// Serialize and check format
@@ -860,10 +925,8 @@ mod tests {
#[tokio::test]
async fn test_mobile_stub_runtime_execute_tool() {
let result = mobile::runtime_execute_tool(
"test_tool".to_string(),
"{}".to_string()
).await;
let result =
mobile::runtime_execute_tool("test_tool".to_string(), "{}".to_string()).await;
// Mobile should return error
assert!(result.is_err());
@@ -881,7 +944,8 @@ mod tests {
"description": "test",
"parameters": {}
}
})).expect("Should deserialize from JSON");
}))
.expect("Should deserialize from JSON");
assert_eq!(tool_schema.type_field, "function");
assert_eq!(tool_schema.function.name, "test");
@@ -892,7 +956,8 @@ mod tests {
"output": "result",
"error": null,
"execution_time": 1000
})).expect("Should deserialize tool result");
}))
.expect("Should deserialize tool result");
assert!(tool_result.success);
assert_eq!(tool_result.output, "result");
@@ -906,7 +971,7 @@ mod tests {
success: false,
output: String::new(),
error: Some("Connection timeout".to_string()),
execution_time: Some(30000) // 30 second timeout
execution_time: Some(30000), // 30 second timeout
};
let json = serde_json::to_string(&error_result).expect("Should serialize error");
@@ -915,8 +980,8 @@ mod tests {
assert!(json.contains("30000"));
// Test deserialization back
let parsed: ZeroClawToolResult = serde_json::from_str(&json)
.expect("Should parse error result");
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()));
}
+3 -5
View File
@@ -7,8 +7,8 @@
//! Commands are desktop-only — mobile stubs return empty/error results.
use crate::runtime::types::{UnifiedSkillEntry, UnifiedSkillResult};
use crate::unified_skills::GenerateSkillSpec;
use crate::unified_skills::self_evolve::{SelfEvolveRequest, SelfEvolveResult};
use crate::unified_skills::GenerateSkillSpec;
use std::sync::Arc;
use tauri::State;
@@ -140,12 +140,10 @@ mod mobile {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub use desktop::{
unified_execute_skill, unified_generate_skill, unified_list_skills,
unified_self_evolve_skill,
unified_execute_skill, unified_generate_skill, unified_list_skills, unified_self_evolve_skill,
};
#[cfg(any(target_os = "android", target_os = "ios"))]
pub use mobile::{
unified_execute_skill, unified_generate_skill, unified_list_skills,
unified_self_evolve_skill,
unified_execute_skill, unified_generate_skill, unified_list_skills, unified_self_evolve_skill,
};
+9 -2
View File
@@ -23,7 +23,10 @@ impl CoreProcessHandle {
pub async fn ensure_running(&self) -> Result<(), String> {
if crate::core_rpc::ping().await {
log::info!("[core] found existing core rpc endpoint at {}", self.rpc_url());
log::info!(
"[core] found existing core rpc endpoint at {}",
self.rpc_url()
);
return Ok(());
}
@@ -38,7 +41,11 @@ impl CoreProcessHandle {
.arg("--port")
.arg(self.port.to_string());
log::info!("[core] spawning core process: {:?} core serve --port {}", cmd.as_std().get_program(), self.port);
log::info!(
"[core] spawning core process: {:?} core serve --port {}",
cmd.as_std().get_program(),
self.port
);
let child = cmd
.spawn()
+2 -4
View File
@@ -9,7 +9,6 @@
//! - Native notifications
mod ai;
pub mod openhuman;
mod auth;
mod commands;
mod core_process;
@@ -17,6 +16,7 @@ mod core_rpc;
pub mod core_server;
pub mod memory;
mod models;
pub mod openhuman;
mod runtime;
mod services;
mod unified_skills;
@@ -457,9 +457,7 @@ fn is_daemon_mode() -> bool {
fn daemon_foreground_requested() -> bool {
matches!(
std::env::var("OPENHUMAN_DAEMON_FOREGROUND")
.ok()
.as_deref(),
std::env::var("OPENHUMAN_DAEMON_FOREGROUND").ok().as_deref(),
Some("1") | Some("true") | Some("TRUE") | Some("yes") | Some("YES")
)
}
+32 -19
View File
@@ -22,15 +22,20 @@ impl MemoryClient {
/// Construct from a JWT token (sourced from `authSlice.token` in the Redux store).
/// Returns `None` if the token is empty or client construction fails.
pub fn from_token(jwt_token: String) -> Option<Self> {
log::info!("[memory] from_token: entry (token_len={})", jwt_token.trim().len());
log::info!(
"[memory] from_token: entry (token_len={})",
jwt_token.trim().len()
);
if jwt_token.trim().is_empty() {
log::warn!("[memory] from_token: exit — token is empty, returning None");
return None;
}
let config = if let Ok(base_url) = std::env::var("OPENHUMAN_BASE_URL")
.or_else(|_| std::env::var("TINYHUMANS_BASE_URL"))
let config = if let Ok(base_url) =
std::env::var("OPENHUMAN_BASE_URL").or_else(|_| std::env::var("TINYHUMANS_BASE_URL"))
{
log::info!("[memory] from_token: constructing client (base_url={base_url}, source=memory_env)");
log::info!(
"[memory] from_token: constructing client (base_url={base_url}, source=memory_env)"
);
TinyHumanConfig::new(jwt_token).with_base_url(base_url)
} else {
let backend_url = crate::utils::config::get_backend_url();
@@ -120,11 +125,7 @@ impl MemoryClient {
match self.inner.get_ingestion_job(&job_id).await {
Ok(status_resp) => {
let state = status_resp
.data
.state
.as_deref()
.unwrap_or("unknown");
let state = status_resp.data.state.as_deref().unwrap_or("unknown");
log::info!(
"[memory] ingestion job status: job_id={job_id}, state={state}, \
@@ -199,11 +200,16 @@ impl MemoryClient {
})
.await
.map_err(|e| {
log::warn!("[memory] query_skill_context: exit — error (namespace={namespace}): {e}");
log::warn!(
"[memory] query_skill_context: exit — error (namespace={namespace}): {e}"
);
format!("Memory query failed: {e}")
})?;
let response = res.data.response.unwrap_or_default();
log::info!("[memory] query_skill_context: exit — ok (namespace={namespace}, response_len={})", response.len());
log::info!(
"[memory] query_skill_context: exit — ok (namespace={namespace}, response_len={})",
response.len()
);
Ok(response)
}
@@ -227,7 +233,9 @@ impl MemoryClient {
})
.await
.map_err(|e| {
log::warn!("[memory] recall_skill_context: exit — error (namespace={namespace}): {e}");
log::warn!(
"[memory] recall_skill_context: exit — error (namespace={namespace}): {e}"
);
format!("Memory recall failed: {e}")
})?;
let response = res.data.context;
@@ -247,7 +255,11 @@ impl MemoryClient {
}
/// Delete a specific document from a namespace.
pub async fn delete_document(&self, document_id: &str, namespace: &str) -> Result<serde_json::Value, String> {
pub async fn delete_document(
&self,
document_id: &str,
namespace: &str,
) -> Result<serde_json::Value, String> {
self.inner
.delete_document(document_id, namespace)
.await
@@ -300,7 +312,8 @@ impl MemoryClient {
let namespace = skill_id.to_string();
log::info!("[memory] clear_skill_memory: entry (namespace={namespace})");
log::debug!("[memory] clear_skill_memory: payload → namespace={namespace}");
let result = self.inner
let result = self
.inner
.delete_memory(DeleteMemoryParams {
namespace: Some(namespace.clone()),
})
@@ -309,7 +322,9 @@ impl MemoryClient {
.map_err(|e| format!("Memory delete failed: {e}"));
match &result {
Ok(()) => log::info!("[memory] clear_skill_memory: exit — ok (namespace={namespace})"),
Err(e) => log::warn!("[memory] clear_skill_memory: exit — error (namespace={namespace}): {e}"),
Err(e) => {
log::warn!("[memory] clear_skill_memory: exit — error (namespace={namespace}): {e}")
}
}
result
}
@@ -350,11 +365,9 @@ mod tests {
#[tokio::test]
#[ignore]
async fn test_memory_skill_sync_flow() {
let jwt_token =
std::env::var("JWT_TOKEN").expect("JWT_TOKEN must be set");
let jwt_token = std::env::var("JWT_TOKEN").expect("JWT_TOKEN must be set");
let client = MemoryClient::from_token(jwt_token)
.expect("client creation failed");
let client = MemoryClient::from_token(jwt_token).expect("client creation failed");
let skill_id = "gmail";
let integration_id = "test@openhuman.dev";
+24 -6
View File
@@ -133,7 +133,10 @@ impl AgentBuilder {
self
}
pub fn identity_config(mut self, identity_config: crate::openhuman::config::IdentityConfig) -> Self {
pub fn identity_config(
mut self,
identity_config: crate::openhuman::config::IdentityConfig,
) -> Self {
self.identity_config = Some(identity_config);
self
}
@@ -664,7 +667,10 @@ mod tests {
serde_json::json!({"type": "object"})
}
async fn execute(&self, _args: serde_json::Value) -> Result<crate::openhuman::tools::ToolResult> {
async fn execute(
&self,
_args: serde_json::Value,
) -> Result<crate::openhuman::tools::ToolResult> {
Ok(crate::openhuman::tools::ToolResult {
success: true,
output: "tool-out".into(),
@@ -687,10 +693,16 @@ mod tests {
..crate::openhuman::config::MemoryConfig::default()
};
let mem: Arc<dyn Memory> = Arc::from(
crate::openhuman::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None).unwrap(),
crate::openhuman::memory::create_memory(
&memory_cfg,
std::path::Path::new("/tmp"),
None,
)
.unwrap(),
);
let observer: Arc<dyn Observer> = Arc::from(crate::openhuman::observability::NoopObserver {});
let observer: Arc<dyn Observer> =
Arc::from(crate::openhuman::observability::NoopObserver {});
let mut agent = Agent::builder()
.provider(provider)
.tools(vec![Box::new(MockTool)])
@@ -729,10 +741,16 @@ mod tests {
..crate::openhuman::config::MemoryConfig::default()
};
let mem: Arc<dyn Memory> = Arc::from(
crate::openhuman::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None).unwrap(),
crate::openhuman::memory::create_memory(
&memory_cfg,
std::path::Path::new("/tmp"),
None,
)
.unwrap(),
);
let observer: Arc<dyn Observer> = Arc::from(crate::openhuman::observability::NoopObserver {});
let observer: Arc<dyn Observer> =
Arc::from(crate::openhuman::observability::NoopObserver {});
let mut agent = Agent::builder()
.provider(provider)
.tools(vec![Box::new(MockTool)])
+3 -1
View File
@@ -1,4 +1,6 @@
use crate::openhuman::providers::{ChatMessage, ChatResponse, ConversationMessage, ToolResultMessage};
use crate::openhuman::providers::{
ChatMessage, ChatResponse, ConversationMessage, ToolResultMessage,
};
use crate::openhuman::tools::{Tool, ToolSpec};
use serde_json::Value;
use std::fmt::Write;
+23 -6
View File
@@ -966,7 +966,9 @@ pub(crate) async fn run_tool_call_loop(
model: model.to_string(),
duration: llm_started_at.elapsed(),
success: false,
error_message: Some(crate::openhuman::providers::sanitize_api_error(&e.to_string())),
error_message: Some(crate::openhuman::providers::sanitize_api_error(
&e.to_string(),
)),
});
return Err(e);
}
@@ -1567,7 +1569,10 @@ pub async fn run(
final_output = response.clone();
if let Err(e) = crate::openhuman::channels::Channel::send(
&cli,
&crate::openhuman::channels::traits::SendMessage::new(format!("\n{response}\n"), "user"),
&crate::openhuman::channels::traits::SendMessage::new(
format!("\n{response}\n"),
"user",
),
)
.await
{
@@ -2792,7 +2797,10 @@ browser_open/url>https://example.com"#;
fn parse_tool_calls_closing_tag_only_returns_text() {
let response = "Some text </tool_call> more text";
let (text, calls) = parse_tool_calls(response);
assert!(calls.is_empty(), "closing tag only should not produce calls");
assert!(
calls.is_empty(),
"closing tag only should not produce calls"
);
assert!(
!text.is_empty(),
"text around orphaned closing tag should be preserved"
@@ -2841,7 +2849,11 @@ browser_open/url>https://example.com"#;
Let me check the result."#;
let (text, calls) = parse_tool_calls(response);
assert_eq!(calls.len(), 1, "should extract one tool call from mixed content");
assert_eq!(
calls.len(),
1,
"should extract one tool call from mixed content"
);
assert_eq!(calls[0].name, "shell");
assert!(
text.contains("help you"),
@@ -2863,7 +2875,10 @@ Let me check the result."#;
fn scrub_credentials_no_sensitive_data() {
let input = "normal text without any secrets";
let result = scrub_credentials(input);
assert_eq!(result, input, "non-sensitive text should pass through unchanged");
assert_eq!(
result, input,
"non-sensitive text should pass through unchanged"
);
}
#[test]
@@ -2887,7 +2902,9 @@ Let me check the result."#;
#[test]
fn trim_history_system_only() {
let mut history = vec![crate::openhuman::providers::ChatMessage::system("system prompt")];
let mut history = vec![crate::openhuman::providers::ChatMessage::system(
"system prompt",
)];
trim_history(&mut history, 10);
assert_eq!(history.len(), 1);
assert_eq!(history[0].role, "system");
+4 -2
View File
@@ -330,7 +330,8 @@ client_id = "app_id_123"
client_secret = "secret_456"
allowed_users = ["user1", "*"]
"#;
let config: crate::openhuman::config::schema::DingTalkConfig = toml::from_str(toml_str).unwrap();
let config: crate::openhuman::config::schema::DingTalkConfig =
toml::from_str(toml_str).unwrap();
assert_eq!(config.client_id, "app_id_123");
assert_eq!(config.client_secret, "secret_456");
assert_eq!(config.allowed_users, vec!["user1", "*"]);
@@ -342,7 +343,8 @@ allowed_users = ["user1", "*"]
client_id = "id"
client_secret = "secret"
"#;
let config: crate::openhuman::config::schema::DingTalkConfig = toml::from_str(toml_str).unwrap();
let config: crate::openhuman::config::schema::DingTalkConfig =
toml::from_str(toml_str).unwrap();
assert!(config.allowed_users.is_empty());
}
+4 -1
View File
@@ -857,7 +857,10 @@ mod tests {
msg.push_str(&"x".repeat(1990));
msg.push_str("\n```\nMore text after code block");
let parts = split_message_for_discord(&msg);
assert!(parts.len() >= 2, "code block spanning boundary should split");
assert!(
parts.len() >= 2,
"code block spanning boundary should split"
);
for part in &parts {
assert!(
part.len() <= DISCORD_MAX_MESSAGE_LENGTH,
+11 -2
View File
@@ -124,7 +124,11 @@ pub fn build_system_prompt(
for skill in skills {
let _ = writeln!(prompt, " <skill>");
let _ = writeln!(prompt, " <name>{}</name>", skill.name);
let _ = writeln!(prompt, " <description>{}</description>", skill.description);
let _ = writeln!(
prompt,
" <description>{}</description>",
skill.description
);
let location = skill.location.clone().unwrap_or_else(|| {
workspace_dir
.join("skills")
@@ -217,7 +221,12 @@ pub fn build_system_prompt(
}
/// Inject a single workspace file into the prompt with truncation and missing-file markers.
fn inject_workspace_file(prompt: &mut String, workspace_dir: &Path, filename: &str, max_chars: usize) {
fn inject_workspace_file(
prompt: &mut String,
workspace_dir: &Path,
filename: &str,
max_chars: usize,
) {
use std::fmt::Write;
let path = workspace_dir.join(filename);
@@ -1,16 +1,16 @@
//! Channel runtime loop and message processing.
use crate::openhuman::agent::loop_::run_tool_call_loop;
use crate::openhuman::channels::context::{
build_memory_context, compact_sender_history, conversation_history_key, conversation_memory_key,
is_context_window_overflow_error, ChannelRuntimeContext, CHANNEL_TYPING_REFRESH_INTERVAL_SECS,
MAX_CHANNEL_HISTORY,
build_memory_context, compact_sender_history, conversation_history_key,
conversation_memory_key, is_context_window_overflow_error, ChannelRuntimeContext,
CHANNEL_TYPING_REFRESH_INTERVAL_SECS, MAX_CHANNEL_HISTORY,
};
use crate::openhuman::channels::routes::{
get_or_create_provider, get_route_selection, handle_runtime_command_if_needed,
};
use crate::openhuman::channels::traits;
use crate::openhuman::channels::{Channel, SendMessage};
use crate::openhuman::agent::loop_::run_tool_call_loop;
use crate::openhuman::providers::{self, ChatMessage};
use crate::openhuman::util::truncate_with_ellipsis;
use std::sync::Arc;
@@ -1,12 +1,12 @@
//! Channel startup wiring.
use super::dispatch::run_message_dispatch_loop;
use super::supervision::{compute_max_in_flight_messages, spawn_supervised_listener};
use crate::openhuman::agent::loop_::build_tool_instructions;
use crate::openhuman::channels::context::{
effective_channel_message_timeout_secs, ChannelRuntimeContext,
DEFAULT_CHANNEL_INITIAL_BACKOFF_SECS, DEFAULT_CHANNEL_MAX_BACKOFF_SECS,
};
use super::dispatch::run_message_dispatch_loop;
use super::supervision::{compute_max_in_flight_messages, spawn_supervised_listener};
use crate::openhuman::agent::loop_::build_tool_instructions;
use crate::openhuman::channels::dingtalk::DingTalkChannel;
use crate::openhuman::channels::discord::DiscordChannel;
use crate::openhuman::channels::email_channel::EmailChannel;
@@ -1,11 +1,10 @@
//! Supervisor helpers for channel listeners.
use super::super::context::{
CHANNEL_MAX_IN_FLIGHT_MESSAGES, CHANNEL_MIN_IN_FLIGHT_MESSAGES, CHANNEL_PARALLELISM_PER_CHANNEL,
};
use super::super::traits;
use super::super::Channel;
use super::super::context::{
CHANNEL_MAX_IN_FLIGHT_MESSAGES, CHANNEL_MIN_IN_FLIGHT_MESSAGES,
CHANNEL_PARALLELISM_PER_CHANNEL,
};
use std::sync::Arc;
use std::time::Duration;
+2 -1
View File
@@ -92,7 +92,8 @@ impl SignalChannel {
fn http_client(&self) -> Client {
let builder = Client::builder().connect_timeout(Duration::from_secs(10));
let builder = crate::openhuman::config::apply_runtime_proxy_to_builder(builder, "channel.signal");
let builder =
crate::openhuman::config::apply_runtime_proxy_to_builder(builder, "channel.signal");
builder.build().expect("Signal HTTP client should build")
}
+5 -5
View File
@@ -45,10 +45,7 @@ fn split_message_for_telegram(message: &str) -> Vec<String> {
pos + 1
} else {
// Try space as fallback
search_area
.rfind(' ')
.unwrap_or(hard_split)
+ 1
search_area.rfind(' ').unwrap_or(hard_split) + 1
}
} else if let Some(pos) = search_area.rfind(' ') {
pos + 1
@@ -2844,7 +2841,10 @@ mod tests {
msg.push_str(&"x".repeat(4085));
msg.push_str("\n```\nMore text after code block");
let parts = split_message_for_telegram(&msg);
assert!(parts.len() >= 2, "code block spanning boundary should split");
assert!(
parts.len() >= 2,
"code block spanning boundary should split"
);
for part in &parts {
assert!(
part.len() <= TELEGRAM_MAX_MESSAGE_LENGTH,
@@ -11,7 +11,11 @@ pub(super) fn make_workspace() -> TempDir {
let tmp = TempDir::new().unwrap();
// Create minimal workspace files
std::fs::write(tmp.path().join("SOUL.md"), "# Soul\nBe helpful.").unwrap();
std::fs::write(tmp.path().join("IDENTITY.md"), "# Identity\nName: OpenHuman").unwrap();
std::fs::write(
tmp.path().join("IDENTITY.md"),
"# Identity\nName: OpenHuman",
)
.unwrap();
std::fs::write(tmp.path().join("USER.md"), "# User\nName: Test User").unwrap();
std::fs::write(
tmp.path().join("AGENTS.md"),
@@ -1,7 +1,7 @@
use super::common::AlwaysFailChannel;
use super::super::commands::{classify_health_result, ChannelHealthState};
use super::super::runtime::spawn_supervised_listener;
use super::super::{traits, Channel, SendMessage};
use super::common::AlwaysFailChannel;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
@@ -1,5 +1,5 @@
use super::common::make_workspace;
use super::super::prompt::build_system_prompt;
use super::common::make_workspace;
#[test]
fn aieos_identity_from_file() {
@@ -1,7 +1,10 @@
use super::common::{HistoryCaptureProvider, NoopMemory, RecordingChannel};
use super::super::context::{build_memory_context, conversation_memory_key, ChannelRuntimeContext, CHANNEL_MESSAGE_TIMEOUT_SECS, MAX_CHANNEL_HISTORY};
use super::super::context::{
build_memory_context, conversation_memory_key, ChannelRuntimeContext,
CHANNEL_MESSAGE_TIMEOUT_SECS, MAX_CHANNEL_HISTORY,
};
use super::super::runtime::process_channel_message;
use super::super::{traits, Channel};
use super::common::{HistoryCaptureProvider, NoopMemory, RecordingChannel};
use crate::openhuman::memory::{Memory, MemoryCategory, SqliteMemory};
use crate::openhuman::observability::NoopObserver;
use crate::openhuman::providers::{self, ChatMessage, Provider};
@@ -1,5 +1,5 @@
use super::common::make_workspace;
use super::super::prompt::{build_system_prompt, BOOTSTRAP_MAX_CHARS};
use super::common::make_workspace;
use tempfile::TempDir;
#[test]
@@ -205,7 +205,8 @@ fn channel_log_truncation_is_utf8_safe_for_multibyte_text() {
let msg = "Hello from OpenHuman 🌍. Current status is healthy, and café-style UTF-8 text stays safe in logs.";
// Reproduces the production crash path where channel logs truncate at 80 chars.
let result = std::panic::catch_unwind(|| crate::openhuman::util::truncate_with_ellipsis(msg, 80));
let result =
std::panic::catch_unwind(|| crate::openhuman::util::truncate_with_ellipsis(msg, 80));
assert!(
result.is_ok(),
"truncate_with_ellipsis should never panic on UTF-8"
@@ -1,7 +1,7 @@
use super::common::{NoopMemory, RecordingChannel, SlowProvider};
use super::super::context::{ChannelRuntimeContext, CHANNEL_MESSAGE_TIMEOUT_SECS};
use super::super::runtime::{process_channel_message, run_message_dispatch_loop};
use super::super::{traits, Channel};
use super::common::{NoopMemory, RecordingChannel, SlowProvider};
use crate::openhuman::observability::NoopObserver;
use crate::openhuman::providers::{self, Provider};
use std::collections::HashMap;
@@ -9,132 +9,131 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
#[tokio::test]
async fn message_dispatch_processes_messages_in_parallel() {
let channel_impl = Arc::new(RecordingChannel::default());
let channel: Arc<dyn Channel> = channel_impl.clone();
let channel_impl = Arc::new(RecordingChannel::default());
let channel: Arc<dyn Channel> = channel_impl.clone();
let mut channels_by_name = HashMap::new();
channels_by_name.insert(channel.name().to_string(), channel);
let mut channels_by_name = HashMap::new();
channels_by_name.insert(channel.name().to_string(), channel);
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
provider: Arc::new(SlowProvider {
delay: Duration::from_millis(250),
}),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![]),
observer: Arc::new(NoopObserver),
system_prompt: Arc::new("test-system-prompt".to_string()),
model: Arc::new("test-model".to_string()),
temperature: 0.0,
auto_save_memory: false,
max_tool_iterations: 10,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
provider_cache: Arc::new(Mutex::new(HashMap::new())),
route_overrides: Arc::new(Mutex::new(HashMap::new())),
api_key: None,
api_url: None,
reliability: Arc::new(crate::openhuman::config::ReliabilityConfig::default()),
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::openhuman::config::MultimodalConfig::default(),
});
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
provider: Arc::new(SlowProvider {
delay: Duration::from_millis(250),
}),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![]),
observer: Arc::new(NoopObserver),
system_prompt: Arc::new("test-system-prompt".to_string()),
model: Arc::new("test-model".to_string()),
temperature: 0.0,
auto_save_memory: false,
max_tool_iterations: 10,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
provider_cache: Arc::new(Mutex::new(HashMap::new())),
route_overrides: Arc::new(Mutex::new(HashMap::new())),
api_key: None,
api_url: None,
reliability: Arc::new(crate::openhuman::config::ReliabilityConfig::default()),
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::openhuman::config::MultimodalConfig::default(),
});
let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(4);
tx.send(traits::ChannelMessage {
id: "1".to_string(),
sender: "alice".to_string(),
reply_target: "alice".to_string(),
content: "hello".to_string(),
channel: "test-channel".to_string(),
timestamp: 1,
thread_ts: None,
})
.await
.unwrap();
tx.send(traits::ChannelMessage {
id: "2".to_string(),
sender: "bob".to_string(),
reply_target: "bob".to_string(),
content: "world".to_string(),
channel: "test-channel".to_string(),
timestamp: 2,
thread_ts: None,
})
.await
.unwrap();
drop(tx);
let started = Instant::now();
run_message_dispatch_loop(rx, runtime_ctx, 2).await;
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_millis(430),
"expected parallel dispatch (<430ms), got {:?}",
elapsed
);
let sent_messages = channel_impl.sent_messages.lock().await;
assert_eq!(sent_messages.len(), 2);
}
#[tokio::test]
async fn process_channel_message_cancels_scoped_typing_task() {
let channel_impl = Arc::new(RecordingChannel::default());
let channel: Arc<dyn Channel> = channel_impl.clone();
let mut channels_by_name = HashMap::new();
channels_by_name.insert(channel.name().to_string(), channel);
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
provider: Arc::new(SlowProvider {
delay: Duration::from_millis(20),
}),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![]),
observer: Arc::new(NoopObserver),
system_prompt: Arc::new("test-system-prompt".to_string()),
model: Arc::new("test-model".to_string()),
temperature: 0.0,
auto_save_memory: false,
max_tool_iterations: 10,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
provider_cache: Arc::new(Mutex::new(HashMap::new())),
route_overrides: Arc::new(Mutex::new(HashMap::new())),
api_key: None,
api_url: None,
reliability: Arc::new(crate::openhuman::config::ReliabilityConfig::default()),
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::openhuman::config::MultimodalConfig::default(),
});
process_channel_message(
runtime_ctx,
traits::ChannelMessage {
id: "typing-msg".to_string(),
let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(4);
tx.send(traits::ChannelMessage {
id: "1".to_string(),
sender: "alice".to_string(),
reply_target: "chat-typing".to_string(),
reply_target: "alice".to_string(),
content: "hello".to_string(),
channel: "test-channel".to_string(),
timestamp: 1,
thread_ts: None,
},
)
.await;
})
.await
.unwrap();
tx.send(traits::ChannelMessage {
id: "2".to_string(),
sender: "bob".to_string(),
reply_target: "bob".to_string(),
content: "world".to_string(),
channel: "test-channel".to_string(),
timestamp: 2,
thread_ts: None,
})
.await
.unwrap();
drop(tx);
let starts = channel_impl.start_typing_calls.load(Ordering::SeqCst);
let stops = channel_impl.stop_typing_calls.load(Ordering::SeqCst);
assert_eq!(starts, 1, "start_typing should be called once");
assert_eq!(stops, 1, "stop_typing should be called once");
let started = Instant::now();
run_message_dispatch_loop(rx, runtime_ctx, 2).await;
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_millis(430),
"expected parallel dispatch (<430ms), got {:?}",
elapsed
);
let sent_messages = channel_impl.sent_messages.lock().await;
assert_eq!(sent_messages.len(), 2);
}
#[tokio::test]
async fn process_channel_message_cancels_scoped_typing_task() {
let channel_impl = Arc::new(RecordingChannel::default());
let channel: Arc<dyn Channel> = channel_impl.clone();
let mut channels_by_name = HashMap::new();
channels_by_name.insert(channel.name().to_string(), channel);
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
provider: Arc::new(SlowProvider {
delay: Duration::from_millis(20),
}),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![]),
observer: Arc::new(NoopObserver),
system_prompt: Arc::new("test-system-prompt".to_string()),
model: Arc::new("test-model".to_string()),
temperature: 0.0,
auto_save_memory: false,
max_tool_iterations: 10,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
provider_cache: Arc::new(Mutex::new(HashMap::new())),
route_overrides: Arc::new(Mutex::new(HashMap::new())),
api_key: None,
api_url: None,
reliability: Arc::new(crate::openhuman::config::ReliabilityConfig::default()),
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::openhuman::config::MultimodalConfig::default(),
});
process_channel_message(
runtime_ctx,
traits::ChannelMessage {
id: "typing-msg".to_string(),
sender: "alice".to_string(),
reply_target: "chat-typing".to_string(),
content: "hello".to_string(),
channel: "test-channel".to_string(),
timestamp: 1,
thread_ts: None,
},
)
.await;
let starts = channel_impl.start_typing_calls.load(Ordering::SeqCst);
let stops = channel_impl.stop_typing_calls.load(Ordering::SeqCst);
assert_eq!(starts, 1, "start_typing should be called once");
assert_eq!(stops, 1, "stop_typing should be called once");
}
@@ -1,11 +1,13 @@
use super::common::{
HistoryCaptureProvider, IterativeToolProvider, MockPriceTool, ModelCaptureProvider,
NoopMemory, RecordingChannel, SlowProvider, TelegramRecordingChannel, ToolCallingAliasProvider,
ToolCallingProvider,
use super::super::context::{
ChannelRouteSelection, ChannelRuntimeContext, CHANNEL_MESSAGE_TIMEOUT_SECS,
};
use super::super::context::{ChannelRuntimeContext, ChannelRouteSelection, CHANNEL_MESSAGE_TIMEOUT_SECS};
use super::super::runtime::{process_channel_message, run_message_dispatch_loop};
use super::super::{traits, Channel, SendMessage};
use super::common::{
HistoryCaptureProvider, IterativeToolProvider, MockPriceTool, ModelCaptureProvider, NoopMemory,
RecordingChannel, SlowProvider, TelegramRecordingChannel, ToolCallingAliasProvider,
ToolCallingProvider,
};
use crate::openhuman::observability::NoopObserver;
use crate::openhuman::providers::{self, ChatMessage, Provider};
use crate::openhuman::tools::Tool;
+6 -6
View File
@@ -1,5 +1,5 @@
pub mod schema;
pub mod daemon;
pub mod schema;
#[allow(unused_imports)]
pub use daemon::DaemonConfig;
@@ -9,11 +9,11 @@ pub use schema::{
apply_runtime_proxy_to_builder, build_runtime_proxy_client,
build_runtime_proxy_client_with_timeouts, runtime_proxy_config, set_runtime_proxy_config,
AgentConfig, AuditConfig, AutonomyConfig, BrowserComputerUseConfig, BrowserConfig,
ChannelsConfig, ClassificationRule, CloudflareTunnelConfig, ComposioConfig, Config,
CostConfig, CronConfig, CustomTunnelConfig, DelegateAgentConfig, DiscordConfig,
DockerRuntimeConfig, EmbeddingRouteConfig, GatewayConfig, HardwareConfig, HardwareTransport,
HeartbeatConfig, HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig,
MemoryConfig, ModelRouteConfig, MultimodalConfig, NgrokTunnelConfig, ObservabilityConfig,
ChannelsConfig, ClassificationRule, CloudflareTunnelConfig, ComposioConfig, Config, CostConfig,
CronConfig, CustomTunnelConfig, DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig,
EmbeddingRouteConfig, GatewayConfig, HardwareConfig, HardwareTransport, HeartbeatConfig,
HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, MemoryConfig,
ModelRouteConfig, MultimodalConfig, NgrokTunnelConfig, ObservabilityConfig,
PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope, QueryClassificationConfig,
ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig,
SchedulerConfig, SecretsConfig, SecurityConfig, SlackConfig, StorageConfig,
@@ -1,7 +1,7 @@
//! Autonomy and security policy configuration.
use crate::openhuman::security::AutonomyLevel;
use super::defaults;
use crate::openhuman::security::AutonomyLevel;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
+14 -10
View File
@@ -307,12 +307,15 @@ impl Config {
let contents = fs::read_to_string(&config_path)
.await
.context("Failed to read config file")?;
let mut config: Config = toml::from_str(&contents)
.with_context(|| format!("Failed to parse config file {}", config_path.display()))?;
let mut config: Config = toml::from_str(&contents).with_context(|| {
format!("Failed to parse config file {}", config_path.display())
})?;
config.config_path = config_path.clone();
config.workspace_dir = workspace_dir;
let store =
crate::openhuman::security::SecretStore::new(&openhuman_dir, config.secrets.encrypt);
let store = crate::openhuman::security::SecretStore::new(
&openhuman_dir,
config.secrets.encrypt,
);
decrypt_optional_secret(&store, &mut config.api_key, "config.api_key")?;
decrypt_optional_secret(
&store,
@@ -396,9 +399,10 @@ impl Config {
self.default_provider = Some(provider);
}
} else if let Ok(provider) = std::env::var("PROVIDER") {
let should_apply_legacy_provider = self.default_provider.as_deref().map_or(true, |c| {
c.trim().eq_ignore_ascii_case("openrouter")
});
let should_apply_legacy_provider = self
.default_provider
.as_deref()
.map_or(true, |c| c.trim().eq_ignore_ascii_case("openrouter"));
if should_apply_legacy_provider && !provider.is_empty() {
self.default_provider = Some(provider);
}
@@ -412,7 +416,8 @@ impl Config {
if let Ok(workspace) = std::env::var("OPENHUMAN_WORKSPACE") {
if !workspace.is_empty() {
let (_, workspace_dir) = resolve_config_dir_for_workspace(&PathBuf::from(workspace));
let (_, workspace_dir) =
resolve_config_dir_for_workspace(&PathBuf::from(workspace));
self.workspace_dir = workspace_dir;
}
}
@@ -425,8 +430,7 @@ impl Config {
}
}
if let Ok(host) =
std::env::var("OPENHUMAN_GATEWAY_HOST").or_else(|_| std::env::var("HOST"))
if let Ok(host) = std::env::var("OPENHUMAN_GATEWAY_HOST").or_else(|_| std::env::var("HOST"))
{
if !host.is_empty() {
self.gateway.host = host;
+4 -1
View File
@@ -95,7 +95,10 @@ async fn process_due_jobs(config: &Config, security: &Arc<SecurityPolicy>, jobs:
while let Some((job_id, success)) = in_flight.next().await {
if !success {
crate::openhuman::health::mark_component_error("scheduler", format!("job {job_id} failed"));
crate::openhuman::health::mark_component_error(
"scheduler",
format!("job {job_id} failed"),
);
}
}
}
+36 -45
View File
@@ -62,16 +62,16 @@ pub async fn run(
}
log::info!("[openhuman] Daemon supervisor started");
log::info!(
"[openhuman] data_dir: {}",
config.data_dir.display()
);
log::info!("[openhuman] data_dir: {}", config.data_dir.display());
log::info!(
"[openhuman] backoff: {}s initial, {}s max",
initial_backoff,
max_backoff
);
log::info!("[openhuman] health: Events will be emitted every {}s to frontend", STATUS_FLUSH_SECONDS);
log::info!(
"[openhuman] health: Events will be emitted every {}s to frontend",
STATUS_FLUSH_SECONDS
);
// Wait for cancellation (Tauri exit)
cancel.cancelled().await;
@@ -107,11 +107,10 @@ pub async fn run_full(
crate::openhuman::health::mark_component_ok("daemon");
if config.heartbeat.enabled {
let _ =
crate::openhuman::heartbeat::engine::HeartbeatEngine::ensure_heartbeat_file(
&config.workspace_dir,
)
.await;
let _ = crate::openhuman::heartbeat::engine::HeartbeatEngine::ensure_heartbeat_file(
&config.workspace_dir,
)
.await;
}
let mut handles: Vec<JoinHandle<()>> =
@@ -127,9 +126,7 @@ pub async fn run_full(
move || {
let cfg = gateway_cfg.clone();
let host = gateway_host.clone();
async move {
crate::openhuman::gateway::run_gateway(&host, port, cfg).await
}
async move { crate::openhuman::gateway::run_gateway(&host, port, cfg).await }
},
));
}
@@ -198,7 +195,6 @@ pub async fn run_full(
Ok(())
}
/// Get the path to the daemon state file shared between internal and external processes.
pub fn state_file_path(config: &Config) -> PathBuf {
config
@@ -246,8 +242,7 @@ async fn run_heartbeat_worker(config: Config) -> Result<()> {
);
let interval_mins = config.heartbeat.interval_minutes.max(5);
let mut interval =
tokio::time::interval(Duration::from_secs(u64::from(interval_mins) * 60));
let mut interval = tokio::time::interval(Duration::from_secs(u64::from(interval_mins) * 60));
loop {
interval.tick().await;
@@ -260,15 +255,9 @@ async fn run_heartbeat_worker(config: Config) -> Result<()> {
for task in tasks {
let prompt = format!("[Heartbeat Task] {task}");
let temp = config.default_temperature;
if let Err(e) = crate::openhuman::agent::run(
config.clone(),
Some(prompt),
None,
None,
temp,
vec![],
)
.await
if let Err(e) =
crate::openhuman::agent::run(config.clone(), Some(prompt), None, None, temp, vec![])
.await
{
crate::openhuman::health::mark_component_error("heartbeat", e.to_string());
log::warn!("Heartbeat task failed: {e}");
@@ -327,7 +316,10 @@ async fn spawn_state_writer(
let _ = tokio::fs::create_dir_all(parent).await;
}
log::info!("[openhuman] Health state writer starting ({}s intervals)", STATUS_FLUSH_SECONDS);
log::info!(
"[openhuman] Health state writer starting ({}s intervals)",
STATUS_FLUSH_SECONDS
);
log::info!("[openhuman] Health state file: {}", state_path.display());
let mut interval = tokio::time::interval(Duration::from_secs(STATUS_FLUSH_SECONDS));
@@ -335,17 +327,17 @@ async fn spawn_state_writer(
loop {
tokio::select! {
_ = interval.tick() => {
event_count += 1;
if event_count % 12 == 1 { // Log every minute (12 * 5s = 60s)
// log::info!("[openhuman] Health monitoring active (event #{})", event_count);
_ = interval.tick() => {
event_count += 1;
if event_count % 12 == 1 { // Log every minute (12 * 5s = 60s)
// log::info!("[openhuman] Health monitoring active (event #{})", event_count);
}
},
_ = cancel.cancelled() => {
log::info!("[openhuman] Health state writer received shutdown signal");
break;
}
}
},
_ = cancel.cancelled() => {
log::info!("[openhuman] Health state writer received shutdown signal");
break;
}
}
let mut json = crate::openhuman::health::snapshot_json();
if let Some(obj) = json.as_object_mut() {
@@ -353,22 +345,22 @@ async fn spawn_state_writer(
"written_at".into(),
serde_json::json!(Utc::now().to_rfc3339()),
);
obj.insert(
"event_count".into(),
serde_json::json!(event_count),
);
obj.insert("event_count".into(), serde_json::json!(event_count));
}
// Emit Tauri event for frontend consumption
// log::debug!("[openhuman] Emitting health event #{}: {:?}", event_count, json); // Removed noisy log
if let Err(e) = app_handle.emit("openhuman:health", &json) {
log::error!("[openhuman] Failed to emit health event #{}: {}", event_count, e);
log::error!(
"[openhuman] Failed to emit health event #{}: {}",
event_count,
e
);
}
// log::debug!("[openhuman] Health event #{} emitted successfully", event_count); // Removed noisy log
// Also persist to disk
let data =
serde_json::to_vec_pretty(&json).unwrap_or_else(|_| b"{}".to_vec());
let data = serde_json::to_vec_pretty(&json).unwrap_or_else(|_| b"{}".to_vec());
if let Err(e) = tokio::fs::write(&state_path, data).await {
log::debug!("[openhuman] Failed to write health state to disk: {}", e);
}
@@ -446,8 +438,7 @@ mod tests {
#[tokio::test]
async fn supervisor_marks_unexpected_exit_as_error() {
let handle =
spawn_component_supervisor("th-daemon-test-exit", 1, 1, || async { Ok(()) });
let handle = spawn_component_supervisor("th-daemon-test-exit", 1, 1, || async { Ok(()) });
tokio::time::sleep(Duration::from_millis(50)).await;
handle.abort();
+35 -8
View File
@@ -87,7 +87,11 @@ pub fn run(config: &Config) -> Result<DoctorReport> {
Ok(DoctorReport {
items,
summary: DoctorSummary { ok, warnings, errors },
summary: DoctorSummary {
ok,
warnings,
errors,
},
})
}
@@ -618,7 +622,10 @@ fn check_daemon_state(config: &Config, items: &mut Vec<DiagnosticItem>) {
let raw = match std::fs::read_to_string(&state_file) {
Ok(r) => r,
Err(e) => {
items.push(DiagnosticItem::error(cat, format!("cannot read state file: {e}")));
items.push(DiagnosticItem::error(
cat,
format!("cannot read state file: {e}"),
));
return;
}
};
@@ -626,7 +633,10 @@ fn check_daemon_state(config: &Config, items: &mut Vec<DiagnosticItem>) {
let snapshot: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
items.push(DiagnosticItem::error(cat, format!("invalid state JSON: {e}")));
items.push(DiagnosticItem::error(
cat,
format!("invalid state JSON: {e}"),
));
return;
}
};
@@ -642,7 +652,10 @@ fn check_daemon_state(config: &Config, items: &mut Vec<DiagnosticItem>) {
.signed_duration_since(ts.with_timezone(&Utc))
.num_seconds();
if age <= DAEMON_STALE_SECONDS {
items.push(DiagnosticItem::ok(cat, format!("heartbeat fresh ({age}s ago)")));
items.push(DiagnosticItem::ok(
cat,
format!("heartbeat fresh ({age}s ago)"),
));
} else {
items.push(DiagnosticItem::error(
cat,
@@ -687,7 +700,10 @@ fn check_daemon_state(config: &Config, items: &mut Vec<DiagnosticItem>) {
));
}
} else {
items.push(DiagnosticItem::warn(cat, "scheduler component not tracked yet"));
items.push(DiagnosticItem::warn(
cat,
"scheduler component not tracked yet",
));
}
// Channels
@@ -711,7 +727,10 @@ fn check_daemon_state(config: &Config, items: &mut Vec<DiagnosticItem>) {
});
if status_ok && age <= CHANNEL_STALE_SECONDS {
items.push(DiagnosticItem::ok(cat, format!("{name} fresh ({age}s ago)")));
items.push(DiagnosticItem::ok(
cat,
format!("{name} fresh ({age}s ago)"),
));
} else {
stale += 1;
items.push(DiagnosticItem::error(
@@ -722,7 +741,10 @@ fn check_daemon_state(config: &Config, items: &mut Vec<DiagnosticItem>) {
}
if channel_count == 0 {
items.push(DiagnosticItem::warn(cat, "no channel components tracked yet"));
items.push(DiagnosticItem::warn(
cat,
"no channel components tracked yet",
));
} else if stale > 0 {
items.push(DiagnosticItem::warn(
cat,
@@ -762,7 +784,12 @@ fn check_environment(items: &mut Vec<DiagnosticItem>) {
check_command_available("curl", &["--version"], cat, items);
}
fn check_command_available(cmd: &str, args: &[&str], cat: &'static str, items: &mut Vec<DiagnosticItem>) {
fn check_command_available(
cmd: &str,
args: &[&str],
cat: &'static str,
items: &mut Vec<DiagnosticItem>,
) {
match std::process::Command::new(cmd)
.args(args)
.stdout(std::process::Stdio::piped())
@@ -26,8 +26,8 @@ pub async fn handle_metrics(State(state): State<AppState>) -> impl IntoResponse
.observer
.as_ref()
.as_any()
.downcast_ref::<crate::openhuman::observability::PrometheusObserver>()
{
.downcast_ref::<crate::openhuman::observability::PrometheusObserver>(
) {
prom.encode()
} else {
String::from(
@@ -1,8 +1,8 @@
//! Linq webhook handlers.
use super::webhook::run_gateway_chat_with_multimodal;
use crate::openhuman::channels::SendMessage;
use crate::openhuman::channels::traits::Channel;
use crate::openhuman::channels::SendMessage;
use crate::openhuman::gateway::state::AppState;
use crate::openhuman::memory::MemoryCategory;
use crate::openhuman::util::truncate_with_ellipsis;
@@ -48,7 +48,11 @@ pub async fn handle_linq_webhook(
) {
tracing::warn!(
"Linq webhook signature verification failed (signature: {})",
if signature.is_empty() { "missing" } else { "invalid" }
if signature.is_empty() {
"missing"
} else {
"invalid"
}
);
return (
StatusCode::UNAUTHORIZED,
@@ -21,7 +21,8 @@ pub async fn handle_pair(
ConnectInfo(peer_addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> impl IntoResponse {
let rate_key = client_key_from_request(Some(peer_addr), &headers, state.trust_forwarded_headers);
let rate_key =
client_key_from_request(Some(peer_addr), &headers, state.trust_forwarded_headers);
if !state.rate_limiter.allow_pair(&rate_key) {
tracing::warn!("/pair rate limit exceeded");
let err = serde_json::json!({
@@ -72,7 +72,8 @@ pub async fn handle_webhook(
headers: HeaderMap,
body: Result<Json<WebhookBody>, axum::extract::rejection::JsonRejection>,
) -> impl IntoResponse {
let rate_key = client_key_from_request(Some(peer_addr), &headers, state.trust_forwarded_headers);
let rate_key =
client_key_from_request(Some(peer_addr), &headers, state.trust_forwarded_headers);
if !state.rate_limiter.allow_webhook(&rate_key) {
tracing::warn!("/webhook rate limit exceeded");
let err = serde_json::json!({
@@ -110,8 +111,7 @@ pub async fn handle_webhook(
Some(val) if constant_time_eq(&val, secret_hash.as_ref()) => {}
_ => {
tracing::warn!("Webhook: rejected request — invalid or missing X-Webhook-Secret");
let err =
serde_json::json!({"error": "Unauthorized — invalid or missing X-Webhook-Secret header"});
let err = serde_json::json!({"error": "Unauthorized — invalid or missing X-Webhook-Secret header"});
return (StatusCode::UNAUTHORIZED, Json(err));
}
}
@@ -179,44 +179,44 @@ pub async fn handle_webhook(
let model_label = state.model.clone();
let started_at = Instant::now();
state
.observer
.record_event(&crate::openhuman::observability::ObserverEvent::AgentStart {
state.observer.record_event(
&crate::openhuman::observability::ObserverEvent::AgentStart {
provider: provider_label.clone(),
model: model_label.clone(),
});
state
.observer
.record_event(&crate::openhuman::observability::ObserverEvent::LlmRequest {
},
);
state.observer.record_event(
&crate::openhuman::observability::ObserverEvent::LlmRequest {
provider: provider_label.clone(),
model: model_label.clone(),
messages_count: 1,
});
},
);
match run_gateway_chat_with_multimodal(&state, &provider_label, message).await {
Ok(response) => {
let duration = started_at.elapsed();
state
.observer
.record_event(&crate::openhuman::observability::ObserverEvent::LlmResponse {
state.observer.record_event(
&crate::openhuman::observability::ObserverEvent::LlmResponse {
provider: provider_label.clone(),
model: model_label.clone(),
duration,
success: true,
error_message: None,
});
},
);
state.observer.record_metric(
&crate::openhuman::observability::traits::ObserverMetric::RequestLatency(duration),
);
state
.observer
.record_event(&crate::openhuman::observability::ObserverEvent::AgentEnd {
state.observer.record_event(
&crate::openhuman::observability::ObserverEvent::AgentEnd {
provider: provider_label,
model: model_label,
duration,
tokens_used: None,
cost_usd: None,
});
},
);
let body = serde_json::json!({"response": response, "model": state.model});
(StatusCode::OK, Json(body))
@@ -225,15 +225,15 @@ pub async fn handle_webhook(
let duration = started_at.elapsed();
let sanitized = providers::sanitize_api_error(&e.to_string());
state
.observer
.record_event(&crate::openhuman::observability::ObserverEvent::LlmResponse {
state.observer.record_event(
&crate::openhuman::observability::ObserverEvent::LlmResponse {
provider: provider_label.clone(),
model: model_label.clone(),
duration,
success: false,
error_message: Some(sanitized.clone()),
});
},
);
state.observer.record_metric(
&crate::openhuman::observability::traits::ObserverMetric::RequestLatency(duration),
);
@@ -243,15 +243,15 @@ pub async fn handle_webhook(
component: "gateway".to_string(),
message: sanitized.clone(),
});
state
.observer
.record_event(&crate::openhuman::observability::ObserverEvent::AgentEnd {
state.observer.record_event(
&crate::openhuman::observability::ObserverEvent::AgentEnd {
provider: provider_label,
model: model_label,
duration,
tokens_used: None,
cost_usd: None,
});
},
);
tracing::error!("Webhook provider error: {}", sanitized);
let err = serde_json::json!({"error": "LLM request failed"});
@@ -1,8 +1,8 @@
//! WhatsApp webhook handlers and signature verification.
use super::webhook::run_gateway_chat_with_multimodal;
use crate::openhuman::channels::SendMessage;
use crate::openhuman::channels::traits::Channel;
use crate::openhuman::channels::SendMessage;
use crate::openhuman::gateway::models::WhatsAppVerifyQuery;
use crate::openhuman::gateway::state::AppState;
use crate::openhuman::memory::MemoryCategory;
@@ -91,7 +91,11 @@ pub async fn handle_whatsapp_message(
if !verify_whatsapp_signature(app_secret, &body, signature) {
tracing::warn!(
"WhatsApp webhook signature verification failed (signature: {})",
if signature.is_empty() { "missing" } else { "invalid" }
if signature.is_empty() {
"missing"
} else {
"invalid"
}
);
return (
StatusCode::UNAUTHORIZED,
@@ -85,7 +85,8 @@ pub struct GatewayRateLimiter {
impl GatewayRateLimiter {
pub fn new(pair_per_minute: u32, webhook_per_minute: u32, max_keys: usize) -> Self {
let window = Duration::from_secs(crate::openhuman::gateway::constants::RATE_LIMIT_WINDOW_SECS);
let window =
Duration::from_secs(crate::openhuman::gateway::constants::RATE_LIMIT_WINDOW_SECS);
Self {
pair: SlidingWindowRateLimiter::new(pair_per_minute, window, max_keys),
webhook: SlidingWindowRateLimiter::new(webhook_per_minute, window, max_keys),
+3 -2
View File
@@ -247,8 +247,9 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
crate::openhuman::health::mark_component_ok("gateway");
// Build shared state
let observer: Arc<dyn crate::openhuman::observability::Observer> =
Arc::from(crate::openhuman::observability::create_observer(&config.observability));
let observer: Arc<dyn crate::openhuman::observability::Observer> = Arc::from(
crate::openhuman::observability::create_observer(&config.observability),
);
let state = AppState {
config: config_state,
+1 -1
View File
@@ -2,10 +2,10 @@
use crate::openhuman::channels::{LinqChannel, WhatsAppChannel};
use crate::openhuman::config::Config;
use crate::openhuman::gateway::rate_limit::{GatewayRateLimiter, IdempotencyStore};
use crate::openhuman::memory::Memory;
use crate::openhuman::providers::Provider;
use crate::openhuman::security::pairing::PairingGuard;
use crate::openhuman::gateway::rate_limit::{GatewayRateLimiter, IdempotencyStore};
use parking_lot::Mutex;
use std::sync::Arc;
+6 -2
View File
@@ -104,7 +104,9 @@ async fn metrics_endpoint_returns_hint_when_prometheus_is_disabled() {
observer: Arc::new(crate::openhuman::observability::NoopObserver),
};
let response = handle_metrics(axum::extract::State(state)).await.into_response();
let response = handle_metrics(axum::extract::State(state))
.await
.into_response();
assert_eq!(response.status(), axum::http::StatusCode::OK);
assert_eq!(
response
@@ -147,7 +149,9 @@ async fn metrics_endpoint_renders_prometheus_output() {
observer,
};
let response = handle_metrics(axum::extract::State(state)).await.into_response();
let response = handle_metrics(axum::extract::State(state))
.await
.into_response();
assert_eq!(response.status(), axum::http::StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
+5 -1
View File
@@ -71,7 +71,11 @@ pub fn mark_component_ok(component: &str) {
#[allow(clippy::needless_pass_by_value)]
pub fn mark_component_error(component: &str, error: impl ToString) {
let err = error.to_string();
log::warn!("[openhuman:health] Component '{}' error: {}", component, err);
log::warn!(
"[openhuman:health] Component '{}' error: {}",
component,
err
);
upsert_component(component, move |entry| {
entry.status = "error".into();
entry.last_error = Some(err);
@@ -725,7 +725,9 @@ pub fn all_integrations() -> Vec<IntegrationEntry> {
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::config::schema::{IMessageConfig, MatrixConfig, StreamMode, TelegramConfig};
use crate::openhuman::config::schema::{
IMessageConfig, MatrixConfig, StreamMode, TelegramConfig,
};
use crate::openhuman::config::Config;
#[test]
+8 -5
View File
@@ -291,7 +291,13 @@ fn normalize_key(raw: &str, idx: usize) -> String {
trimmed
.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect::<String>()
.trim_matches('_')
.to_string()
@@ -380,10 +386,7 @@ fn pick_column_expr<'a>(
fallback
}
fn pick_optional_column_expr<'a>(
columns: &'a [String],
candidates: &[&'a str],
) -> Option<&'a str> {
fn pick_optional_column_expr<'a>(columns: &'a [String], candidates: &[&'a str]) -> Option<&'a str> {
for candidate in candidates {
if columns.iter().any(|c| c.eq_ignore_ascii_case(candidate)) {
return Some(candidate);
+2 -2
View File
@@ -11,8 +11,8 @@
// Many types/functions are not yet consumed but are intentionally exported.
#![allow(dead_code)]
pub mod approval;
pub mod agent;
pub mod approval;
pub mod channels;
pub mod config;
pub mod cost;
@@ -28,8 +28,8 @@ pub mod integrations;
pub mod memory;
pub mod migration;
pub mod multimodal;
pub mod onboard;
pub mod observability;
pub mod onboard;
pub mod peripherals;
pub mod providers;
pub mod rag;
+1 -3
View File
@@ -2,9 +2,7 @@
pub mod models;
pub use models::{
run_models_refresh, ModelCacheSnapshot, ModelRefreshResult, ModelRefreshSource,
};
pub use models::{run_models_refresh, ModelCacheSnapshot, ModelRefreshResult, ModelRefreshSource};
#[cfg(test)]
mod tests {
+6 -4
View File
@@ -118,9 +118,8 @@ pub fn run_models_refresh(
});
}
Err(error).with_context(|| {
format!("failed to refresh models for provider '{provider_name}'")
})
Err(error)
.with_context(|| format!("failed to refresh models for provider '{provider_name}'"))
}
}
}
@@ -501,7 +500,10 @@ fn save_model_cache_state(workspace_dir: &Path, state: &ModelCacheState) -> Resu
let path = model_cache_path(workspace_dir);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("failed to create model cache directory {}", parent.display())
format!(
"failed to create model cache directory {}",
parent.display()
)
})?;
}
@@ -132,7 +132,10 @@ pub fn flash_arduino_firmware(port: &str) -> Result<()> {
}
/// Resolve port from config or path. Returns the path to use for flashing.
pub fn resolve_port(config: &crate::openhuman::config::Config, path_override: Option<&str>) -> Option<String> {
pub fn resolve_port(
config: &crate::openhuman::config::Config,
path_override: Option<&str>,
) -> Option<String> {
if let Some(p) = path_override {
return Some(p.to_string());
}
+6 -6
View File
@@ -115,12 +115,12 @@ pub async fn create_peripheral_tools(config: &PeripheralsConfig) -> Result<Vec<B
if !tools.is_empty() {
let board_names: Vec<String> = config.boards.iter().map(|b| b.board.clone()).collect();
tools.push(Box::new(HardwareMemoryMapTool::new(board_names.clone())));
tools.push(Box::new(crate::openhuman::tools::HardwareBoardInfoTool::new(
board_names.clone(),
)));
tools.push(Box::new(crate::openhuman::tools::HardwareMemoryReadTool::new(
board_names,
)));
tools.push(Box::new(
crate::openhuman::tools::HardwareBoardInfoTool::new(board_names.clone()),
));
tools.push(Box::new(
crate::openhuman::tools::HardwareMemoryReadTool::new(board_names),
));
}
// Phase C: Add hardware_capabilities tool when any serial boards
@@ -114,7 +114,8 @@ fn write_embedded_bridge(dest: &std::path::Path) -> Result<()> {
let sketch_ino = include_str!("../../firmware/openhuman-uno-q-bridge/sketch/sketch.ino");
let sketch_yaml = include_str!("../../firmware/openhuman-uno-q-bridge/sketch/sketch.yaml");
let main_py = include_str!("../../firmware/openhuman-uno-q-bridge/python/main.py");
let requirements = include_str!("../../firmware/openhuman-uno-q-bridge/python/requirements.txt");
let requirements =
include_str!("../../firmware/openhuman-uno-q-bridge/python/requirements.txt");
std::fs::write(dest.join("app.yaml"), app_yaml)?;
std::fs::create_dir_all(dest.join("sketch"))?;
@@ -400,7 +400,11 @@ impl AnthropicProvider {
}
fn http_client(&self) -> Client {
crate::openhuman::config::build_runtime_proxy_client_with_timeouts("provider.anthropic", 120, 10)
crate::openhuman::config::build_runtime_proxy_client_with_timeouts(
"provider.anthropic",
120,
10,
)
}
}
+5 -1
View File
@@ -349,7 +349,11 @@ impl BedrockProvider {
}
fn http_client(&self) -> Client {
crate::openhuman::config::build_runtime_proxy_client_with_timeouts("provider.bedrock", 120, 10)
crate::openhuman::config::build_runtime_proxy_client_with_timeouts(
"provider.bedrock",
120,
10,
)
}
/// Percent-encode the model ID for URL path: only encode `:` to `%3A`.
@@ -159,8 +159,10 @@ impl OpenAiCompatibleProvider {
.timeout(std::time::Duration::from_secs(120))
.connect_timeout(std::time::Duration::from_secs(10))
.default_headers(headers);
let builder =
crate::openhuman::config::apply_runtime_proxy_to_builder(builder, "provider.compatible");
let builder = crate::openhuman::config::apply_runtime_proxy_to_builder(
builder,
"provider.compatible",
);
return builder.build().unwrap_or_else(|error| {
tracing::warn!("Failed to build proxied timeout client with user-agent: {error}");
@@ -168,7 +170,11 @@ impl OpenAiCompatibleProvider {
});
}
crate::openhuman::config::build_runtime_proxy_client_with_timeouts("provider.compatible", 120, 10)
crate::openhuman::config::build_runtime_proxy_client_with_timeouts(
"provider.compatible",
120,
10,
)
}
/// Build the full URL for chat completions, detecting if base_url already includes the path.
@@ -233,7 +239,9 @@ impl OpenAiCompatibleProvider {
}
}
fn tool_specs_to_openai_format(tools: &[crate::openhuman::tools::ToolSpec]) -> Vec<serde_json::Value> {
fn tool_specs_to_openai_format(
tools: &[crate::openhuman::tools::ToolSpec],
) -> Vec<serde_json::Value> {
tools
.iter()
.map(|tool| {
+5 -1
View File
@@ -208,7 +208,11 @@ impl CopilotProvider {
}
fn http_client(&self) -> Client {
crate::openhuman::config::build_runtime_proxy_client_with_timeouts("provider.copilot", 120, 10)
crate::openhuman::config::build_runtime_proxy_client_with_timeouts(
"provider.copilot",
120,
10,
)
}
/// Required headers for Copilot API requests (editor identification).
+5 -1
View File
@@ -276,7 +276,11 @@ impl GeminiProvider {
}
fn http_client(&self) -> Client {
crate::openhuman::config::build_runtime_proxy_client_with_timeouts("provider.gemini", 120, 10)
crate::openhuman::config::build_runtime_proxy_client_with_timeouts(
"provider.gemini",
120,
10,
)
}
fn build_generate_content_request(
+5 -1
View File
@@ -124,7 +124,11 @@ impl OllamaProvider {
}
fn http_client(&self) -> Client {
crate::openhuman::config::build_runtime_proxy_client_with_timeouts("provider.ollama", 300, 10)
crate::openhuman::config::build_runtime_proxy_client_with_timeouts(
"provider.ollama",
300,
10,
)
}
fn resolve_request_details(&self, model: &str) -> anyhow::Result<(String, bool)> {
+5 -1
View File
@@ -250,7 +250,11 @@ impl OpenAiProvider {
}
fn http_client(&self) -> Client {
crate::openhuman::config::build_runtime_proxy_client_with_timeouts("provider.openai", 120, 10)
crate::openhuman::config::build_runtime_proxy_client_with_timeouts(
"provider.openai",
120,
10,
)
}
}
@@ -221,7 +221,11 @@ impl OpenRouterProvider {
}
fn http_client(&self) -> Client {
crate::openhuman::config::build_runtime_proxy_client_with_timeouts("provider.openrouter", 120, 10)
crate::openhuman::config::build_runtime_proxy_client_with_timeouts(
"provider.openrouter",
120,
10,
)
}
}
+1 -3
View File
@@ -239,9 +239,7 @@ pub enum ToolsPayload {
fn should_log_prompts() -> bool {
matches!(
std::env::var("OPENHUMAN_LOG_PROMPTS")
.ok()
.as_deref(),
std::env::var("OPENHUMAN_LOG_PROMPTS").ok().as_deref(),
Some("1") | Some("true") | Some("TRUE") | Some("yes") | Some("YES")
)
}
+3 -9
View File
@@ -25,9 +25,7 @@ pub fn create_sandbox(config: &SecurityConfig) -> Arc<dyn Sandbox> {
}
}
}
log::warn!(
"Landlock requested but not available, falling back to application-layer"
);
log::warn!("Landlock requested but not available, falling back to application-layer");
Arc::new(super::traits::NoopSandbox)
}
SandboxBackend::Firejail => {
@@ -37,9 +35,7 @@ pub fn create_sandbox(config: &SecurityConfig) -> Arc<dyn Sandbox> {
return Arc::new(sandbox);
}
}
log::warn!(
"Firejail requested but not available, falling back to application-layer"
);
log::warn!("Firejail requested but not available, falling back to application-layer");
Arc::new(super::traits::NoopSandbox)
}
SandboxBackend::Bubblewrap => {
@@ -52,9 +48,7 @@ pub fn create_sandbox(config: &SecurityConfig) -> Arc<dyn Sandbox> {
}
}
}
log::warn!(
"Bubblewrap requested but not available, falling back to application-layer"
);
log::warn!("Bubblewrap requested but not available, falling back to application-layer");
Arc::new(super::traits::NoopSandbox)
}
SandboxBackend::Docker => {
+12 -6
View File
@@ -86,9 +86,7 @@ pub fn start(config: &Config) -> Result<ServiceStatus> {
.arg(&plist),
);
if let Err(err) = bootstrap_ok {
log::warn!(
"[service] launchctl bootstrap failed, falling back to load -w: {err}"
);
log::warn!("[service] launchctl bootstrap failed, falling back to load -w: {err}");
run_checked(Command::new("launchctl").arg("load").arg("-w").arg(&plist))?;
}
} else {
@@ -217,7 +215,11 @@ pub fn stop(config: &Config) -> Result<ServiceStatus> {
// Compatibility fallback.
run_best_effort(Command::new("launchctl").arg("stop").arg(SERVICE_LABEL));
run_best_effort(Command::new("launchctl").arg("stop").arg(LEGACY_SERVICE_LABEL));
run_best_effort(
Command::new("launchctl")
.arg("stop")
.arg(LEGACY_SERVICE_LABEL),
);
return status(config);
}
@@ -579,8 +581,12 @@ fn run_best_effort(cmd: &mut Command) {
/// Check if the macOS LaunchAgent service is loaded (regardless of running state)
fn is_service_loaded_macos() -> Result<bool> {
if run_checked(Command::new("launchctl").arg("print").arg(macos_target(SERVICE_LABEL)?))
.is_ok()
if run_checked(
Command::new("launchctl")
.arg("print")
.arg(macos_target(SERVICE_LABEL)?),
)
.is_ok()
{
return Ok(true);
}
+2 -5
View File
@@ -430,8 +430,7 @@ impl BrowserTool {
anyhow::bail!("Blocked local/private host: {host}");
}
if !self.allowed_domains.is_empty()
&& !host_matches_allowlist(&host, &self.allowed_domains)
if !self.allowed_domains.is_empty() && !host_matches_allowlist(&host, &self.allowed_domains)
{
anyhow::bail!("Host '{host}' not in browser.allowed_domains");
}
@@ -2077,9 +2076,7 @@ fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool {
fn allow_all_browser_domains() -> bool {
matches!(
std::env::var("OPENHUMAN_BROWSER_ALLOW_ALL")
.ok()
.as_deref(),
std::env::var("OPENHUMAN_BROWSER_ALLOW_ALL").ok().as_deref(),
Some("1") | Some("true") | Some("TRUE") | Some("yes") | Some("YES")
)
}
@@ -118,7 +118,8 @@ impl HttpRequestTool {
.timeout(Duration::from_secs(self.timeout_secs))
.connect_timeout(Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none());
let builder = crate::openhuman::config::apply_runtime_proxy_to_builder(builder, "tool.http_request");
let builder =
crate::openhuman::config::apply_runtime_proxy_to_builder(builder, "tool.http_request");
let client = builder.build()?;
let mut request = client.request(method, url);
+2 -2
View File
@@ -363,8 +363,8 @@ mod tests {
.unwrap();
assert!(allowed.success);
let _ =
tokio::fs::remove_file(std::env::temp_dir().join("openhuman_shell_approval_test")).await;
let _ = tokio::fs::remove_file(std::env::temp_dir().join("openhuman_shell_approval_test"))
.await;
}
// ── §5.2 Shell timeout enforcement tests ─────────────────
+1 -3
View File
@@ -130,9 +130,7 @@ pub fn create_tunnel(config: &TunnelConfig) -> Result<Option<Box<dyn Tunnel>>> {
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::config::{
CloudflareTunnelConfig, CustomTunnelConfig, NgrokTunnelConfig,
};
use crate::openhuman::config::{CloudflareTunnelConfig, CustomTunnelConfig, NgrokTunnelConfig};
/// Helper: assert `create_tunnel` returns an error containing `needle`.
fn assert_tunnel_err(cfg: &TunnelConfig, needle: &str) {
+17 -13
View File
@@ -59,7 +59,10 @@ impl SkillDb {
/// Execute a SQL statement (no result rows expected).
pub fn exec(&self, sql: &str, params: &[JsonValue]) -> Result<usize, String> {
let conn = self.conn.lock().map_err(|e| format!("DB lock error: {e}"))?;
let conn = self
.conn
.lock()
.map_err(|e| format!("DB lock error: {e}"))?;
let sqlite_params = json_values_to_sqlite(params);
conn.execute(sql, params_from_iter(sqlite_params.iter()))
.map_err(|e| format!("SQL exec error: {e}"))
@@ -67,7 +70,10 @@ impl SkillDb {
/// Fetch a single row as a JSON object. Returns `null` if no row matches.
pub fn get(&self, sql: &str, params: &[JsonValue]) -> Result<JsonValue, String> {
let conn = self.conn.lock().map_err(|e| format!("DB lock error: {e}"))?;
let conn = self
.conn
.lock()
.map_err(|e| format!("DB lock error: {e}"))?;
let sqlite_params = json_values_to_sqlite(params);
let mut stmt = conn
.prepare(sql)
@@ -91,7 +97,10 @@ impl SkillDb {
/// Fetch all matching rows as a JSON array of objects.
pub fn all(&self, sql: &str, params: &[JsonValue]) -> Result<JsonValue, String> {
let conn = self.conn.lock().map_err(|e| format!("DB lock error: {e}"))?;
let conn = self
.conn
.lock()
.map_err(|e| format!("DB lock error: {e}"))?;
let sqlite_params = json_values_to_sqlite(params);
let mut stmt = conn
.prepare(sql)
@@ -124,10 +133,7 @@ impl SkillDb {
match result {
JsonValue::Null => Ok(JsonValue::Null),
obj => {
let val_str = obj
.get("value")
.and_then(|v| v.as_str())
.unwrap_or("null");
let val_str = obj.get("value").and_then(|v| v.as_str()).unwrap_or("null");
serde_json::from_str(val_str)
.map_err(|e| format!("Failed to parse KV value for '{key}': {e}"))
}
@@ -213,12 +219,10 @@ fn row_to_json(row: &rusqlite::Row<'_>, column_names: &[String]) -> Result<JsonV
let text = String::from_utf8_lossy(s).to_string();
JsonValue::String(text)
}
Ok(rusqlite::types::ValueRef::Blob(b)) => {
JsonValue::String(base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
b,
))
}
Ok(rusqlite::types::ValueRef::Blob(b)) => JsonValue::String(base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
b,
)),
Err(e) => return Err(format!("Failed to read column '{}': {}", name, e)),
};
obj.insert(name.clone(), value);
+1 -5
View File
@@ -91,11 +91,7 @@ fn do_fetch(url: &str, options_json: &str) -> Result<String, String> {
let headers: HashMap<String, String> = resp
.headers()
.iter()
.filter_map(|(k, v)| {
v.to_str()
.ok()
.map(|val| (k.to_string(), val.to_string()))
})
.filter_map(|(k, v)| v.to_str().ok().map(|val| (k.to_string(), val.to_string())))
.collect();
let body = resp
.text()
@@ -68,7 +68,9 @@ pub fn call_tool(
.build()
.map_err(|e| format!("Failed to create runtime: {e}"))
.map(|rt| {
rt.block_on(async { registry.call_tool(&target, &tool, arguments_clone).await })
rt.block_on(async {
registry.call_tool(&target, &tool, arguments_clone).await
})
})
});
+3 -12
View File
@@ -98,13 +98,8 @@ impl CronScheduler {
/// Unregister all schedules for a skill (called when skill stops).
pub fn unregister_all_for_skill(&self, skill_id: &str) {
let prefix = format!("{}:", skill_id);
self.entries
.write()
.retain(|k, _| !k.starts_with(&prefix));
log::info!(
"[cron] Unregistered all schedules for skill '{}'",
skill_id
);
self.entries.write().retain(|k, _| !k.starts_with(&prefix));
log::info!("[cron] Unregistered all schedules for skill '{}'", skill_id);
}
/// List all registered schedules for a skill.
@@ -200,11 +195,7 @@ impl CronScheduler {
if let Some(registry) = registry {
for (skill_id, schedule_id) in to_fire {
log::info!(
"[cron] Firing '{}' for skill '{}'",
schedule_id,
skill_id
);
log::info!("[cron] Firing '{}' for skill '{}'", schedule_id, skill_id);
if let Err(e) = registry.trigger_cron(&skill_id, &schedule_id).await {
log::warn!(
"[cron] Failed to trigger '{}:{}': {e}",
+2 -2
View File
@@ -22,8 +22,8 @@ pub mod cron_scheduler;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub mod ping_scheduler;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub mod skill_registry;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub mod qjs_engine;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub mod qjs_skill_instance;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub mod skill_registry;
+4 -1
View File
@@ -78,7 +78,10 @@ impl PingScheduler {
let mut stop_rx = self.stop_tx.subscribe();
tokio::spawn(async move {
log::info!("[ping] Scheduler started ({}s interval)", PING_INTERVAL.as_secs());
log::info!(
"[ping] Scheduler started ({}s interval)",
PING_INTERVAL.as_secs()
);
loop {
tokio::select! {
+19 -13
View File
@@ -13,10 +13,10 @@ use crate::runtime::cron_scheduler::CronScheduler;
use crate::runtime::manifest::SkillManifest;
use crate::runtime::ping_scheduler::PingScheduler;
use crate::runtime::preferences::PreferencesStore;
use crate::runtime::qjs_skill_instance::{BridgeDeps, QjsSkillInstance};
use crate::runtime::skill_registry::SkillRegistry;
use crate::runtime::socket_manager::SocketManager;
use crate::runtime::types::{events, SkillMessage, SkillSnapshot, SkillStatus, ToolResult};
use crate::runtime::qjs_skill_instance::{BridgeDeps, QjsSkillInstance};
// IdbStorage removed during runtime cleanup
/// The central runtime engine using QuickJS.
@@ -248,10 +248,7 @@ impl RuntimeEngine {
let manifest_path = skill_dir.join("manifest.json");
if !manifest_path.exists() {
return Err(format!(
"Skill '{}' not found (no manifest.json)",
skill_id
));
return Err(format!("Skill '{}' not found (no manifest.json)", skill_id));
}
let manifest = SkillManifest::from_path(&manifest_path).await?;
@@ -266,12 +263,18 @@ impl RuntimeEngine {
let data_dir = self.skills_data_dir.join(skill_id);
// Create the QuickJS skill instance
log::info!("[runtime] Creating QuickJS skill instance for '{}'", skill_id);
log::info!(
"[runtime] Creating QuickJS skill instance for '{}'",
skill_id
);
log::info!("[runtime] Config: {:?}", config);
log::info!("[runtime] Skill dir: {:?}", skill_dir);
log::info!("[runtime] Data dir: {:?}", data_dir);
let (instance, rx) = QjsSkillInstance::new(config.clone(), skill_dir, data_dir.clone());
log::info!("[runtime] QuickJS skill instance created for '{}'", skill_id);
log::info!(
"[runtime] QuickJS skill instance created for '{}'",
skill_id
);
// Bundle bridge dependencies (no creation lock needed for QuickJS)
let deps = BridgeDeps {
@@ -380,7 +383,9 @@ impl RuntimeEngine {
tool_name: &str,
arguments: serde_json::Value,
) -> Result<ToolResult, String> {
self.registry.call_tool(skill_id, tool_name, arguments).await
self.registry
.call_tool(skill_id, tool_name, arguments)
.await
}
/// Broadcast a server event to all running skills.
@@ -505,7 +510,11 @@ impl RuntimeEngine {
params: serde_json::Value::Object(load_params),
};
if let Err(e) = self.registry.send_message(skill_id, msg) {
log::warn!("[runtime] Failed to send LoadParams to skill '{}': {}", skill_id, e);
log::warn!(
"[runtime] Failed to send LoadParams to skill '{}': {}",
skill_id,
e
);
}
}
Ok(serde_json::json!({ "ok": true }))
@@ -566,10 +575,7 @@ impl RuntimeEngine {
}
"tools/call" => {
let tool_name = params
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("");
let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
let arguments = params
.get("arguments")
.cloned()
+202 -137
View File
@@ -129,7 +129,10 @@ impl QjsSkillInstance {
let mut s = state.write();
s.status = SkillStatus::Error;
s.error = Some(format!("Failed to read {}: {e}", config.entry_point));
log::error!("[skill:{}] Failed to read entry point: {e}", config.skill_id);
log::error!(
"[skill:{}] Failed to read entry point: {e}",
config.skill_id
);
return;
}
};
@@ -141,7 +144,10 @@ impl QjsSkillInstance {
let mut s = state.write();
s.status = SkillStatus::Error;
s.error = Some(format!("Failed to create QuickJS runtime: {e}"));
log::error!("[skill:{}] Failed to create QuickJS runtime: {e}", config.skill_id);
log::error!(
"[skill:{}] Failed to create QuickJS runtime: {e}",
config.skill_id
);
return;
}
};
@@ -173,53 +179,55 @@ impl QjsSkillInstance {
// Register ops and run bootstrap + skill code
let skill_id = config.skill_id.clone();
let init_result = ctx.with(|js_ctx| {
// Register native ops as __ops global
let skill_context = qjs_ops::SkillContext {
skill_id: skill_id.clone(),
data_dir: data_dir.clone(),
app_handle: _deps.app_handle.clone(),
};
let init_result = ctx
.with(|js_ctx| {
// Register native ops as __ops global
let skill_context = qjs_ops::SkillContext {
skill_id: skill_id.clone(),
data_dir: data_dir.clone(),
app_handle: _deps.app_handle.clone(),
};
if let Err(e) = qjs_ops::register_ops(
&js_ctx,
storage.clone(),
skill_context,
published_state.clone(),
timer_state.clone(),
ws_state.clone(),
) {
return Err(format!("Failed to register ops: {e}"));
}
if let Err(e) = qjs_ops::register_ops(
&js_ctx,
storage.clone(),
skill_context,
published_state.clone(),
timer_state.clone(),
ws_state.clone(),
) {
return Err(format!("Failed to register ops: {e}"));
}
// Load bootstrap
let bootstrap_code = include_str!("../services/quickjs_libs/bootstrap.js");
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(bootstrap_code) {
let detail = format_js_exception(&js_ctx, &e);
return Err(format!("Bootstrap failed: {detail}"));
}
// Load bootstrap
let bootstrap_code = include_str!("../services/quickjs_libs/bootstrap.js");
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(bootstrap_code) {
let detail = format_js_exception(&js_ctx, &e);
return Err(format!("Bootstrap failed: {detail}"));
}
// Set skill ID
let bridge_code = format!(
r#"globalThis.__skillId = "{}";"#,
skill_id.replace('"', r#"\""#)
);
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(bridge_code.as_bytes()) {
let detail = format_js_exception(&js_ctx, &e);
return Err(format!("Skill init failed: {detail}"));
}
// Set skill ID
let bridge_code = format!(
r#"globalThis.__skillId = "{}";"#,
skill_id.replace('"', r#"\""#)
);
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(bridge_code.as_bytes()) {
let detail = format_js_exception(&js_ctx, &e);
return Err(format!("Skill init failed: {detail}"));
}
// Execute the skill's entry point
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(js_source.as_bytes()) {
let detail = format_js_exception(&js_ctx, &e);
return Err(format!("Skill load failed: {detail}"));
}
// Execute the skill's entry point
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(js_source.as_bytes()) {
let detail = format_js_exception(&js_ctx, &e);
return Err(format!("Skill load failed: {detail}"));
}
// Extract tool definitions
extract_tools(&js_ctx, &state);
// Extract tool definitions
extract_tools(&js_ctx, &state);
Ok(())
}).await;
Ok(())
})
.await;
if let Err(e) = init_result {
let mut s = state.write();
@@ -271,7 +279,17 @@ impl QjsSkillInstance {
drive_jobs(&rt).await;
// Run the event loop
run_event_loop(&rt, &ctx, &mut rx, &state, &config.skill_id, &timer_state, &published_state, _deps.app_handle.as_ref()).await;
run_event_loop(
&rt,
&ctx,
&mut rx,
&state,
&config.skill_id,
&timer_state,
&published_state,
_deps.app_handle.as_ref(),
)
.await;
})
}
}
@@ -331,7 +349,17 @@ async fn run_event_loop(
// messages (events, pings, etc.) but queue any new CallTool.
match rx.try_recv() {
Ok(msg) => {
let should_stop = handle_message(rt, ctx, msg, state, skill_id, &mut pending_tool, app_handle, ops_state).await;
let should_stop = handle_message(
rt,
ctx,
msg,
state,
skill_id,
&mut pending_tool,
app_handle,
ops_state,
)
.await;
if should_stop {
break;
}
@@ -341,7 +369,10 @@ async fn run_event_loop(
}
Err(mpsc::error::TryRecvError::Disconnected) => {
// Channel closed, exit
log::info!("[skill:{}] Message channel disconnected, stopping", skill_id);
log::info!(
"[skill:{}] Message channel disconnected, stopping",
skill_id
);
break;
}
}
@@ -351,12 +382,13 @@ async fn run_event_loop(
// 4. Check if pending async tool call has completed
if pending_tool.is_some() {
let done = ctx.with(|js_ctx| {
js_ctx
.eval::<bool, _>(b"globalThis.__pendingToolDone === true")
.unwrap_or(false)
})
.await;
let done = ctx
.with(|js_ctx| {
js_ctx
.eval::<bool, _>(b"globalThis.__pendingToolDone === true")
.unwrap_or(false)
})
.await;
if done {
// Read the resolved value and send it back
@@ -397,7 +429,11 @@ async fn run_event_loop(
"state": new_map,
});
if let Err(e) = handle.emit_to("main", "skill-state-changed", payload) {
log::warn!("[skill:{}] Failed to emit skill-state-changed to main: {}", skill_id, e);
log::warn!(
"[skill:{}] Failed to emit skill-state-changed to main: {}",
skill_id,
e
);
}
}
}
@@ -435,7 +471,8 @@ async fn fire_timer_callback(ctx: &rquickjs::AsyncContext, timer_id: u32) {
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
log::error!("[timer] Callback for timer {} failed: {}", timer_id, e);
}
}).await;
})
.await;
}
/// Handle a single message from the channel.
@@ -451,7 +488,11 @@ async fn handle_message(
ops_state: &Arc<RwLock<qjs_ops::SkillState>>,
) -> bool {
match msg {
SkillMessage::CallTool { tool_name, arguments, reply } => {
SkillMessage::CallTool {
tool_name,
arguments,
reply,
} => {
// Lazy-load persisted OAuth credential before calling the tool
restore_oauth_credential(ctx, skill_id).await;
@@ -494,7 +535,8 @@ async fn handle_message(
})()"#;
ctx.with(|js_ctx| {
let _ = js_ctx.eval::<rquickjs::Value, _>(clear_code.as_bytes());
}).await;
})
.await;
state.write().status = SkillStatus::Stopped;
log::info!("[skill:{}] Stopped (OAuth credential cleared)", skill_id);
let _ = reply.send(());
@@ -505,7 +547,11 @@ async fn handle_message(
let result = handle_js_call(rt, ctx, "onSetupStart", "{}").await;
let _ = reply.send(result);
}
SkillMessage::SetupSubmit { step_id, values, reply } => {
SkillMessage::SetupSubmit {
step_id,
values,
reply,
} => {
let args = serde_json::json!({ "stepId": step_id, "values": values });
let result = handle_js_call(rt, ctx, "onSetupSubmit", &args.to_string()).await;
let _ = reply.send(result);
@@ -540,10 +586,19 @@ async fn handle_message(
SkillMessage::LoadParams { params } => {
let params_str = serde_json::to_string(&params).unwrap_or_else(|_| "{}".to_string());
if let Err(e) = handle_js_void_call(rt, ctx, "onLoad", &params_str).await {
log::warn!("[skill:{}] onLoad failed (skill may not export it): {}", skill_id, e);
log::warn!(
"[skill:{}] onLoad failed (skill may not export it): {}",
skill_id,
e
);
}
}
SkillMessage::Error { error_type, message, source, recoverable } => {
SkillMessage::Error {
error_type,
message,
source,
recoverable,
} => {
let args = serde_json::json!({
"type": error_type,
"message": message,
@@ -554,19 +609,24 @@ async fn handle_message(
log::warn!("[skill:{}] onError() handler failed: {e}", skill_id);
}
}
SkillMessage::Rpc { method, params, reply } => {
// Resolve the optional memory client once for this RPC call.
// State is registered as MemoryState(Mutex<Option<MemoryClientRef>>), not
// Option<MemoryClientRef> directly, so we must use the newtype wrapper.
SkillMessage::Rpc {
method,
params,
reply,
} => {
// Resolve the optional memory client once for this RPC call.
// State is registered as MemoryState(Mutex<Option<MemoryClientRef>>), not
// Option<MemoryClientRef> directly, so we must use the newtype wrapper.
let memory_client_opt = app_handle.and_then(|ah| {
ah.try_state::<crate::commands::memory::MemoryState>()
.and_then(|s| s.0.lock().ok().and_then(|g| g.clone()))
.and_then(|s| s.0.lock().ok().and_then(|g| g.clone()))
});
let result = match method.as_str() {
"oauth/complete" => {
// Set credential on the oauth bridge + persist to store
let cred_json = serde_json::to_string(&params).unwrap_or_else(|_| "null".to_string());
let cred_json =
serde_json::to_string(&params).unwrap_or_else(|_| "null".to_string());
let code = format!(
r#"(function() {{
if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) {{
@@ -580,14 +640,17 @@ async fn handle_message(
);
ctx.with(|js_ctx| {
let _ = js_ctx.eval::<rquickjs::Value, _>(code.as_bytes());
}).await;
log::info!("[skill:{}] OAuth credential set and persisted to store", skill_id);
let params_str = serde_json::to_string(&params).unwrap_or_else(|_| "{}".to_string());
})
.await;
log::info!(
"[skill:{}] OAuth credential set and persisted to store",
skill_id
);
let params_str =
serde_json::to_string(&params).unwrap_or_else(|_| "{}".to_string());
handle_js_call(rt, ctx, "onOAuthComplete", &params_str).await
}
"skill/ping" => {
handle_js_call(rt, ctx, "onPing", "{}").await
}
"skill/ping" => handle_js_call(rt, ctx, "onPing", "{}").await,
"skill/sync" => {
let result = handle_js_call(rt, ctx, "onSync", "{}").await;
// Fire-and-forget: persist published ops state to TinyHumans memory.
@@ -601,24 +664,16 @@ async fn handle_message(
if !state_snapshot.is_empty() {
if let Some(client) = memory_client_opt.clone() {
let skill = skill_id.to_string();
let content = serde_json::to_string_pretty(
&serde_json::Value::Object(state_snapshot),
)
let content = serde_json::to_string_pretty(&serde_json::Value::Object(
state_snapshot,
))
.unwrap_or_else(|_| "{}".to_string());
let title = format!("{} periodic sync", skill);
tokio::spawn(async move {
if let Err(e) = client
.store_skill_sync(
&skill,
"default",
&title,
&content,
None,
None,
None,
None,
None,
None,
&skill, "default", &title, &content, None, None, None,
None, None, None,
)
.await
{
@@ -642,7 +697,8 @@ async fn handle_message(
})()"#;
ctx.with(|js_ctx| {
let _ = js_ctx.eval::<rquickjs::Value, _>(clear_code.as_bytes());
}).await;
})
.await;
log::info!("[skill:{}] OAuth credential cleared from store", skill_id);
// Fire-and-forget: delete memory for this integration
@@ -654,16 +710,23 @@ async fn handle_message(
.unwrap_or("unknown")
.to_string();
tokio::spawn(async move {
if let Err(e) = client.clear_skill_memory(&skill, &integration_id).await {
if let Err(e) = client.clear_skill_memory(&skill, &integration_id).await
{
log::warn!("[memory] clear_skill_memory failed: {e}");
} else {
log::info!("[memory] Cleared memory for {}:{}", skill, integration_id);
log::info!(
"[memory] Cleared memory for {}:{}",
skill,
integration_id
);
}
});
}
let params_str = serde_json::to_string(&params).unwrap_or_else(|_| "{}".to_string());
handle_js_void_call(rt, ctx, "onOAuthRevoked", &params_str).await
let params_str =
serde_json::to_string(&params).unwrap_or_else(|_| "{}".to_string());
handle_js_void_call(rt, ctx, "onOAuthRevoked", &params_str)
.await
.map(|()| serde_json::json!({ "ok": true }))
}
_ => {
@@ -693,12 +756,8 @@ fn format_js_exception(js_ctx: &rquickjs::Ctx<'_>, err: &rquickjs::Error) -> Str
// Try to get .message and .stack from the exception object
if let Some(obj) = exception.as_object() {
let message: String = obj
.get::<_, String>("message")
.unwrap_or_default();
let stack: String = obj
.get::<_, String>("stack")
.unwrap_or_default();
let message: String = obj.get::<_, String>("message").unwrap_or_default();
let stack: String = obj.get::<_, String>("stack").unwrap_or_default();
if !message.is_empty() {
if !stack.is_empty() {
@@ -764,11 +823,13 @@ async fn call_lifecycle(
loop {
drive_jobs(rt).await;
let done = ctx.with(|js_ctx| {
js_ctx
.eval::<bool, _>(b"globalThis.__pendingLifecycleDone === true")
.unwrap_or(false)
}).await;
let done = ctx
.with(|js_ctx| {
js_ctx
.eval::<bool, _>(b"globalThis.__pendingLifecycleDone === true")
.unwrap_or(false)
})
.await;
if done {
let error = ctx.with(|js_ctx| {
@@ -830,16 +891,14 @@ fn extract_tools(js_ctx: &rquickjs::Ctx<'_>, state: &Arc<RwLock<SkillState>>) {
// eval with String type hint tells rquickjs to convert the result to a Rust String
match js_ctx.eval::<String, _>(code) {
Ok(json_str) => {
match serde_json::from_str::<Vec<ToolDefinition>>(&json_str) {
Ok(tools) => {
state.write().tools = tools;
}
Err(e) => {
log::warn!("[tools] Failed to parse tools JSON: {e}");
}
Ok(json_str) => match serde_json::from_str::<Vec<ToolDefinition>>(&json_str) {
Ok(tools) => {
state.write().tools = tools;
}
}
Err(e) => {
log::warn!("[tools] Failed to parse tools JSON: {e}");
}
},
Err(e) => {
log::warn!("[tools] Failed to extract tools: {e}");
}
@@ -859,8 +918,8 @@ async fn start_async_tool_call(
tool_name: &str,
arguments: serde_json::Value,
) -> Result<Option<ToolResult>, String> {
let args_str = serde_json::to_string(&arguments)
.map_err(|e| format!("Failed to serialize args: {e}"))?;
let args_str =
serde_json::to_string(&arguments).map_err(|e| format!("Failed to serialize args: {e}"))?;
let tool_name = tool_name.to_string();
let eval_result = ctx
@@ -928,9 +987,7 @@ async fn start_async_tool_call(
/// Read the result of a completed async tool call from JS globals.
/// Call this only after `globalThis.__pendingToolDone === true`.
async fn read_pending_tool_result(
ctx: &rquickjs::AsyncContext,
) -> Result<ToolResult, String> {
async fn read_pending_tool_result(ctx: &rquickjs::AsyncContext) -> Result<ToolResult, String> {
let result_text = ctx
.with(|js_ctx| {
let code = r#"(function() {
@@ -1018,11 +1075,13 @@ async fn handle_server_event(
loop {
drive_jobs(rt).await;
let done = ctx.with(|js_ctx| {
js_ctx
.eval::<bool, _>(b"globalThis.__pendingEventDone === true")
.unwrap_or(false)
}).await;
let done = ctx
.with(|js_ctx| {
js_ctx
.eval::<bool, _>(b"globalThis.__pendingEventDone === true")
.unwrap_or(false)
})
.await;
if done {
let error = ctx.with(|js_ctx| {
@@ -1111,11 +1170,13 @@ async fn handle_cron_trigger(
loop {
drive_jobs(rt).await;
let done = ctx.with(|js_ctx| {
js_ctx
.eval::<bool, _>(b"globalThis.__pendingCronDone === true")
.unwrap_or(false)
}).await;
let done = ctx
.with(|js_ctx| {
js_ctx
.eval::<bool, _>(b"globalThis.__pendingCronDone === true")
.unwrap_or(false)
})
.await;
if done {
let error = ctx.with(|js_ctx| {
@@ -1215,11 +1276,13 @@ async fn handle_js_call(
loop {
drive_jobs(rt).await;
let done = ctx.with(|js_ctx| {
js_ctx
.eval::<bool, _>(b"globalThis.__pendingRpcDone === true")
.unwrap_or(false)
}).await;
let done = ctx
.with(|js_ctx| {
js_ctx
.eval::<bool, _>(b"globalThis.__pendingRpcDone === true")
.unwrap_or(false)
})
.await;
if done {
let result = ctx.with(|js_ctx| {
@@ -1324,11 +1387,13 @@ async fn handle_js_void_call(
loop {
drive_jobs(rt).await;
let done = ctx.with(|js_ctx| {
js_ctx
.eval::<bool, _>(b"globalThis.__pendingRpcVoidDone === true")
.unwrap_or(false)
}).await;
let done = ctx
.with(|js_ctx| {
js_ctx
.eval::<bool, _>(b"globalThis.__pendingRpcVoidDone === true")
.unwrap_or(false)
})
.await;
if done {
let error = ctx.with(|js_ctx| {
@@ -1384,9 +1449,9 @@ async fn restore_oauth_credential(ctx: &rquickjs::AsyncContext, skill_id: &str)
return false;
})()"#;
let restored = ctx.with(|js_ctx| {
js_ctx.eval::<bool, _>(code.as_bytes()).unwrap_or(false)
}).await;
let restored = ctx
.with(|js_ctx| js_ctx.eval::<bool, _>(code.as_bytes()).unwrap_or(false))
.await;
if restored {
log::info!("[skill:{}] Restored OAuth credential from store", skill_id);
+4 -1
View File
@@ -254,7 +254,10 @@ impl SkillRegistry {
match sender.try_send(msg) {
Ok(()) => {
log::info!("[runtime] Successfully sent message to skill '{}'", skill_id);
log::info!(
"[runtime] Successfully sent message to skill '{}'",
skill_id
);
Ok(())
}
Err(e) => {
+18 -30
View File
@@ -61,9 +61,8 @@ struct SharedState {
// ---------------------------------------------------------------------------
#[cfg(not(target_os = "android"))]
type WsStream = tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>;
type WsStream =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
// ---------------------------------------------------------------------------
// Connection outcome (desktop + iOS)
@@ -191,8 +190,7 @@ impl SocketManager {
let payload =
serde_json::to_string(&json!([event, data])).map_err(|e| format!("{e}"))?;
let msg = format!("42{}", payload);
tx.send(msg)
.map_err(|_| "Socket not connected".to_string())
tx.send(msg).map_err(|_| "Socket not connected".to_string())
} else {
Err("Not connected".to_string())
}
@@ -377,13 +375,12 @@ async fn run_connection(
let (mut ws_write, mut ws_read) = ws_stream.split();
// 4. Read Engine.IO OPEN packet
let open_data = match tokio::time::timeout(Duration::from_secs(10), read_eio_open(&mut ws_read))
.await
{
Ok(Ok(data)) => data,
Ok(Err(e)) => return ConnectionOutcome::Failed(format!("EIO OPEN: {e}")),
Err(_) => return ConnectionOutcome::Failed("Timeout waiting for EIO OPEN".into()),
};
let open_data =
match tokio::time::timeout(Duration::from_secs(10), read_eio_open(&mut ws_read)).await {
Ok(Ok(data)) => data,
Ok(Err(e)) => return ConnectionOutcome::Failed(format!("EIO OPEN: {e}")),
Err(_) => return ConnectionOutcome::Failed("Timeout waiting for EIO OPEN".into()),
};
let ping_interval = open_data
.get("pingInterval")
@@ -393,10 +390,7 @@ async fn run_connection(
.get("pingTimeout")
.and_then(|v| v.as_u64())
.unwrap_or(20000);
let eio_sid = open_data
.get("sid")
.and_then(|v| v.as_str())
.unwrap_or("?");
let eio_sid = open_data.get("sid").and_then(|v| v.as_str()).unwrap_or("?");
log::info!(
"[socket-mgr] EIO OPEN: sid={}, ping={}ms, timeout={}ms",
eio_sid,
@@ -407,10 +401,7 @@ async fn run_connection(
// 5. Send Socket.IO CONNECT with auth
let connect_payload = json!({"token": token});
let connect_msg = format!("40{}", serde_json::to_string(&connect_payload).unwrap());
if let Err(e) = ws_write
.send(WsMessage::Text(connect_msg.into()))
.await
{
if let Err(e) = ws_write.send(WsMessage::Text(connect_msg.into())).await {
return ConnectionOutcome::Failed(format!("Send SIO CONNECT: {e}"));
}
@@ -661,7 +652,11 @@ fn handle_sio_packet(
}
b'4' => {
// Socket.IO CONNECT_ERROR
let error_str = if text.len() > 1 { &text[1..] } else { "unknown" };
let error_str = if text.len() > 1 {
&text[1..]
} else {
"unknown"
};
log::error!("[socket-mgr] SIO CONNECT_ERROR: {}", error_str);
}
_ => {
@@ -770,10 +765,7 @@ async fn handle_mcp_list_tools(
Vec::new()
};
log::info!(
"[socket-mgr] mcp:listToolsResponse — {} tools",
tools.len()
);
log::info!("[socket-mgr] mcp:listToolsResponse — {} tools", tools.len());
emit_via_channel(
emit_tx,
@@ -884,11 +876,7 @@ fn build_ws_url(url: &str) -> String {
/// Send a Socket.IO event through the emit channel.
/// Formats: `42["eventName", data]`
#[cfg(not(target_os = "android"))]
fn emit_via_channel(
tx: &mpsc::UnboundedSender<String>,
event: &str,
data: serde_json::Value,
) {
fn emit_via_channel(tx: &mpsc::UnboundedSender<String>, event: &str, data: serde_json::Value) {
let payload = serde_json::to_string(&json!([event, data])).unwrap_or_default();
let msg = format!("42{}", payload);
if let Err(e) = tx.send(msg) {
+1 -3
View File
@@ -102,9 +102,7 @@ pub enum SkillMessage {
},
/// Load params from frontend (e.g. wallet address for wallet skill).
/// Delivered after skill/load RPC; skill may export onLoad(params) to receive them.
LoadParams {
params: serde_json::Value,
},
LoadParams { params: serde_json::Value },
}
/// Result of executing a tool.
+2 -2
View File
@@ -66,7 +66,7 @@ where
let result = match runtime_result {
Ok(value) => Ok(value),
Err(e) => Err(format!("Runtime creation failed: {}", e))
Err(e) => Err(format!("Runtime creation failed: {}", e)),
};
let _ = tx.send(result);
@@ -137,4 +137,4 @@ mod tests {
// Inside async context
assert!(is_in_runtime());
}
}
}
-1
View File
@@ -6,4 +6,3 @@ pub mod quickjs_libs;
#[cfg(desktop)]
pub mod notification_service;
@@ -7,84 +7,127 @@ use std::time::{Duration, Instant};
use super::types::{TimerEntry, TimerState, ALLOWED_ENV_VARS};
pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, timer_state: Arc<RwLock<TimerState>>) -> rquickjs::Result<()> {
pub fn register<'js>(
ctx: &Ctx<'js>,
ops: &Object<'js>,
timer_state: Arc<RwLock<TimerState>>,
) -> rquickjs::Result<()> {
// ========================================================================
// Console (3)
// ========================================================================
ops.set("console_log", Function::new(ctx.clone(), |msg: String| {
log::info!("[js] {}", msg);
}))?;
ops.set(
"console_log",
Function::new(ctx.clone(), |msg: String| {
log::info!("[js] {}", msg);
}),
)?;
ops.set("console_warn", Function::new(ctx.clone(), |msg: String| {
log::warn!("[js] {}", msg);
}))?;
ops.set(
"console_warn",
Function::new(ctx.clone(), |msg: String| {
log::warn!("[js] {}", msg);
}),
)?;
ops.set("console_error", Function::new(ctx.clone(), |msg: String| {
log::error!("[js] {}", msg);
}))?;
ops.set(
"console_error",
Function::new(ctx.clone(), |msg: String| {
log::error!("[js] {}", msg);
}),
)?;
// ========================================================================
// Crypto (3)
// ========================================================================
ops.set("crypto_random", Function::new(ctx.clone(), |len: usize| -> Vec<u8> {
use rand::RngCore;
let mut buf = vec![0u8; len];
rand::thread_rng().fill_bytes(&mut buf);
buf
}))?;
ops.set(
"crypto_random",
Function::new(ctx.clone(), |len: usize| -> Vec<u8> {
use rand::RngCore;
let mut buf = vec![0u8; len];
rand::thread_rng().fill_bytes(&mut buf);
buf
}),
)?;
ops.set("atob", Function::new(ctx.clone(), |input: String| -> rquickjs::Result<String> {
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(&input)
.map_err(|e| super::types::js_err(e.to_string()))?;
String::from_utf8(bytes).map_err(|e| super::types::js_err(e.to_string()))
}))?;
ops.set(
"atob",
Function::new(ctx.clone(), |input: String| -> rquickjs::Result<String> {
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(&input)
.map_err(|e| super::types::js_err(e.to_string()))?;
String::from_utf8(bytes).map_err(|e| super::types::js_err(e.to_string()))
}),
)?;
ops.set("btoa", Function::new(ctx.clone(), |input: String| -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(input.as_bytes())
}))?;
ops.set(
"btoa",
Function::new(ctx.clone(), |input: String| -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(input.as_bytes())
}),
)?;
// ========================================================================
// Performance (1)
// ========================================================================
ops.set("performance_now", Function::new(ctx.clone(), || -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs_f64()
* 1000.0
}))?;
ops.set(
"performance_now",
Function::new(ctx.clone(), || -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs_f64()
* 1000.0
}),
)?;
// ========================================================================
// Platform (2)
// ========================================================================
ops.set("platform_os", Function::new(ctx.clone(), || -> &'static str {
if cfg!(target_os = "windows") { "windows" }
else if cfg!(target_os = "macos") { "macos" }
else if cfg!(target_os = "linux") { "linux" }
else if cfg!(target_os = "android") { "android" }
else if cfg!(target_os = "ios") { "ios" }
else { "unknown" }
}))?;
ops.set(
"platform_os",
Function::new(ctx.clone(), || -> &'static str {
if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "android") {
"android"
} else if cfg!(target_os = "ios") {
"ios"
} else {
"unknown"
}
}),
)?;
ops.set("platform_env", Function::new(ctx.clone(), |key: String| -> Option<String> {
if ALLOWED_ENV_VARS.contains(&key.as_str()) {
std::env::var(&key).ok()
} else {
None
}
}))?;
ops.set(
"platform_env",
Function::new(ctx.clone(), |key: String| -> Option<String> {
if ALLOWED_ENV_VARS.contains(&key.as_str()) {
std::env::var(&key).ok()
} else {
None
}
}),
)?;
ops.set("get_session_token", Function::new(ctx.clone(), || -> String {
let token = crate::commands::auth::SESSION_SERVICE.get_token().unwrap_or_default();
return token;
}))?;
ops.set(
"get_session_token",
Function::new(ctx.clone(), || -> String {
let token = crate::commands::auth::SESSION_SERVICE
.get_token()
.unwrap_or_default();
return token;
}),
)?;
// ========================================================================
// Timers (2)
@@ -92,24 +135,34 @@ pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, timer_state: Arc<RwLock<
{
let ts = timer_state.clone();
ops.set("timer_start", Function::new(ctx.clone(),
move |id: u32, delay_ms: u32, is_interval: bool| {
let mut state = ts.write();
state.timers.insert(id, TimerEntry {
deadline: Instant::now() + Duration::from_millis(delay_ms as u64),
delay_ms,
is_interval,
});
},
))?;
ops.set(
"timer_start",
Function::new(
ctx.clone(),
move |id: u32, delay_ms: u32, is_interval: bool| {
let mut state = ts.write();
state.timers.insert(
id,
TimerEntry {
deadline: Instant::now() + Duration::from_millis(delay_ms as u64),
delay_ms,
is_interval,
},
);
},
),
)?;
}
{
let ts = timer_state;
ops.set("timer_cancel", Function::new(ctx.clone(), move |id: u32| {
let mut state = ts.write();
state.timers.remove(&id);
}))?;
ops.set(
"timer_cancel",
Function::new(ctx.clone(), move |id: u32| {
let mut state = ts.write();
state.timers.remove(&id);
}),
)?;
}
Ok(())
@@ -34,43 +34,59 @@ pub fn register<'js>(
{
let ss = skill_state.clone();
ops.set("state_get", Function::new(ctx.clone(),
move |key: String| -> rquickjs::Result<String> {
let state = ss.read();
let value = state.data.get(&key).cloned().unwrap_or(serde_json::Value::Null);
serde_json::to_string(&value).map_err(|e| js_err(e.to_string()))
},
))?;
ops.set(
"state_get",
Function::new(
ctx.clone(),
move |key: String| -> rquickjs::Result<String> {
let state = ss.read();
let value = state
.data
.get(&key)
.cloned()
.unwrap_or(serde_json::Value::Null);
serde_json::to_string(&value).map_err(|e| js_err(e.to_string()))
},
),
)?;
}
{
let ss = skill_state.clone();
ops.set("state_set", Function::new(ctx.clone(),
move |key: String, value_json: String| -> rquickjs::Result<()> {
let value: serde_json::Value =
serde_json::from_str(&value_json).map_err(|e| js_err(e.to_string()))?;
let mut state = ss.write();
state.data.insert(key, value);
state.dirty = true;
Ok(())
},
))?;
ops.set(
"state_set",
Function::new(
ctx.clone(),
move |key: String, value_json: String| -> rquickjs::Result<()> {
let value: serde_json::Value =
serde_json::from_str(&value_json).map_err(|e| js_err(e.to_string()))?;
let mut state = ss.write();
state.data.insert(key, value);
state.dirty = true;
Ok(())
},
),
)?;
}
{
let ss = skill_state;
ops.set("state_set_partial", Function::new(ctx.clone(),
move |partial_json: String| -> rquickjs::Result<()> {
let partial: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(&partial_json).map_err(|e| js_err(e.to_string()))?;
let mut state = ss.write();
for (k, v) in partial {
state.data.insert(k, v);
}
state.dirty = true;
Ok(())
},
))?;
ops.set(
"state_set_partial",
Function::new(
ctx.clone(),
move |partial_json: String| -> rquickjs::Result<()> {
let partial: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(&partial_json).map_err(|e| js_err(e.to_string()))?;
let mut state = ss.write();
for (k, v) in partial {
state.data.insert(k, v);
}
state.dirty = true;
Ok(())
},
),
)?;
}
// ========================================================================
@@ -79,22 +95,30 @@ pub fn register<'js>(
{
let sc = skill_context.clone();
ops.set("data_read", Function::new(ctx.clone(),
move |filename: String| -> rquickjs::Result<String> {
let path = sc.data_dir.join(&filename);
std::fs::read_to_string(&path).map_err(|e| js_err(e.to_string()))
},
))?;
ops.set(
"data_read",
Function::new(
ctx.clone(),
move |filename: String| -> rquickjs::Result<String> {
let path = sc.data_dir.join(&filename);
std::fs::read_to_string(&path).map_err(|e| js_err(e.to_string()))
},
),
)?;
}
{
let sc = skill_context.clone();
ops.set("data_write", Function::new(ctx.clone(),
move |filename: String, content: String| -> rquickjs::Result<()> {
let path = sc.data_dir.join(&filename);
std::fs::write(&path, content).map_err(|e| js_err(e.to_string()))
},
))?;
ops.set(
"data_write",
Function::new(
ctx.clone(),
move |filename: String, content: String| -> rquickjs::Result<()> {
let path = sc.data_dir.join(&filename);
std::fs::write(&path, content).map_err(|e| js_err(e.to_string()))
},
),
)?;
}
// ========================================================================
@@ -103,80 +127,93 @@ pub fn register<'js>(
{
let sc = skill_context;
ops.set("memory_insert", Function::new(ctx.clone(),
move |metadata_json: String| -> rquickjs::Result<()> {
let input: JsMemoryInsertInput =
serde_json::from_str(&metadata_json).map_err(|e| js_err(e.to_string()))?;
if input.title.trim().is_empty() {
return Err(js_err("memory.insert requires a non-empty title"));
}
if input.content.trim().is_empty() {
return Err(js_err("memory.insert requires non-empty content"));
}
let app_handle = sc
.app_handle
.clone()
.ok_or_else(|| js_err("App handle not available for memory insert"))?;
let memory_state = app_handle
.try_state::<crate::commands::memory::MemoryState>()
.ok_or_else(|| js_err("Memory state not available"))?;
let client_opt = memory_state
.0
.lock()
.map_err(|_| js_err("Failed to lock memory state"))?
.clone();
let client = client_opt.ok_or_else(|| js_err("Memory client is not initialized"))?;
let skill_id = sc.skill_id.clone();
let integration_id = sc.skill_id.clone();
let source_type = match input.source_type.as_deref() {
Some("doc") => Some(SourceType::Doc),
Some("chat") => Some(SourceType::Chat),
Some("email") => Some(SourceType::Email),
Some(_) => return Err(js_err("sourceType must be one of: doc, chat, email")),
None => None,
};
let priority = match input.priority.as_deref() {
Some("high") => Some(Priority::High),
Some("medium") => Some(Priority::Medium),
Some("low") => Some(Priority::Low),
Some(_) => return Err(js_err("priority must be one of: high, medium, low")),
None => None,
};
let metadata = input.metadata.unwrap_or_else(|| serde_json::json!({}));
tokio::spawn(async move {
if let Err(e) = client
.store_skill_sync(
&skill_id,
&integration_id,
&input.title,
&input.content,
source_type,
Some(metadata),
priority,
input.created_at,
input.updated_at,
input.document_id,
)
.await
{
log::warn!("[quickjs] memory_insert failed for '{}': {}", integration_id, e);
} else {
log::info!(
"[quickjs] memory_insert stored '{}': title='{}'",
integration_id,
input.title
);
ops.set(
"memory_insert",
Function::new(
ctx.clone(),
move |metadata_json: String| -> rquickjs::Result<()> {
let input: JsMemoryInsertInput =
serde_json::from_str(&metadata_json).map_err(|e| js_err(e.to_string()))?;
if input.title.trim().is_empty() {
return Err(js_err("memory.insert requires a non-empty title"));
}
if input.content.trim().is_empty() {
return Err(js_err("memory.insert requires non-empty content"));
}
});
Ok(())
},
))?;
let app_handle = sc
.app_handle
.clone()
.ok_or_else(|| js_err("App handle not available for memory insert"))?;
let memory_state = app_handle
.try_state::<crate::commands::memory::MemoryState>()
.ok_or_else(|| js_err("Memory state not available"))?;
let client_opt = memory_state
.0
.lock()
.map_err(|_| js_err("Failed to lock memory state"))?
.clone();
let client =
client_opt.ok_or_else(|| js_err("Memory client is not initialized"))?;
let skill_id = sc.skill_id.clone();
let integration_id = sc.skill_id.clone();
let source_type = match input.source_type.as_deref() {
Some("doc") => Some(SourceType::Doc),
Some("chat") => Some(SourceType::Chat),
Some("email") => Some(SourceType::Email),
Some(_) => {
return Err(js_err("sourceType must be one of: doc, chat, email"))
}
None => None,
};
let priority = match input.priority.as_deref() {
Some("high") => Some(Priority::High),
Some("medium") => Some(Priority::Medium),
Some("low") => Some(Priority::Low),
Some(_) => {
return Err(js_err("priority must be one of: high, medium, low"))
}
None => None,
};
let metadata = input.metadata.unwrap_or_else(|| serde_json::json!({}));
tokio::spawn(async move {
if let Err(e) = client
.store_skill_sync(
&skill_id,
&integration_id,
&input.title,
&input.content,
source_type,
Some(metadata),
priority,
input.created_at,
input.updated_at,
input.document_id,
)
.await
{
log::warn!(
"[quickjs] memory_insert failed for '{}': {}",
integration_id,
e
);
} else {
log::info!(
"[quickjs] memory_insert stored '{}': title='{}'",
integration_id,
input.title
);
}
});
Ok(())
},
),
)?;
}
Ok(())
@@ -5,132 +5,206 @@ use rquickjs::{Ctx, Function, Object};
use super::types::{js_err, SkillContext};
use crate::services::quickjs_libs::storage::IdbStorage;
pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, storage: IdbStorage, skill_context: SkillContext) -> rquickjs::Result<()> {
pub fn register<'js>(
ctx: &Ctx<'js>,
ops: &Object<'js>,
storage: IdbStorage,
skill_context: SkillContext,
) -> rquickjs::Result<()> {
// ========================================================================
// IndexedDB (11) - all sync
// ========================================================================
{
let s = storage.clone();
ops.set("idb_open", Function::new(ctx.clone(),
move |name: String, version: u32| -> rquickjs::Result<String> {
let result = s.open_database(&name, version).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
ops.set(
"idb_open",
Function::new(
ctx.clone(),
move |name: String, version: u32| -> rquickjs::Result<String> {
let result = s.open_database(&name, version).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
),
)?;
}
{
let s = storage.clone();
ops.set("idb_close", Function::new(ctx.clone(), move |name: String| {
s.close_database(&name);
}))?;
ops.set(
"idb_close",
Function::new(ctx.clone(), move |name: String| {
s.close_database(&name);
}),
)?;
}
{
let s = storage.clone();
ops.set("idb_delete_database", Function::new(ctx.clone(),
move |name: String| -> rquickjs::Result<()> {
ops.set(
"idb_delete_database",
Function::new(ctx.clone(), move |name: String| -> rquickjs::Result<()> {
s.delete_database(&name).map_err(|e| js_err(e))
},
))?;
}),
)?;
}
{
let s = storage.clone();
ops.set("idb_create_object_store", Function::new(ctx.clone(),
move |db_name: String, store_name: String, options: String| -> rquickjs::Result<()> {
let opts: serde_json::Value =
serde_json::from_str(&options).map_err(|e| js_err(e.to_string()))?;
let key_path = opts["keyPath"].as_str();
let auto_increment = opts["autoIncrement"].as_bool().unwrap_or(false);
s.create_object_store(&db_name, &store_name, key_path, auto_increment)
.map_err(|e| js_err(e))
},
))?;
ops.set(
"idb_create_object_store",
Function::new(
ctx.clone(),
move |db_name: String,
store_name: String,
options: String|
-> rquickjs::Result<()> {
let opts: serde_json::Value =
serde_json::from_str(&options).map_err(|e| js_err(e.to_string()))?;
let key_path = opts["keyPath"].as_str();
let auto_increment = opts["autoIncrement"].as_bool().unwrap_or(false);
s.create_object_store(&db_name, &store_name, key_path, auto_increment)
.map_err(|e| js_err(e))
},
),
)?;
}
{
let s = storage.clone();
ops.set("idb_delete_object_store", Function::new(ctx.clone(),
move |db_name: String, store_name: String| -> rquickjs::Result<()> {
s.delete_object_store(&db_name, &store_name).map_err(|e| js_err(e))
},
))?;
ops.set(
"idb_delete_object_store",
Function::new(
ctx.clone(),
move |db_name: String, store_name: String| -> rquickjs::Result<()> {
s.delete_object_store(&db_name, &store_name)
.map_err(|e| js_err(e))
},
),
)?;
}
{
let s = storage.clone();
ops.set("idb_get", Function::new(ctx.clone(),
move |db_name: String, store_name: String, key: String| -> rquickjs::Result<String> {
let key_val: serde_json::Value =
serde_json::from_str(&key).map_err(|e| js_err(e.to_string()))?;
let result = s.get(&db_name, &store_name, &key_val).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
ops.set(
"idb_get",
Function::new(
ctx.clone(),
move |db_name: String,
store_name: String,
key: String|
-> rquickjs::Result<String> {
let key_val: serde_json::Value =
serde_json::from_str(&key).map_err(|e| js_err(e.to_string()))?;
let result = s
.get(&db_name, &store_name, &key_val)
.map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
),
)?;
}
{
let s = storage.clone();
ops.set("idb_put", Function::new(ctx.clone(),
move |db_name: String, store_name: String, key: String, value: String| -> rquickjs::Result<()> {
let key_val: serde_json::Value =
serde_json::from_str(&key).map_err(|e| js_err(e.to_string()))?;
let value_val: serde_json::Value =
serde_json::from_str(&value).map_err(|e| js_err(e.to_string()))?;
s.put(&db_name, &store_name, &key_val, &value_val).map_err(|e| js_err(e))
},
))?;
ops.set(
"idb_put",
Function::new(
ctx.clone(),
move |db_name: String,
store_name: String,
key: String,
value: String|
-> rquickjs::Result<()> {
let key_val: serde_json::Value =
serde_json::from_str(&key).map_err(|e| js_err(e.to_string()))?;
let value_val: serde_json::Value =
serde_json::from_str(&value).map_err(|e| js_err(e.to_string()))?;
s.put(&db_name, &store_name, &key_val, &value_val)
.map_err(|e| js_err(e))
},
),
)?;
}
{
let s = storage.clone();
ops.set("idb_delete", Function::new(ctx.clone(),
move |db_name: String, store_name: String, key: String| -> rquickjs::Result<()> {
let key_val: serde_json::Value =
serde_json::from_str(&key).map_err(|e| js_err(e.to_string()))?;
s.delete(&db_name, &store_name, &key_val).map_err(|e| js_err(e))
},
))?;
ops.set(
"idb_delete",
Function::new(
ctx.clone(),
move |db_name: String, store_name: String, key: String| -> rquickjs::Result<()> {
let key_val: serde_json::Value =
serde_json::from_str(&key).map_err(|e| js_err(e.to_string()))?;
s.delete(&db_name, &store_name, &key_val)
.map_err(|e| js_err(e))
},
),
)?;
}
{
let s = storage.clone();
ops.set("idb_clear", Function::new(ctx.clone(),
move |db_name: String, store_name: String| -> rquickjs::Result<()> {
s.clear(&db_name, &store_name).map_err(|e| js_err(e))
},
))?;
ops.set(
"idb_clear",
Function::new(
ctx.clone(),
move |db_name: String, store_name: String| -> rquickjs::Result<()> {
s.clear(&db_name, &store_name).map_err(|e| js_err(e))
},
),
)?;
}
{
let s = storage.clone();
ops.set("idb_get_all", Function::new(ctx.clone(),
move |db_name: String, store_name: String, count: Option<u32>| -> rquickjs::Result<String> {
let result = s.get_all(&db_name, &store_name, count).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
ops.set(
"idb_get_all",
Function::new(
ctx.clone(),
move |db_name: String,
store_name: String,
count: Option<u32>|
-> rquickjs::Result<String> {
let result = s
.get_all(&db_name, &store_name, count)
.map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
),
)?;
}
{
let s = storage.clone();
ops.set("idb_get_all_keys", Function::new(ctx.clone(),
move |db_name: String, store_name: String, count: Option<u32>| -> rquickjs::Result<String> {
let result = s.get_all_keys(&db_name, &store_name, count).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
ops.set(
"idb_get_all_keys",
Function::new(
ctx.clone(),
move |db_name: String,
store_name: String,
count: Option<u32>|
-> rquickjs::Result<String> {
let result = s
.get_all_keys(&db_name, &store_name, count)
.map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
),
)?;
}
{
let s = storage.clone();
ops.set("idb_count", Function::new(ctx.clone(),
move |db_name: String, store_name: String| -> rquickjs::Result<u32> {
s.count(&db_name, &store_name).map_err(|e| js_err(e))
},
))?;
ops.set(
"idb_count",
Function::new(
ctx.clone(),
move |db_name: String, store_name: String| -> rquickjs::Result<u32> {
s.count(&db_name, &store_name).map_err(|e| js_err(e))
},
),
)?;
}
// ========================================================================
@@ -140,72 +214,99 @@ pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, storage: IdbStorage, ski
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("db_exec", Function::new(ctx.clone(),
move |sql: String, params_json: Option<String>| -> rquickjs::Result<i64> {
let params: Vec<serde_json::Value> = if let Some(p) = params_json {
serde_json::from_str(&p).map_err(|e| js_err(e.to_string()))?
} else {
Vec::new()
};
let rows = s.skill_db_exec(&sc.skill_id, &sql, &params).map_err(|e| js_err(e))?;
Ok(rows as i64)
},
))?;
ops.set(
"db_exec",
Function::new(
ctx.clone(),
move |sql: String, params_json: Option<String>| -> rquickjs::Result<i64> {
let params: Vec<serde_json::Value> = if let Some(p) = params_json {
serde_json::from_str(&p).map_err(|e| js_err(e.to_string()))?
} else {
Vec::new()
};
let rows = s
.skill_db_exec(&sc.skill_id, &sql, &params)
.map_err(|e| js_err(e))?;
Ok(rows as i64)
},
),
)?;
}
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("db_get", Function::new(ctx.clone(),
move |sql: String, params_json: Option<String>| -> rquickjs::Result<String> {
let params: Vec<serde_json::Value> = if let Some(p) = params_json {
serde_json::from_str(&p).map_err(|e| js_err(e.to_string()))?
} else {
Vec::new()
};
let result = s.skill_db_get(&sc.skill_id, &sql, &params).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
ops.set(
"db_get",
Function::new(
ctx.clone(),
move |sql: String, params_json: Option<String>| -> rquickjs::Result<String> {
let params: Vec<serde_json::Value> = if let Some(p) = params_json {
serde_json::from_str(&p).map_err(|e| js_err(e.to_string()))?
} else {
Vec::new()
};
let result = s
.skill_db_get(&sc.skill_id, &sql, &params)
.map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
),
)?;
}
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("db_all", Function::new(ctx.clone(),
move |sql: String, params_json: Option<String>| -> rquickjs::Result<String> {
let params: Vec<serde_json::Value> = if let Some(p) = params_json {
serde_json::from_str(&p).map_err(|e| js_err(e.to_string()))?
} else {
Vec::new()
};
let result = s.skill_db_all(&sc.skill_id, &sql, &params).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
ops.set(
"db_all",
Function::new(
ctx.clone(),
move |sql: String, params_json: Option<String>| -> rquickjs::Result<String> {
let params: Vec<serde_json::Value> = if let Some(p) = params_json {
serde_json::from_str(&p).map_err(|e| js_err(e.to_string()))?
} else {
Vec::new()
};
let result = s
.skill_db_all(&sc.skill_id, &sql, &params)
.map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
),
)?;
}
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("db_kv_get", Function::new(ctx.clone(),
move |key: String| -> rquickjs::Result<String> {
let result = s.skill_kv_get(&sc.skill_id, &key).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
ops.set(
"db_kv_get",
Function::new(
ctx.clone(),
move |key: String| -> rquickjs::Result<String> {
let result = s.skill_kv_get(&sc.skill_id, &key).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
),
)?;
}
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("db_kv_set", Function::new(ctx.clone(),
move |key: String, value_json: String| -> rquickjs::Result<()> {
let value: serde_json::Value =
serde_json::from_str(&value_json).map_err(|e| js_err(e.to_string()))?;
s.skill_kv_set(&sc.skill_id, &key, &value).map_err(|e| js_err(e))
},
))?;
ops.set(
"db_kv_set",
Function::new(
ctx.clone(),
move |key: String, value_json: String| -> rquickjs::Result<()> {
let value: serde_json::Value =
serde_json::from_str(&value_json).map_err(|e| js_err(e.to_string()))?;
s.skill_kv_set(&sc.skill_id, &key, &value)
.map_err(|e| js_err(e))
},
),
)?;
}
// ========================================================================
@@ -215,45 +316,59 @@ pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, storage: IdbStorage, ski
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("store_get", Function::new(ctx.clone(),
move |key: String| -> rquickjs::Result<String> {
let result = s.skill_store_get(&sc.skill_id, &key).map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
))?;
ops.set(
"store_get",
Function::new(
ctx.clone(),
move |key: String| -> rquickjs::Result<String> {
let result = s
.skill_store_get(&sc.skill_id, &key)
.map_err(|e| js_err(e))?;
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
},
),
)?;
}
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("store_set", Function::new(ctx.clone(),
move |key: String, value_json: String| -> rquickjs::Result<()> {
let value: serde_json::Value =
serde_json::from_str(&value_json).map_err(|e| js_err(e.to_string()))?;
s.skill_store_set(&sc.skill_id, &key, &value).map_err(|e| js_err(e))
},
))?;
ops.set(
"store_set",
Function::new(
ctx.clone(),
move |key: String, value_json: String| -> rquickjs::Result<()> {
let value: serde_json::Value =
serde_json::from_str(&value_json).map_err(|e| js_err(e.to_string()))?;
s.skill_store_set(&sc.skill_id, &key, &value)
.map_err(|e| js_err(e))
},
),
)?;
}
{
let s = storage.clone();
let sc = skill_context.clone();
ops.set("store_delete", Function::new(ctx.clone(),
move |key: String| -> rquickjs::Result<()> {
s.skill_store_delete(&sc.skill_id, &key).map_err(|e| js_err(e))
},
))?;
ops.set(
"store_delete",
Function::new(ctx.clone(), move |key: String| -> rquickjs::Result<()> {
s.skill_store_delete(&sc.skill_id, &key)
.map_err(|e| js_err(e))
}),
)?;
}
{
let s = storage;
let sc = skill_context;
ops.set("store_keys", Function::new(ctx.clone(),
move || -> rquickjs::Result<String> {
ops.set(
"store_keys",
Function::new(ctx.clone(), move || -> rquickjs::Result<String> {
let keys = s.skill_store_keys(&sc.skill_id).map_err(|e| js_err(e))?;
serde_json::to_string(&keys).map_err(|e| js_err(e.to_string()))
},
))?;
}),
)?;
}
Ok(())
@@ -121,12 +121,7 @@ pub struct WebSocketState {
// Constants & Helpers
// ============================================================================
pub const ALLOWED_ENV_VARS: &[&str] = &[
"VITE_BACKEND_URL",
"BACKEND_URL",
"JWT_TOKEN",
"NODE_ENV",
];
pub const ALLOWED_ENV_VARS: &[&str] = &["VITE_BACKEND_URL", "BACKEND_URL", "JWT_TOKEN", "NODE_ENV"];
/// Sanitize error message for use with QuickJS/rquickjs.
/// Some messages (e.g. from SQLite "Invalid symbol 45, offset 19") can trigger
+15 -11
View File
@@ -201,7 +201,10 @@ impl IdbStorage {
// Remove from registry
conn_guard
.execute("DELETE FROM _idb_stores WHERE name = ?", params![store_name])
.execute(
"DELETE FROM _idb_stores WHERE name = ?",
params![store_name],
)
.map_err(|e| format!("Failed to delete object store: {e}"))?;
// Drop the table
@@ -401,8 +404,7 @@ impl IdbStorage {
.map(|v| -> Box<dyn rusqlite::ToSql> { Box::new(json_to_sql(v)) })
.collect();
let params_refs: Vec<&dyn rusqlite::ToSql> =
params.iter().map(|b| b.as_ref()).collect();
let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|b| b.as_ref()).collect();
conn_guard
.execute(sql, params_refs.as_slice())
@@ -425,8 +427,7 @@ impl IdbStorage {
.map(|v| -> Box<dyn rusqlite::ToSql> { Box::new(json_to_sql(v)) })
.collect();
let params_refs: Vec<&dyn rusqlite::ToSql> =
params.iter().map(|b| b.as_ref()).collect();
let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|b| b.as_ref()).collect();
let mut stmt = conn_guard
.prepare(sql)
@@ -470,8 +471,7 @@ impl IdbStorage {
.map(|v| -> Box<dyn rusqlite::ToSql> { Box::new(json_to_sql(v)) })
.collect();
let params_refs: Vec<&dyn rusqlite::ToSql> =
params.iter().map(|b| b.as_ref()).collect();
let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|b| b.as_ref()).collect();
let mut stmt = conn_guard
.prepare(sql)
@@ -519,9 +519,7 @@ impl IdbStorage {
.ok();
match result {
Some(v) => {
serde_json::from_str(&v).map_err(|e| format!("Failed to parse value: {e}"))
}
Some(v) => serde_json::from_str(&v).map_err(|e| format!("Failed to parse value: {e}")),
None => Ok(serde_json::Value::Null),
}
}
@@ -624,7 +622,13 @@ impl IdbStorage {
/// Sanitize a name for use as a table name.
fn sanitize_name(name: &str) -> String {
name.chars()
.map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' })
.map(|c| {
if c.is_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect()
}
+25 -12
View File
@@ -45,9 +45,12 @@ pub async fn generate_openhuman(
"auto_start": false
});
let manifest_path = skill_dir.join("manifest.json");
tokio::fs::write(&manifest_path, serde_json::to_string_pretty(&manifest).unwrap())
.await
.map_err(|e| format!("Failed to write manifest.json: {e}"))?;
tokio::fs::write(
&manifest_path,
serde_json::to_string_pretty(&manifest).unwrap(),
)
.await
.map_err(|e| format!("Failed to write manifest.json: {e}"))?;
// Write index.js — use full LLM-generated source when available,
// otherwise build from the minimal template.
@@ -55,9 +58,10 @@ pub async fn generate_openhuman(
let index_js_content: String = if let Some(full) = spec.full_index_js.as_deref() {
full.to_string()
} else {
let tool_code = spec.tool_code.as_deref().unwrap_or(
"return { result: 'Generated skill executed successfully', args };",
);
let tool_code = spec
.tool_code
.as_deref()
.unwrap_or("return { result: 'Generated skill executed successfully', args };");
let tool_fn_name = sanitize_fn_name(&spec.name);
build_index_js(&tool_fn_name, &spec.description, tool_code)
};
@@ -123,14 +127,17 @@ pub async fn generate_openclaw(spec: &GenerateSkillSpec) -> Result<PathBuf, Stri
}],
};
let toml_content = toml::to_string(&obj)
.map_err(|e| format!("Failed to serialize SKILL.toml: {e}"))?;
let toml_content =
toml::to_string(&obj).map_err(|e| format!("Failed to serialize SKILL.toml: {e}"))?;
tokio::fs::write(skill_dir.join("SKILL.toml"), toml_content)
.await
.map_err(|e| format!("Failed to write SKILL.toml: {e}"))?;
} else {
// Markdown prompt skill.
let md_content = spec.markdown_content.as_deref().unwrap_or(&spec.description);
let md_content = spec
.markdown_content
.as_deref()
.unwrap_or(&spec.description);
let full_md = format!("# {}\n\n{}\n", spec.name, md_content);
tokio::fs::write(skill_dir.join("SKILL.md"), full_md)
.await
@@ -155,8 +162,8 @@ fn workspace_skills_dir() -> Result<PathBuf, String> {
/// always correctly escaped for embedding in a JS string literal.
fn build_index_js(tool_fn: &str, description: &str, tool_code: &str) -> String {
// serde_json::to_string produces a quoted, escaped JSON string literal.
let desc_json = serde_json::to_string(description)
.unwrap_or_else(|_| r#""unknown""#.to_string());
let desc_json =
serde_json::to_string(description).unwrap_or_else(|_| r#""unknown""#.to_string());
format!(
r#"// Auto-generated openhuman skill
@@ -204,7 +211,13 @@ fn sanitize_fn_name(name: &str) -> String {
let joined = name
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' })
.map(|c| {
if c.is_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect::<String>()
.split('_')
.filter(|s| !s.is_empty())
+67 -43
View File
@@ -81,13 +81,9 @@ impl LlmGenerator {
}
/// Generate a skill from scratch based on `task_description`.
pub async fn generate_spec(
&self,
task_description: &str,
) -> Result<GenerateSkillSpec, String> {
let prompt = format!(
"Generate a QuickJS skill for the following task:\n\n{task_description}"
);
pub async fn generate_spec(&self, task_description: &str) -> Result<GenerateSkillSpec, String> {
let prompt =
format!("Generate a QuickJS skill for the following task:\n\n{task_description}");
let code = self.call_anthropic(SYSTEM_PROMPT, &prompt).await?;
Ok(self.build_spec(task_description, code))
}
@@ -114,11 +110,7 @@ impl LlmGenerator {
// -----------------------------------------------------------------------
/// POST a single-turn chat to the Anthropic API and return the text response.
async fn call_anthropic(
&self,
system: &str,
user_message: &str,
) -> Result<String, String> {
async fn call_anthropic(&self, system: &str, user_message: &str) -> Result<String, String> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(90))
.build()
@@ -163,9 +155,7 @@ impl LlmGenerator {
} else {
&body
};
return Err(format!(
"Anthropic API error {status}: {trimmed}"
));
return Err(format!("Anthropic API error {status}: {trimmed}"));
}
let resp: AnthropicResponse = response
@@ -193,10 +183,7 @@ impl LlmGenerator {
GenerateSkillSpec {
name,
description: task_description
.chars()
.take(200)
.collect::<String>(),
description: task_description.chars().take(200).collect::<String>(),
skill_type: "openhuman".to_string(),
// `tool_code` holds the full source when `full_index_js` is set;
// this ensures the fallback path in `generate_openhuman` also has
@@ -224,37 +211,75 @@ impl LlmGenerator {
fn smart_name(task_description: &str) -> String {
// Preambles are checked case-insensitively (longest first to avoid partial matches).
const PREAMBLES: &[&str] = &[
"create a skill that ", "create a skill to ", "create a skill which ",
"create a skill for ", "create a skill ",
"write a skill that ", "write a skill to ", "write a skill which ",
"write a skill for ", "write a skill ",
"build a skill that ", "build a skill to ", "build a skill which ",
"build a skill for ", "build a skill ",
"make a skill that ", "make a skill to ", "make a skill which ",
"make a skill for ", "make a skill ",
"generate a skill that ","generate a skill to ","generate a skill which ",
"generate a skill for ", "generate a skill ",
"a skill that ", "a skill to ", "a skill which ", "a skill for ",
"create a skill that ",
"create a skill to ",
"create a skill which ",
"create a skill for ",
"create a skill ",
"write a skill that ",
"write a skill to ",
"write a skill which ",
"write a skill for ",
"write a skill ",
"build a skill that ",
"build a skill to ",
"build a skill which ",
"build a skill for ",
"build a skill ",
"make a skill that ",
"make a skill to ",
"make a skill which ",
"make a skill for ",
"make a skill ",
"generate a skill that ",
"generate a skill to ",
"generate a skill which ",
"generate a skill for ",
"generate a skill ",
"a skill that ",
"a skill to ",
"a skill which ",
"a skill for ",
];
// Leading fillers that add no meaning when they open the core phrase.
const LEAD_FILLERS: &[&str] = &[
"returns ", "return ", "fetches ", "fetch ", "gets ", "get ",
"shows ", "show ", "displays ", "display ", "outputs ", "output ",
"reads ", "read ", "calculates ", "calculate ", "computes ", "compute ",
"lists ", "list ", "the ", "a ", "an ",
"returns ",
"return ",
"fetches ",
"fetch ",
"gets ",
"get ",
"shows ",
"show ",
"displays ",
"display ",
"outputs ",
"output ",
"reads ",
"read ",
"calculates ",
"calculate ",
"computes ",
"compute ",
"lists ",
"list ",
"the ",
"a ",
"an ",
];
// Stop words skipped when selecting the 3 display tokens.
const STOP_WORDS: &[&str] = &[
"a", "an", "the", "of", "from", "in", "at", "by", "for",
"with", "using", "via", "and", "or", "as", "to", "that",
"a", "an", "the", "of", "from", "in", "at", "by", "for", "with", "using", "via", "and",
"or", "as", "to", "that",
];
let lower = task_description.to_lowercase();
// Step 1 — strip preamble
let preamble_skip = PREAMBLES.iter()
let preamble_skip = PREAMBLES
.iter()
.find(|p| lower.starts_with(*p))
.map(|p| p.len())
.unwrap_or(0);
@@ -262,12 +287,14 @@ fn smart_name(task_description: &str) -> String {
// Step 2 — strip leading filler (up to two passes, e.g. "returns the ")
let core_lower = core.to_lowercase();
let after_filler1 = LEAD_FILLERS.iter()
let after_filler1 = LEAD_FILLERS
.iter()
.find(|f| core_lower.starts_with(*f))
.map(|f| core[f.len()..].trim())
.unwrap_or(core);
let after_filler1_lower = after_filler1.to_lowercase();
let content = LEAD_FILLERS.iter()
let content = LEAD_FILLERS
.iter()
.find(|f| after_filler1_lower.starts_with(*f))
.map(|f| after_filler1[f.len()..].trim())
.unwrap_or(after_filler1);
@@ -427,10 +454,7 @@ mod tests {
#[test]
fn smart_name_get_btc_eth() {
assert_eq!(
smart_name("Get BTC/ETH prices (live)"),
"BTC ETH Prices"
);
assert_eq!(smart_name("Get BTC/ETH prices (live)"), "BTC ETH Prices");
}
#[test]
+7 -2
View File
@@ -88,7 +88,10 @@ impl UnifiedSkillRegistry {
id: manifest.id.clone(),
name: manifest.name.clone(),
skill_type: manifest.skill_type.clone(),
version: manifest.version.clone().unwrap_or_else(|| "0.1.0".to_string()),
version: manifest
.version
.clone()
.unwrap_or_else(|| "0.1.0".to_string()),
description: manifest.description.clone().unwrap_or_default(),
tools,
setup: manifest.setup.clone(),
@@ -227,7 +230,9 @@ impl UnifiedSkillRegistry {
setup: None,
})
}
other => Err(format!("Unknown skill_type: '{other}'. Use 'openhuman' or 'openclaw'.")),
other => Err(format!(
"Unknown skill_type: '{other}'. Use 'openhuman' or 'openclaw'."
)),
}
}
}
+2 -12
View File
@@ -88,13 +88,7 @@ impl SkillEvolver {
let loop_result = tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs),
Self::run_loop(
registry,
api_key,
task_description,
max_iter,
on_progress,
),
Self::run_loop(registry, api_key, task_description, max_iter, on_progress),
)
.await;
@@ -137,11 +131,7 @@ impl SkillEvolver {
let prev_code = last_spec
.as_ref()
.and_then(|s| s.full_index_js.clone())
.or_else(|| {
last_spec
.as_ref()
.and_then(|s| s.tool_code.clone())
})
.or_else(|| last_spec.as_ref().and_then(|s| s.tool_code.clone()))
.unwrap_or_default();
generator_llm
+7 -22
View File
@@ -45,10 +45,7 @@ impl SkillTester {
// rquickjs AsyncRuntime is Send so we can use tokio::task::spawn_blocking
// or just run inline — we use spawn_blocking to avoid blocking the executor
// on a potentially long-running JS init/start sequence.
let result = tokio::task::spawn_blocking(move || {
run_in_sync_context(&js_source)
})
.await;
let result = tokio::task::spawn_blocking(move || run_in_sync_context(&js_source)).await;
match result {
Ok(test_result) => test_result,
@@ -306,20 +303,15 @@ async fn run_async_test(js_source: &str) -> TestResult {
}
Ok(s) if s == "__promise__" => {
// Drive promises until done
let deadline =
tokio::time::Instant::now() + std::time::Duration::from_secs(10);
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
drive_jobs(&qjs_rt).await;
let done = ctx
.with(move |js_ctx| {
let check = format!(
"globalThis.__testToolDone_{idx} === true",
idx = idx
);
js_ctx
.eval::<bool, _>(check.as_bytes())
.unwrap_or(false)
let check =
format!("globalThis.__testToolDone_{idx} === true", idx = idx);
js_ctx.eval::<bool, _>(check.as_bytes()).unwrap_or(false)
})
.await;
@@ -388,10 +380,7 @@ async fn run_async_test(js_source: &str) -> TestResult {
TestResult {
passed: true,
output: if tool_outputs.is_empty() {
format!(
"All {} tool(s) passed",
tool_count
)
format!("All {} tool(s) passed", tool_count)
} else {
tool_outputs.join("; ")
},
@@ -481,11 +470,7 @@ async fn call_lifecycle_fn(
})
.await;
return if err.is_empty() {
Ok(())
} else {
Err(err)
};
return if err.is_empty() { Ok(()) } else { Err(err) };
}
if tokio::time::Instant::now() > deadline {
+17 -5
View File
@@ -152,10 +152,10 @@ const Conversations = () => {
);
}
if (selectedThreadId !== DEFAULT_THREAD_ID) {
dispatch(setSelectedThread(DEFAULT_THREAD_ID));
}
}, [dispatch, selectedThreadId, threads]);
// Always set selected thread to ensure messages view is synced from persisted storage
dispatch(setSelectedThread(DEFAULT_THREAD_ID));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch]);
useEffect(() => {
setIsLoadingModels(true);
@@ -293,6 +293,19 @@ const Conversations = () => {
});
if (event.error_type !== 'cancelled') {
// Deduplicate: skip if the last message is already an error
const currentState = store.getState() as {
thread: { messagesByThreadId: Record<string, ThreadMessage[]> };
};
const threadMessages = currentState.thread.messagesByThreadId[event.thread_id] || [];
const lastMsg = threadMessages[threadMessages.length - 1];
if (
lastMsg?.sender === 'agent' &&
lastMsg?.content === 'Something went wrong — please try again.'
) {
return;
}
dispatch(
addInferenceResponse({
content: 'Something went wrong — please try again.',
@@ -340,7 +353,6 @@ const Conversations = () => {
};
dispatch(addMessageLocal({ threadId: sendingThreadId, message: userMessage }));
dispatch(setSelectedThread(sendingThreadId));
const historySnapshot = messages.filter(
m => !m.id.startsWith('optimistic-') && m.id !== userMessage.id
+5
View File
@@ -249,6 +249,11 @@ const threadSlice = createSlice({
}
state.messagesByThreadId[threadId].push(message);
// Also update current messages view if this is the selected thread
if (threadId === state.selectedThreadId) {
state.messages.push(message);
}
// Update thread metadata
const thread = state.threads.find(t => t.id === threadId);
if (thread) {