mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(agent): add generic skills_call bridge for runtime skill tools
Expose a model-callable `skills_call` tool in the shared tool registry and add coverage for native dispatcher execution of generic skill invocations. Closes #67. Made-with: Cursor
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ToolResult> {
|
||||
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;
|
||||
|
||||
@@ -1275,6 +1323,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<String> = agent
|
||||
.history()
|
||||
.iter()
|
||||
.filter_map(|msg| match msg {
|
||||
ConversationMessage::ToolResults(results) => Some(
|
||||
results
|
||||
.iter()
|
||||
.map(|r| r.content.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.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
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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: {:?}",
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<ToolResult> {
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user