diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index fdc765cb6..29d11ef90 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -24,6 +24,7 @@ use openfang_runtime::llm_driver::{CompletionRequest, DriverConfig, LlmDriver, S use openfang_runtime::python_runtime::{self, PythonConfig}; use openfang_runtime::routing::ModelRouter; use openfang_runtime::sandbox::{SandboxConfig, WasmSandbox}; +use openfang_runtime::str_utils::safe_truncate_str; use openfang_runtime::tool_runner::builtin_tool_definitions; use openfang_types::agent::*; use openfang_types::capability::Capability; @@ -417,8 +418,8 @@ fn append_daily_memory_log(workspace: &Path, response: &str) { return; } } - // Truncate long responses for the log (UTF-8 safe) - let summary = openfang_types::truncate_str(trimmed, 500); + // Truncate long responses for the log + let summary = safe_truncate_str(trimmed, 500); let timestamp = chrono::Utc::now().format("%H:%M:%S").to_string(); if let Ok(mut f) = std::fs::OpenOptions::new() .create(true) @@ -450,7 +451,7 @@ fn read_identity_file(workspace: &Path, filename: &str) -> Option { return None; } if content.len() > MAX_IDENTITY_FILE_BYTES { - Some(openfang_types::truncate_str(&content, MAX_IDENTITY_FILE_BYTES).to_string()) + Some(safe_truncate_str(&content, MAX_IDENTITY_FILE_BYTES).to_string()) } else { Some(content) } @@ -2383,7 +2384,7 @@ impl OpenFangKernel { .take(5) .enumerate() .map(|(i, t)| { - let truncated = if t.len() > 200 { &t[..200] } else { t }; + let truncated = safe_truncate_str(t, 200); format!("{}. {}", i + 1, truncated) }) .collect::>() diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index 84934fd16..22d764423 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -295,6 +295,7 @@ pub async fn execute_tool( // Location tool "location_get" => tool_location_get().await, + "system_time" => tool_system_time(input).await, // Cron scheduling tools "cron_create" => tool_cron_create(input, kernel, caller_agent_id).await, @@ -807,6 +808,17 @@ pub fn builtin_tool_definitions() -> Vec { "properties": {} }), }, + ToolDefinition { + name: "system_time".to_string(), + description: "Get current system time with timezone and Unix timestamp. Supports timezone='local' or 'utc', and format='rfc3339' or 'iso'.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "timezone": { "type": "string", "description": "Timezone mode: 'local' (default) or 'utc'" }, + "format": { "type": "string", "description": "Output time format: 'rfc3339' (default) or 'iso'" } + } + }), + }, // --- Browser automation tools --- ToolDefinition { name: "browser_navigate".to_string(), @@ -2514,6 +2526,68 @@ async fn tool_location_get() -> Result { serde_json::to_string_pretty(&result).map_err(|e| format!("Serialize error: {e}")) } +async fn tool_system_time(input: &serde_json::Value) -> Result { + let timezone = input["timezone"].as_str().unwrap_or("local").to_lowercase(); + let format = input["format"].as_str().unwrap_or("rfc3339").to_lowercase(); + + if format != "rfc3339" && format != "iso" { + return Err(format!( + "Unsupported format '{}'. Use 'rfc3339' or 'iso'.", + format + )); + } + + fn format_dt(dt: chrono::DateTime, fmt: &str) -> String + where + Tz::Offset: std::fmt::Display, + { + match fmt { + "iso" => dt.format("%Y-%m-%dT%H:%M:%S%.3f%:z").to_string(), + _ => dt.to_rfc3339(), + } + } + + let (local_time, utc_time, offset, timezone_label, unix_seconds) = match timezone.as_str() { + "local" => { + let local_now = chrono::Local::now(); + let utc_now = local_now.with_timezone(&chrono::Utc); + ( + format_dt(local_now, &format), + format_dt(utc_now, &format), + local_now.offset().to_string(), + "local".to_string(), + local_now.timestamp(), + ) + } + "utc" => { + let utc_now = chrono::Utc::now(); + ( + format_dt(utc_now, &format), + format_dt(utc_now, &format), + "+00:00".to_string(), + "utc".to_string(), + utc_now.timestamp(), + ) + } + other => { + return Err(format!( + "Unsupported timezone '{}'. Use 'local' or 'utc'.", + other + )); + } + }; + + let result = serde_json::json!({ + "local_time": local_time, + "utc_time": utc_time, + "timezone": timezone_label, + "offset": offset, + "unix_seconds": unix_seconds, + }); + + serde_json::to_string_pretty(&result).map_err(|e| format!("Serialize error: {e}")) +} + // --------------------------------------------------------------------------- // Media understanding tools // --------------------------------------------------------------------------- @@ -3310,6 +3384,85 @@ mod tests { assert!(result.content.contains("Unknown tool")); } + #[tokio::test] + async fn test_system_time_default() { + let result = execute_tool( + "test-id", + "system_time", + &serde_json::json!({}), + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, // media_engine + None, // exec_policy + None, // tts_engine + None, // docker_config + None, // process_manager + ) + .await; + assert!(!result.is_error); + assert!(result.content.contains("unix_seconds")); + assert!(result.content.contains("local_time")); + } + + #[tokio::test] + async fn test_system_time_utc_iso() { + let result = execute_tool( + "test-id", + "system_time", + &serde_json::json!({"timezone":"utc","format":"iso"}), + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, // media_engine + None, // exec_policy + None, // tts_engine + None, // docker_config + None, // process_manager + ) + .await; + assert!(!result.is_error); + assert!(result.content.contains("\"timezone\": \"utc\"")); + } + + #[tokio::test] + async fn test_system_time_invalid_timezone() { + let result = execute_tool( + "test-id", + "system_time", + &serde_json::json!({"timezone":"Asia/Shanghai"}), + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, // media_engine + None, // exec_policy + None, // tts_engine + None, // docker_config + None, // process_manager + ) + .await; + assert!(result.is_error); + assert!(result.content.contains("Unsupported timezone")); + } + #[tokio::test] async fn test_agent_tools_without_kernel() { let result = execute_tool( diff --git a/crates/openfang-skills/src/clawhub.rs b/crates/openfang-skills/src/clawhub.rs index 65e3b627b..d91f79f2c 100644 --- a/crates/openfang-skills/src/clawhub.rs +++ b/crates/openfang-skills/src/clawhub.rs @@ -17,6 +17,7 @@ use crate::SkillError; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; +use std::time::Duration; use tracing::{info, warn}; // --------------------------------------------------------------------------- @@ -427,20 +428,66 @@ impl ClawHubClient { info!(slug, "Downloading skill from ClawHub"); - let response = self - .client - .get(&url) - .header("User-Agent", "OpenFang/0.1") - .send() - .await - .map_err(|e| SkillError::Network(format!("ClawHub download failed: {e}")))?; - - if !response.status().is_success() { - return Err(SkillError::Network(format!( - "ClawHub download returned {}", - response.status() - ))); - } + const MAX_ATTEMPTS: usize = 4; + let response = { + let mut attempt = 0usize; + loop { + attempt += 1; + match self + .client + .get(&url) + .header("User-Agent", "OpenFang/0.1") + .send() + .await + { + Ok(resp) if resp.status().is_success() => break resp, + Ok(resp) => { + let status = resp.status(); + let retryable = status == reqwest::StatusCode::TOO_MANY_REQUESTS + || status.is_server_error(); + if retryable && attempt < MAX_ATTEMPTS { + let delay = retry_delay(&resp, attempt); + warn!( + slug, + %status, + attempt, + max_attempts = MAX_ATTEMPTS, + wait_secs = delay.as_secs(), + "ClawHub download throttled/server error; retrying" + ); + tokio::time::sleep(delay).await; + continue; + } + if retryable { + return Err(SkillError::Network(format!( + "ClawHub download returned {status} after {attempt} attempt(s)" + ))); + } + return Err(SkillError::Network(format!( + "ClawHub download returned {status}" + ))); + } + Err(e) => { + if attempt < MAX_ATTEMPTS { + let delay = exponential_backoff(attempt); + warn!( + slug, + attempt, + max_attempts = MAX_ATTEMPTS, + wait_secs = delay.as_secs(), + error = %e, + "ClawHub download request failed; retrying" + ); + tokio::time::sleep(delay).await; + continue; + } + return Err(SkillError::Network(format!( + "ClawHub download failed after {attempt} attempt(s): {e}" + ))); + } + } + } + }; let bytes = response .bytes() @@ -621,9 +668,28 @@ fn which_check(name: &str) -> Option { } } +fn retry_delay(response: &reqwest::Response, attempt: usize) -> Duration { + if let Some(retry_after) = response + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.trim().parse::().ok()) + { + return Duration::from_secs(retry_after.min(60)); + } + exponential_backoff(attempt) +} + +fn exponential_backoff(attempt: usize) -> Duration { + // attempt starts at 1 + let secs = 1u64 << attempt.saturating_sub(1).min(5); + Duration::from_secs(secs) +} + #[cfg(test)] mod tests { use super::*; + use reqwest::header::{HeaderMap, HeaderValue, RETRY_AFTER}; #[test] fn test_browse_entry_serde_real_format() { @@ -821,4 +887,23 @@ mod tests { }; assert_eq!(ClawHubClient::entry_version(&entry), "2.0.0"); } + + #[test] + fn test_exponential_backoff_caps() { + assert_eq!(exponential_backoff(1).as_secs(), 1); + assert_eq!(exponential_backoff(2).as_secs(), 2); + assert_eq!(exponential_backoff(3).as_secs(), 4); + assert_eq!(exponential_backoff(10).as_secs(), 32); + } + + #[test] + fn test_retry_after_parse_seconds() { + let mut headers = HeaderMap::new(); + headers.insert(RETRY_AFTER, HeaderValue::from_static("7")); + let parsed = headers + .get(RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.trim().parse::().ok()); + assert_eq!(parsed, Some(7)); + } }