Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions crates/openfang-kernel/src/kernel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -450,7 +451,7 @@ fn read_identity_file(workspace: &Path, filename: &str) -> Option<String> {
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)
}
Expand Down Expand Up @@ -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::<Vec<_>>()
Expand Down
153 changes: 153 additions & 0 deletions crates/openfang-runtime/src/tool_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -807,6 +808,17 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
"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(),
Expand Down Expand Up @@ -2514,6 +2526,68 @@ async fn tool_location_get() -> Result<String, String> {
serde_json::to_string_pretty(&result).map_err(|e| format!("Serialize error: {e}"))
}

async fn tool_system_time(input: &serde_json::Value) -> Result<String, String> {
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<Tz: chrono::TimeZone>(dt: chrono::DateTime<Tz>, 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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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(
Expand Down
113 changes: 99 additions & 14 deletions crates/openfang-skills/src/clawhub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -621,9 +668,28 @@ fn which_check(name: &str) -> Option<PathBuf> {
}
}

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::<u64>().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() {
Expand Down Expand Up @@ -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::<u64>().ok());
assert_eq!(parsed, Some(7));
}
}