mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
Merge pull request #38 from graycyrus/develop
fixes: conversation fixes
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,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::*;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)> {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ─────────────────
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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! {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(¶ms).unwrap_or_else(|_| "{}".to_string());
|
||||
if let Err(e) = handle_js_void_call(rt, ctx, "onLoad", ¶ms_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(¶ms).unwrap_or_else(|_| "null".to_string());
|
||||
let cred_json =
|
||||
serde_json::to_string(¶ms).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(¶ms).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(¶ms).unwrap_or_else(|_| "{}".to_string());
|
||||
handle_js_call(rt, ctx, "onOAuthComplete", ¶ms_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(¶ms).unwrap_or_else(|_| "{}".to_string());
|
||||
handle_js_void_call(rt, ctx, "onOAuthRevoked", ¶ms_str).await
|
||||
let params_str =
|
||||
serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string());
|
||||
handle_js_void_call(rt, ctx, "onOAuthRevoked", ¶ms_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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ¶ms).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, ¶ms)
|
||||
.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, ¶ms).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, ¶ms)
|
||||
.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, ¶ms).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, ¶ms)
|
||||
.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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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'."
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user