Merge pull request #125 from M3gA-Mind/feat/skills-resetup

fix(agent): execute fallback tool calls in the loop
This commit is contained in:
Mega Mind
2026-03-31 21:48:31 +05:30
committed by GitHub
9 changed files with 640 additions and 77 deletions
+1 -1
View File
@@ -57,4 +57,4 @@ tauri.key.pub
/target/
src-tauri/target/
openhuman-skills
workflow
+3
View File
@@ -0,0 +1,3 @@
[submodule "openhuman-skills"]
path = openhuman-skills
url = https://github.com/tinyhumansai/openhuman-skills
+15
View File
@@ -35,11 +35,26 @@ This project adheres to the [Contributor Covenant Code of Conduct](CODE_OF_CONDU
```bash
git clone https://github.com/YOUR_USERNAME/openhuman.git
cd openhuman
git submodule update --init --recursive # pulls openhuman-skills
yarn install
```
Use your own fork in place of `YOUR_USERNAME` when cloning.
The `openhuman-skills` submodule contains the built skill bundles used by the runtime. After cloning you must initialise it (the command above does this). If you forget, the runtime will fall back to fetching skills from the remote registry, which is slower and requires network access.
### Updating Skills
When the `openhuman-skills` submodule is updated upstream, run:
```bash
git submodule update --remote openhuman-skills
cd openhuman-skills && yarn install && yarn build
cd ..
git add openhuman-skills
git commit -m "chore: update openhuman-skills submodule"
```
### Run the App
- **Web only**: `yarn dev` (Vite dev server, typically port 1420)
+401
View File
@@ -0,0 +1,401 @@
# Skills: How They Work End-to-End
This document explains how OpenHuman skills are discovered, fetched, installed, initialized, executed, and synchronized across the desktop app and Rust core.
It is written for engineers who need to debug, extend, or migrate the skills system.
---
## 1) Mental Model
OpenHuman has two skill-related paths:
1. **Active runtime path (authoritative for execution):**
- QuickJS skills managed by Rust core runtime.
- Accessed through JSON-RPC methods under `openhuman.skills_*`.
- UI acts as an RPC client and orchestration layer.
2. **Legacy metadata path (still present):**
- Workspace scanning of `skill.json` + `SKILL.md`.
- Used for older prompt/context loading flows, not the primary execution runtime.
If you are implementing runtime behavior, use the active QuickJS path.
---
## 2) Key Directories and Files
### Frontend (app)
- `app/src/lib/skills/skillsApi.ts`
- Typed RPC wrapper for skills methods (`list_available`, `install`, `start`, `rpc`, etc.).
- `app/src/lib/skills/manager.ts`
- Orchestrates setup, OAuth completion, tool usage, and sync triggers.
- `app/src/lib/skills/runtime.ts`
- Runtime-facing wrapper around skill lifecycle/tool calls.
- `app/src/lib/skills/hooks.ts`
- Read hooks for snapshots and available skills.
- `app/src/lib/skills/sync.ts`
- Maps snapshots to tool sync payloads.
- `app/src/lib/skills/skillEvents.ts`
- Event emitter for local invalidation/re-fetch.
- `app/src/utils/desktopDeepLinkListener.ts`
- Handles deep links (including OAuth complete/error) and notifies runtime.
- `app/src/utils/config.ts`
- Frontend config values including `VITE_SKILLS_GITHUB_REPO`.
### Rust core
- `src/core/jsonrpc.rs`
- Core server startup and runtime bootstrap (`bootstrap_skill_runtime`).
- `src/openhuman/skills/schemas.rs`
- Controller schemas and handlers for `openhuman.skills_*` methods.
- `src/openhuman/skills/registry_ops.rs`
- Remote registry fetch/cache/search/install/uninstall/list logic.
- `src/openhuman/skills/registry_types.rs`
- Registry and available/installed type shapes.
- `src/openhuman/skills/qjs_engine.rs`
- Runtime engine for discovery/start/stop/rpc/tool execution.
- `src/openhuman/skills/manifest.rs`
- Manifest parsing and platform/runtime eligibility checks.
- `src/openhuman/skills/skill_registry.rs`
- Running skill registry, message routing, snapshots.
- `src/openhuman/skills/qjs_skill_instance/*`
- QuickJS instance lifecycle, event loop, JS handlers.
- `src/openhuman/skills/quickjs_libs/bootstrap.js`
- JS environment bootstrap and bridged APIs.
- `src/openhuman/skills/socket_manager.rs`
- Socket integration for tool sync and tool-call routing.
- `src/openhuman/skills/preferences.rs`
- Persisted per-skill preference state (enabled/setup flags).
### Legacy path (non-authoritative for runtime execution)
- `src/openhuman/skills/ops.rs`
- `workspace/skills` scanner for `skill.json` and `SKILL.md`.
---
## 3) Skill Packaging and Storage
The active runtime expects each skill directory to contain at minimum:
- `manifest.json`
- JS entry file (usually `index.js`, but depends on manifest `entry`)
Installed skills are written to:
- `${workspace_dir}/skills/<skill_id>/manifest.json`
- `${workspace_dir}/skills/<skill_id>/<entry_file>`
The runtime also has a skills data area:
- `${base_dir}/skills_data/<skill_id>/...`
Where:
- `base_dir` is `$OPENHUMAN_WORKSPACE` if set, otherwise `~/.openhuman`
- `workspace_dir` is `${base_dir}/workspace`
---
## 4) Registry Fetch and Availability Flow
### Registry source
The core fetches a JSON registry from:
- `SKILLS_REGISTRY_URL` if set, else
- default:
`https://raw.githubusercontent.com/tinyhumansai/openhuman-skills/refs/heads/build/skills/registry.json`
### Caching
The registry is cached to:
- `${workspace_dir}/skills/.registry-cache.json`
Cache TTL is one hour.
### Availability API
When UI calls `openhuman.skills_list_available`, core:
1. Fetches or reads cached registry.
2. Scans installed skill directories under `workspace/skills`.
3. Merges both views:
- `installed` boolean
- `installed_version`
- `update_available`
### Install API
When UI calls `openhuman.skills_install`, core:
1. Finds skill entry by ID in registry.
2. Downloads `manifest_url` and `download_url`.
3. Verifies checksum if `checksum_sha256` exists.
4. Writes files under `workspace/skills/<skill_id>/`.
Uninstall removes that directory.
---
## 5) Runtime Bootstrap and Auto-Start
On core startup, `bootstrap_skill_runtime()`:
1. Resolves `base_dir`.
2. Creates `skills_data` directory.
3. Creates `RuntimeEngine`.
4. Sets `workspace_dir` on engine (`<base_dir>/workspace`).
5. Registers engine globally for RPC handlers.
6. Starts ping and cron schedulers.
7. Launches async auto-start.
Auto-start behavior is driven by:
- discovered manifests (`discover_skills`)
- manifest defaults (`auto_start`)
- preference overrides (`enable/disable` and setup state persistence)
---
## 6) Discovery and Start Rules
`discover_skills()` scans two locations:
1. Runtime source directory (bundled/dev source path resolution).
2. Workspace installed directory (`workspace/skills`).
For each candidate:
- Reads `manifest.json`
- Requires JavaScript runtime compatibility
- Checks current platform compatibility
- Deduplicates by `manifest.id`
`start_skill(skill_id)` behavior:
1. Returns existing running/initializing snapshot if already active.
2. Resolves directory (source dir first, workspace fallback).
3. Validates manifest runtime/platform.
4. Creates a QuickJS skill instance.
5. Spawns event loop and registers skill in registry.
6. Runs lifecycle (`init`, then `start`).
7. Exposes current snapshot/tools/state.
---
## 7) Runtime Message Model
Most interactions become messages from engine to skill instance event loop.
Typical operations:
- `start` / `stop`
- generic rpc (`openhuman.skills_rpc`)
- tool call (`openhuman.skills_call_tool`)
- setup events (`setup/start`, `oauth/complete`)
- sync/tick events (`skill/tick`)
Tool calls can be sync or async in JS. Async calls are awaited with runtime polling and timeout handling in the QuickJS event loop layer.
---
## 8) JSON-RPC Surface (`openhuman.skills_*`)
The skills controllers are registered in `src/openhuman/skills/schemas.rs`.
Current method families:
- Registry/catalog:
- `openhuman.skills_registry_fetch`
- `openhuman.skills_search`
- `openhuman.skills_list_available`
- `openhuman.skills_list_installed`
- `openhuman.skills_install`
- `openhuman.skills_uninstall`
- Runtime lifecycle/state:
- `openhuman.skills_discover`
- `openhuman.skills_list`
- `openhuman.skills_start`
- `openhuman.skills_stop`
- `openhuman.skills_status`
- `openhuman.skills_get_all_snapshots`
- Runtime actions:
- `openhuman.skills_list_tools`
- `openhuman.skills_call_tool`
- `openhuman.skills_rpc`
- `openhuman.skills_sync`
- `openhuman.skills_setup_start`
- Persistence/control:
- `openhuman.skills_enable`
- `openhuman.skills_disable`
- `openhuman.skills_is_enabled`
- `openhuman.skills_set_setup_complete`
- `openhuman.skills_data_read`
- `openhuman.skills_data_write`
- `openhuman.skills_data_dir`
---
## 9) OAuth and Setup Completion Flow (Desktop)
OAuth callback is handled in `desktopDeepLinkListener.ts`.
For `openhuman://oauth/success?...`:
1. Persist setup complete via `openhuman.skills_set_setup_complete`.
2. Ensure skill is running via `openhuman.skills_start`.
3. Send `oauth/complete` via `openhuman.skills_rpc`.
4. Trigger initial sync (`skillManager.triggerSync`).
5. Emit local skill-state refresh event.
This keeps persistence, runtime, and UI in sync after browser-based auth.
---
## 10) State and Snapshot Model
Skill state can be published from JS via bridge APIs (`state.*` in bootstrap environment).
Core tracks snapshots containing:
- skill id/name/status
- tools
- runtime error (if any)
- published state map
- setup and connection status
Frontend hooks (`useSkillSnapshot`, `useAllSkillSnapshots`, etc.) render from these snapshots and refresh on skill events.
---
## 11) Tool Sync and Socket Integration
Socket manager bridges runtime tool inventory and MCP-style calls.
High-level pattern:
1. Core publishes available tools from running skills.
2. Frontend/runtime sync maps snapshots to tool payload.
3. Incoming tool calls route to `skill_id` + `tool_name`.
4. Core executes via runtime and returns `ToolResult`.
---
## 12) Environment Variables and Configuration
### Core/runtime relevant
- `SKILLS_REGISTRY_URL`
- Override skill catalog URL.
- `OPENHUMAN_WORKSPACE`
- Sets base workspace root (`skills_data`, `workspace/skills`, config).
- `OPENHUMAN_CORE_PORT`
- Core JSON-RPC HTTP port.
- `OPENHUMAN_CORE_RUN_MODE`
- Tauri core launch mode behavior.
- `OPENHUMAN_CORE_BIN`
- Override core binary path.
### Frontend relevant
- `VITE_SKILLS_GITHUB_REPO`
- UI-side repository slug default for skills registry context/display.
- Note: runtime fetch authority is still `SKILLS_REGISTRY_URL` in core.
- `VITE_OPENHUMAN_CORE_RPC_URL` / `OPENHUMAN_CORE_RPC_URL`
- Core RPC endpoint override for app client.
---
## 13) End-to-End Sequence (Install + OAuth + Tool Call)
1. User opens Skills screen.
2. UI calls `openhuman.skills_list_available`.
3. Core returns registry + installed/enriched availability.
4. User clicks install/connect.
5. UI calls `openhuman.skills_install`.
6. UI starts skill via `openhuman.skills_start`.
7. Skill initializes in QuickJS (`init` then `start`).
8. OAuth browser completes and deep links back.
9. UI marks setup complete, sends `oauth/complete`, triggers sync.
10. Agent or UI calls tool.
11. Core routes tool call to skill event loop and returns result.
---
## 14) Debugging Guide
### Common checks
1. Registry errors:
- verify `SKILLS_REGISTRY_URL`
- inspect cache file under `workspace/skills/.registry-cache.json`
2. Install issues:
- check `manifest_url`/`download_url` accessibility
- validate checksum mismatch logs
3. Startup issues:
- ensure `manifest.json` exists and `runtime` is JS-compatible
- verify platform filter in manifest
4. OAuth issues:
- confirm deep-link callback includes `integrationId` and `skillId`
- verify `set_setup_complete` and `oauth/complete` RPCs are invoked
5. Tool-call failures:
- verify skill status is `Running`
- inspect skill error in snapshot
### Useful runtime truths
- Catalog truth: remote registry (+ cache)
- Installed truth: `workspace/skills/*/manifest.json`
- Running truth: runtime snapshots from `openhuman.skills_status` / `openhuman.skills_get_all_snapshots`
---
## 15) Known Split-Brain Risks
There is an intentional but risky overlap between:
- QuickJS runtime manifests (`manifest.json`) and
- legacy loader semantics (`skill.json` + `SKILL.md`)
Impact:
- Different subsystems can report different views of "what skills exist."
- Documentation or migration work can accidentally target the wrong system.
Recommendation:
- Treat `openhuman.skills_*` + QuickJS manifests as canonical for execution paths.
- Keep legacy path use explicitly scoped until fully migrated.
---
## 16) Testing Coverage Pointers
- Registry/install/runtime e2e validations:
- `tests/json_rpc_e2e.rs`
- Core unit tests:
- `src/openhuman/skills/*` (registry/runtime modules)
- App integration points:
- `app/src/lib/skills/*`
- deep-link flow in `app/src/utils/desktopDeepLinkListener.ts`
When changing behavior, test both:
1. JSON-RPC behavior from core (`openhuman.skills_*` methods)
2. App orchestration behavior (especially OAuth/setup/sync)
---
## 17) Practical Rules for Contributors
- Put business/runtime behavior in Rust core.
- Keep frontend as orchestration and UX.
- Prefer adding/using explicit `openhuman.skills_*` methods over side channels.
- Preserve setup + enabled flags coherently across restarts.
- Avoid introducing new legacy skill metadata paths.
- Add traceable logs around install/start/setup/tool call boundaries.
+101 -4
View File
@@ -6,7 +6,9 @@ use super::prompt::{PromptContext, SystemPromptBuilder};
use crate::openhuman::agent::host_runtime;
use crate::openhuman::config::Config;
use crate::openhuman::memory::{self, Memory, MemoryCategory};
use crate::openhuman::providers::{self, ChatMessage, ChatRequest, ConversationMessage, Provider};
use crate::openhuman::providers::{
self, ChatMessage, ChatRequest, ConversationMessage, Provider, ToolCall,
};
use crate::openhuman::security::SecurityPolicy;
use crate::openhuman::tools::{self, Tool, ToolSpec};
use crate::openhuman::util::truncate_with_ellipsis;
@@ -195,6 +197,41 @@ impl AgentBuilder {
}
impl Agent {
fn with_fallback_tool_call_ids(
mut parsed_calls: Vec<ParsedToolCall>,
iteration: usize,
) -> Vec<ParsedToolCall> {
for (idx, call) in parsed_calls.iter_mut().enumerate() {
if call.tool_call_id.is_none() {
call.tool_call_id = Some(format!("parsed-{}-{}", iteration + 1, idx + 1));
}
}
parsed_calls
}
fn persisted_tool_calls_for_history(
response: &crate::openhuman::providers::ChatResponse,
parsed_calls: &[ParsedToolCall],
iteration: usize,
) -> Vec<ToolCall> {
if !response.tool_calls.is_empty() {
return response.tool_calls.clone();
}
parsed_calls
.iter()
.enumerate()
.map(|(idx, call)| ToolCall {
id: call
.tool_call_id
.clone()
.unwrap_or_else(|| format!("parsed-{}-{}", iteration + 1, idx + 1)),
name: call.name.clone(),
arguments: call.arguments.to_string(),
})
.collect()
}
pub fn builder() -> AgentBuilder {
AgentBuilder::new()
}
@@ -486,6 +523,7 @@ impl Agent {
};
let (text, calls) = self.tool_dispatcher.parse_response(&response);
let calls = Self::with_fallback_tool_call_ids(calls, iteration);
log::info!(
"[agent_loop] parsed response i={} parsed_text_chars={} parsed_tool_calls={}",
iteration + 1,
@@ -540,10 +578,16 @@ impl Agent {
iteration + 1,
tool_names
);
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,
persisted_tool_calls.len(),
calls.len()
);
self.history.push(ConversationMessage::AssistantToolCalls {
text: response.text.clone(),
tool_calls: response.tool_calls.clone(),
text: if text.is_empty() { None } else { Some(text.clone()) },
tool_calls: persisted_tool_calls,
});
let results = self.execute_tools(&calls).await;
@@ -773,4 +817,57 @@ mod tests {
.iter()
.any(|msg| matches!(msg, ConversationMessage::ToolResults(_))));
}
#[tokio::test]
async fn turn_with_native_dispatcher_persists_fallback_tool_calls() {
let workspace = tempfile::TempDir::new().expect("temp workspace");
let workspace_path = workspace.path().to_path_buf();
let provider = Box::new(MockProvider {
responses: Mutex::new(vec![
crate::openhuman::providers::ChatResponse {
text: Some(
"Checking...\n<tool_call>{\"name\":\"echo\",\"arguments\":{}}</tool_call>"
.into(),
),
tool_calls: vec![],
},
crate::openhuman::providers::ChatResponse {
text: Some("done".into()),
tool_calls: vec![],
},
]),
});
let memory_cfg = crate::openhuman::config::MemoryConfig {
backend: "none".into(),
..crate::openhuman::config::MemoryConfig::default()
};
let mem: Arc<dyn Memory> = Arc::from(
crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path, None).unwrap(),
);
let mut agent = Agent::builder()
.provider(provider)
.tools(vec![Box::new(MockTool)])
.memory(mem)
.tool_dispatcher(Box::new(NativeToolDispatcher))
.workspace_dir(workspace_path)
.build()
.unwrap();
let response = agent.turn("hi").await.unwrap();
assert_eq!(response, "done");
let persisted_calls = agent
.history()
.iter()
.find_map(|msg| match msg {
ConversationMessage::AssistantToolCalls { tool_calls, .. } => Some(tool_calls),
_ => None,
})
.expect("assistant tool calls should be persisted");
assert_eq!(persisted_calls.len(), 1);
assert_eq!(persisted_calls[0].name, "echo");
}
}
+57 -65
View File
@@ -2,6 +2,7 @@ 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;
@@ -32,55 +33,17 @@ pub trait ToolDispatcher: Send + Sync {
pub struct XmlToolDispatcher;
impl XmlToolDispatcher {
fn parse_xml_tool_calls(response: &str) -> (String, Vec<ParsedToolCall>) {
let mut text_parts = Vec::new();
let mut calls = Vec::new();
let mut remaining = response;
while let Some(start) = remaining.find("<tool_call>") {
let before = &remaining[..start];
if !before.trim().is_empty() {
text_parts.push(before.trim().to_string());
}
if let Some(end) = remaining[start..].find("</tool_call>") {
let inner = &remaining[start + 11..start + end];
match serde_json::from_str::<Value>(inner.trim()) {
Ok(parsed) => {
let name = parsed
.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if name.is_empty() {
remaining = &remaining[start + end + 12..];
continue;
}
let arguments = parsed
.get("arguments")
.cloned()
.unwrap_or_else(|| Value::Object(serde_json::Map::new()));
calls.push(ParsedToolCall {
name,
arguments,
tool_call_id: None,
});
}
Err(e) => {
tracing::warn!("Malformed <tool_call> JSON: {e}");
}
}
remaining = &remaining[start + end + 12..];
} else {
break;
}
}
if !remaining.trim().is_empty() {
text_parts.push(remaining.trim().to_string());
}
(text_parts.join("\n"), calls)
fn parse_tool_calls_from_text(response: &str) -> (String, Vec<ParsedToolCall>) {
let (text, calls) = parse_tool_calls(response);
let parsed_calls = calls
.into_iter()
.map(|call| ParsedToolCall {
name: call.name,
arguments: call.arguments,
tool_call_id: None,
})
.collect::<Vec<_>>();
(text, parsed_calls)
}
pub fn tool_specs(tools: &[Box<dyn Tool>]) -> Vec<ToolSpec> {
@@ -91,7 +54,13 @@ impl XmlToolDispatcher {
impl ToolDispatcher for XmlToolDispatcher {
fn parse_response(&self, response: &ChatResponse) -> (String, Vec<ParsedToolCall>) {
let text = response.text_or_empty();
Self::parse_xml_tool_calls(text)
let (parsed_text, parsed_calls) = Self::parse_tool_calls_from_text(text);
tracing::debug!(
parse_mode = "text_fallback",
parsed_tool_calls = parsed_calls.len(),
"xml dispatcher parsed response"
);
(parsed_text, parsed_calls)
}
fn format_results(&self, results: &[ToolExecutionResult]) -> ConversationMessage {
@@ -163,7 +132,7 @@ pub struct NativeToolDispatcher;
impl ToolDispatcher for NativeToolDispatcher {
fn parse_response(&self, response: &ChatResponse) -> (String, Vec<ParsedToolCall>) {
let text = response.text.clone().unwrap_or_default();
let mut calls: Vec<ParsedToolCall> = response
let calls: Vec<ParsedToolCall> = response
.tool_calls
.iter()
.map(|tc| ParsedToolCall {
@@ -180,29 +149,37 @@ impl ToolDispatcher for NativeToolDispatcher {
})
.collect();
// Fallback for providers/models that emit XML-style tool calls in text
// even when native tool-calling is configured.
if calls.is_empty() && !text.is_empty() {
let (fallback_text, fallback_calls) = XmlToolDispatcher::parse_xml_tool_calls(&text);
if !fallback_calls.is_empty() {
calls = fallback_calls
.into_iter()
.map(|call| ParsedToolCall {
name: call.name,
arguments: call.arguments,
tool_call_id: None,
})
.collect();
if !calls.is_empty() {
tracing::debug!(
parse_mode = "native_structured",
parsed_tool_calls = calls.len(),
"native dispatcher parsed response"
);
return (text, calls);
}
if !text.is_empty() {
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
} else {
fallback_text
};
return (display_text, calls);
tracing::debug!(
parse_mode = "text_fallback",
parsed_tool_calls = fallback_calls.len(),
"native dispatcher parsed response"
);
return (display_text, fallback_calls);
}
}
tracing::debug!(
parse_mode = "none",
parsed_tool_calls = 0,
"native dispatcher parsed response"
);
(text, calls)
}
@@ -331,6 +308,21 @@ mod tests {
assert_eq!(calls[0].tool_call_id, None);
}
#[test]
fn native_dispatcher_falls_back_to_invoke_tag() {
let response = ChatResponse {
text: Some(
"Let me run this.\n<invoke>{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}}</invoke>".into(),
),
tool_calls: vec![],
};
let dispatcher = NativeToolDispatcher;
let (text, calls) = dispatcher.parse_response(&response);
assert_eq!(text, "Let me run this.");
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "shell");
}
#[test]
fn xml_format_results_contains_tool_result_tags() {
let dispatcher = XmlToolDispatcher;
+1
View File
@@ -9,6 +9,7 @@ mod session;
mod tool_loop;
pub(crate) use instructions::build_tool_instructions;
pub(crate) use parse::parse_tool_calls;
pub use session::{process_message, run};
pub(crate) use tool_loop::run_tool_call_loop;
+56 -2
View File
@@ -832,6 +832,59 @@ async fn turn_handles_multiple_tools_in_one_response() {
);
}
#[tokio::test]
async fn e2e_native_loop_executes_text_fallback_tool_calls_and_persists_history() {
let provider = Box::new(ScriptedProvider::new(vec![
ChatResponse {
text: Some(
"I'll inspect now.\n<invoke>{\"name\":\"echo\",\"arguments\":{\"message\":\"from-fallback\"}}</invoke>"
.into(),
),
tool_calls: vec![],
},
text_response("Completed via tool"),
]));
let mut agent = build_agent_with(
provider,
vec![Box::new(EchoTool)],
Box::new(NativeToolDispatcher),
);
let response = agent.turn("please use a tool").await.unwrap();
assert_eq!(response, "Completed via tool");
let mut assistant_tool_calls: Option<Vec<ToolCall>> = None;
let mut tool_results: Option<Vec<ToolResultMessage>> = None;
for msg in agent.history() {
match msg {
ConversationMessage::AssistantToolCalls { tool_calls, .. } => {
assistant_tool_calls = Some(tool_calls.clone());
}
ConversationMessage::ToolResults(results) => {
tool_results = Some(results.clone());
}
_ => {}
}
}
let calls = assistant_tool_calls.expect("assistant tool calls should be persisted");
let results = tool_results.expect("tool results should be persisted");
assert_eq!(calls.len(), 1, "expected one parsed/persisted tool call");
assert_eq!(results.len(), 1, "expected one tool result");
assert_eq!(calls[0].name, "echo");
assert!(
calls[0].arguments.contains("from-fallback"),
"persisted tool-call arguments should include fallback payload"
);
assert_eq!(
calls[0].id, results[0].tool_call_id,
"tool result must map to persisted assistant tool-call id"
);
assert_eq!(results[0].content, "from-fallback");
}
// ═══════════════════════════════════════════════════════════════════════════
// 14. System prompt generation & tool instructions
// ═══════════════════════════════════════════════════════════════════════════
@@ -1056,8 +1109,9 @@ fn xml_dispatcher_handles_unclosed_tool_call() {
let dispatcher = XmlToolDispatcher;
let (text, calls) = dispatcher.parse_response(&response);
// Should not panic — just treat as text
assert!(calls.is_empty());
// Should not panic; robust parser recovers the JSON tool call.
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "shell");
assert!(text.contains("Before"));
}
+5 -5
View File
@@ -165,16 +165,16 @@ impl RuntimeEngine {
let current =
std::env::current_dir().map_err(|e| format!("Failed to get current dir: {e}"))?;
// 2. Dev: cwd/skills/skills
let dev_skills = current.join("skills").join("skills");
// 2. Dev: cwd/openhuman-skills/skills
let dev_skills = current.join("openhuman-skills").join("skills");
if dev_skills.exists() {
log::info!("[runtime] Using dev skills dir: {:?}", dev_skills);
return Ok(dev_skills);
}
// 3. Dev: ../skills/skills
// 3. Dev: ../openhuman-skills/skills
if let Some(parent) = current.parent() {
let parent_skills = parent.join("skills").join("skills");
let parent_skills = parent.join("openhuman-skills").join("skills");
if parent_skills.exists() {
log::info!("[runtime] Using parent dev skills dir: {:?}", parent_skills);
return Ok(parent_skills);
@@ -183,7 +183,7 @@ 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("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: {:?}",