diff --git a/src/openhuman/agent/agent.rs b/src/openhuman/agent/agent.rs index 1e18a0d80..cba70389f 100644 --- a/src/openhuman/agent/agent.rs +++ b/src/openhuman/agent/agent.rs @@ -578,7 +578,8 @@ impl Agent { iteration + 1, tool_names ); - let persisted_tool_calls = Self::persisted_tool_calls_for_history(&response, &calls, iteration); + let persisted_tool_calls = + Self::persisted_tool_calls_for_history(&response, &calls, iteration); log::info!( "[agent_loop] persisting assistant tool calls i={} persisted_tool_calls={} parsed_tool_calls={}", iteration + 1, @@ -586,7 +587,11 @@ impl Agent { calls.len() ); self.history.push(ConversationMessage::AssistantToolCalls { - text: if text.is_empty() { None } else { Some(text.clone()) }, + text: if text.is_empty() { + None + } else { + Some(text.clone()) + }, tool_calls: persisted_tool_calls, }); diff --git a/src/openhuman/agent/dispatcher.rs b/src/openhuman/agent/dispatcher.rs index f73b15007..66db64590 100644 --- a/src/openhuman/agent/dispatcher.rs +++ b/src/openhuman/agent/dispatcher.rs @@ -1,8 +1,8 @@ +use crate::openhuman::agent::loop_::parse_tool_calls; use crate::openhuman::providers::{ ChatMessage, ChatResponse, ConversationMessage, ToolResultMessage, }; use crate::openhuman::tools::{Tool, ToolSpec}; -use crate::openhuman::agent::loop_::parse_tool_calls; use serde_json::Value; use std::fmt::Write; @@ -159,7 +159,8 @@ impl ToolDispatcher for NativeToolDispatcher { } if !text.is_empty() { - let (fallback_text, fallback_calls) = XmlToolDispatcher::parse_tool_calls_from_text(&text); + let (fallback_text, fallback_calls) = + XmlToolDispatcher::parse_tool_calls_from_text(&text); if !fallback_calls.is_empty() { let display_text = if fallback_text.is_empty() { text diff --git a/src/openhuman/agent/tests.rs b/src/openhuman/agent/tests.rs index 0f37509e3..44b3cbedb 100644 --- a/src/openhuman/agent/tests.rs +++ b/src/openhuman/agent/tests.rs @@ -159,6 +159,54 @@ impl Tool for EchoTool { } } +/// A generic skills bridge test-double tool. +struct SkillsCallEchoTool; + +#[async_trait] +impl Tool for SkillsCallEchoTool { + fn name(&self) -> &str { + "skills_call" + } + + fn description(&self) -> &str { + "Calls a skill tool" + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "skill_id": {"type": "string"}, + "tool_name": {"type": "string"}, + "arguments": {"type": "object"} + }, + "required": ["skill_id", "tool_name"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> Result { + let skill_id = args + .get("skill_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let tool_name = args + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let message = args + .get("arguments") + .and_then(|v| v.get("message")) + .and_then(|v| v.as_str()) + .unwrap_or("empty"); + + Ok(ToolResult { + success: true, + output: format!("{skill_id}:{tool_name}:{message}"), + error: None, + }) + } +} + /// A tool that always fails execution. struct FailingTool; @@ -1289,6 +1337,54 @@ fn native_dispatcher_converts_tool_results_to_tool_messages() { assert_eq!(messages[1].role, "tool"); } +#[tokio::test] +async fn native_dispatcher_executes_generic_skills_call_tool() { + let provider = Box::new(ScriptedProvider::new(vec![ + tool_response(vec![ToolCall { + id: "tc-skills-1".into(), + name: "skills_call".into(), + arguments: serde_json::json!({ + "skill_id": "e2e-runtime", + "tool_name": "echo", + "arguments": { "message": "hello from agent test" } + }) + .to_string(), + }]), + text_response("skills call done"), + ])); + + let mut agent = build_agent_with( + provider, + vec![Box::new(SkillsCallEchoTool)], + Box::new(NativeToolDispatcher), + ); + + let response = agent.turn("Use skills_call").await.unwrap(); + assert_eq!(response, "skills call done"); + + let result_payloads: Vec = agent + .history() + .iter() + .filter_map(|msg| match msg { + ConversationMessage::ToolResults(results) => Some( + results + .iter() + .map(|r| r.content.clone()) + .collect::>() + .join("\n"), + ), + _ => None, + }) + .collect(); + + assert!( + result_payloads + .iter() + .any(|payload| payload.contains("e2e-runtime:echo:hello from agent test")), + "expected skills_call output in tool results, got: {result_payloads:?}" + ); +} + // ═══════════════════════════════════════════════════════════════════════════ // 23. XML tool instructions generation // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/openhuman/skills/qjs_engine.rs b/src/openhuman/skills/qjs_engine.rs index f8188a86f..9298f26a2 100644 --- a/src/openhuman/skills/qjs_engine.rs +++ b/src/openhuman/skills/qjs_engine.rs @@ -183,7 +183,10 @@ impl RuntimeEngine { // 4. Production: bundled resources if let Some(resource_dir) = self.resource_dir.read().as_ref() { - let bundled_skills = resource_dir.join("_up_").join("openhuman-skills").join("skills"); + let bundled_skills = resource_dir + .join("_up_") + .join("openhuman-skills") + .join("skills"); if bundled_skills.exists() { log::info!( "[runtime] Using bundled skills from resources: {:?}", diff --git a/src/openhuman/tools/mod.rs b/src/openhuman/tools/mod.rs index 8480daa57..9aa5c3eae 100644 --- a/src/openhuman/tools/mod.rs +++ b/src/openhuman/tools/mod.rs @@ -29,6 +29,7 @@ pub mod schema; mod schemas; pub mod screenshot; pub mod shell; +pub mod skills_call; pub mod traits; pub mod web_search_tool; @@ -65,6 +66,7 @@ pub use schemas::{ }; pub use screenshot::ScreenshotTool; pub use shell::ShellTool; +pub use skills_call::SkillsCallTool; pub use traits::Tool; #[allow(unused_imports)] pub use traits::{ToolResult, ToolSpec}; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 878647a46..6aa7e4453 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -94,6 +94,7 @@ pub fn all_tools_with_runtime( security.clone(), workspace_dir.to_path_buf(), )), + Box::new(SkillsCallTool::new()), ]; if browser_config.enabled { @@ -252,6 +253,7 @@ mod tests { assert!(names.contains(&"schedule")); assert!(names.contains(&"pushover")); assert!(names.contains(&"proxy_config")); + assert!(names.contains(&"skills_call")); } #[test] @@ -291,6 +293,7 @@ mod tests { assert!(names.contains(&"browser_open")); assert!(names.contains(&"pushover")); assert!(names.contains(&"proxy_config")); + assert!(names.contains(&"skills_call")); } #[test] diff --git a/src/openhuman/tools/skills_call.rs b/src/openhuman/tools/skills_call.rs new file mode 100644 index 000000000..343ab0a6b --- /dev/null +++ b/src/openhuman/tools/skills_call.rs @@ -0,0 +1,179 @@ +use async_trait::async_trait; +use serde_json::{Map, Value}; + +use crate::openhuman::skills::require_engine; +use crate::openhuman::tools::{Tool, ToolResult}; + +pub struct SkillsCallTool; + +impl SkillsCallTool { + pub fn new() -> Self { + Self + } + + fn validate_args(args: Value) -> anyhow::Result<(String, String, Value)> { + let obj = args + .as_object() + .ok_or_else(|| anyhow::anyhow!("arguments must be a JSON object"))?; + + let skill_id = obj + .get("skill_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|v| !v.is_empty()) + .ok_or_else(|| anyhow::anyhow!("missing required field: skill_id"))? + .to_string(); + + let tool_name = obj + .get("tool_name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|v| !v.is_empty()) + .ok_or_else(|| anyhow::anyhow!("missing required field: tool_name"))? + .to_string(); + + let arguments = obj + .get("arguments") + .cloned() + .unwrap_or_else(|| Value::Object(Map::new())); + + Ok((skill_id, tool_name, arguments)) + } + + fn render_skill_output(result: &crate::openhuman::skills::types::ToolResult) -> String { + let mut parts = Vec::new(); + for block in &result.content { + match block { + crate::openhuman::skills::types::ToolContent::Text { text } => { + parts.push(text.clone()); + } + crate::openhuman::skills::types::ToolContent::Json { data } => { + parts.push(data.to_string()); + } + } + } + + if parts.is_empty() { + String::new() + } else { + parts.join("\n") + } + } +} + +#[async_trait] +impl Tool for SkillsCallTool { + fn name(&self) -> &str { + "skills_call" + } + + fn description(&self) -> &str { + "Call a running QuickJS skill tool by skill_id and tool_name." + } + + fn parameters_schema(&self) -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "skill_id": { + "type": "string", + "description": "The runtime skill id (for example: notion, google, github)." + }, + "tool_name": { + "type": "string", + "description": "The tool name exported by that skill." + }, + "arguments": { + "type": "object", + "description": "Arguments for the skill tool call. Defaults to {}." + } + }, + "required": ["skill_id", "tool_name"], + "additionalProperties": false + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let (skill_id, tool_name, arguments) = Self::validate_args(args)?; + let engine = require_engine().map_err(anyhow::Error::msg)?; + + let result = engine + .call_tool(&skill_id, &tool_name, arguments) + .await + .map_err(anyhow::Error::msg)?; + + Ok(ToolResult { + success: !result.is_error, + output: Self::render_skill_output(&result), + error: result + .is_error + .then(|| "skill tool reported an error".to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schema_declares_required_fields() { + let schema = SkillsCallTool::new().parameters_schema(); + let required = schema["required"] + .as_array() + .expect("required must be an array"); + assert!(required.contains(&Value::String("skill_id".to_string()))); + assert!(required.contains(&Value::String("tool_name".to_string()))); + } + + #[test] + fn validate_args_defaults_arguments_object() { + let (skill_id, tool_name, arguments) = SkillsCallTool::validate_args(serde_json::json!({ + "skill_id": "notion", + "tool_name": "search" + })) + .expect("args should be valid"); + + assert_eq!(skill_id, "notion"); + assert_eq!(tool_name, "search"); + assert!(arguments.is_object()); + } + + #[test] + fn render_skill_output_joins_content_blocks() { + let rendered = + SkillsCallTool::render_skill_output(&crate::openhuman::skills::types::ToolResult { + content: vec![ + crate::openhuman::skills::types::ToolContent::Text { + text: "first".to_string(), + }, + crate::openhuman::skills::types::ToolContent::Json { + data: serde_json::json!({"ok": true}), + }, + ], + is_error: false, + }); + + assert!(rendered.contains("first")); + assert!(rendered.contains(r#"{"ok":true}"#)); + } + + #[tokio::test] + async fn execute_fails_when_runtime_is_not_initialized() { + let tool = SkillsCallTool::new(); + let result = tool + .execute(serde_json::json!({ + "skill_id": "missing-runtime", + "tool_name": "echo", + "arguments": {} + })) + .await; + + assert!(result.is_err()); + let err = result.err().expect("error expected").to_string(); + assert!( + err.contains("skill runtime not initialized"), + "unexpected error: {err}" + ); + } +}