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:
M3gA-Mind
2026-03-31 22:04:51 +05:30
parent 331b1e4ef8
commit 9aa60e6e42
7 changed files with 294 additions and 5 deletions
+7 -2
View File
@@ -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,
});
+3 -2
View File
@@ -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
+96
View File
@@ -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
// ═══════════════════════════════════════════════════════════════════════════
+4 -1
View File
@@ -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: {:?}",
+2
View File
@@ -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};
+3
View File
@@ -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]
+179
View File
@@ -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}"
);
}
}