feat: route staged scene skills through runtime

Add registry-driven scene routing and multi-root skill loading so fault-details and 95598 scene skills can be triggered from natural language while still running through the browser-backed runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
木炎
2026-04-07 16:17:17 +08:00
parent bdf8e12246
commit 96c3bf1dee
21 changed files with 2846 additions and 240 deletions

View File

@@ -1,4 +1,4 @@
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use zeroclaw::agent::dispatcher::NativeToolDispatcher;
@@ -12,8 +12,9 @@ use zeroclaw::tools::{self, ReadSkillTool};
use zeroclaw::SecurityPolicy;
use crate::compat::memory_adapter::build_memory;
use crate::compat::config_adapter::resolve_scene_skills_dir_path;
use crate::pipe::PipeError;
use crate::runtime::{RuntimeProfile, ToolPolicy};
use crate::runtime::{match_scene_instruction, DispatchMode, RuntimeProfile, ToolPolicy};
const BROWSER_ACTION_TOOL_NAME: &str = "browser_action";
const SUPERRPA_BROWSER_TOOL_NAME: &str = "superrpa_browser";
@@ -25,6 +26,7 @@ const ZHIHU_HOTLIST_EXECUTION_PROMPT: &str = "Zhihu hotlist execution contract:\
const OFFICE_EXPORT_COMPLETION_PROMPT: &str = "Export completion contract:\n- This task requires a real Excel export.\n- After the Zhihu rows are available, you must call openxml_office before finishing.\n- Never fabricate, simulate, or invent substitute hotlist data when a live collection/export task fails.\n- If live collection fails, report the failure concisely instead of producing fake rows.\n- Do not stop after describing how you will parse or export the data.\n- Do not repeat the same sentence or section in your final answer.\n- Your final answer must include the generated local .xlsx path.";
const SCREEN_EXPORT_COMPLETION_PROMPT: &str = "Presentation completion contract:\n- This task requires a real dashboard artifact.\n- After the Zhihu rows are available, you must call screen_html_export before finishing.\n- Do not stop after describing how you will render or present the data.\n- Do not repeat the same sentence or section in your final answer.\n- Your final answer must include the local .html path and the presentation object.";
const ZHIHU_WRITE_PUBLISH_PROMPT: &str = "Zhihu article publish contract:\n- This task may publish a Zhihu article.\n- You must not click publish without explicit human confirmation in the current session.\n- If the user asked to publish but no explicit confirmation phrase is present yet, ask for confirmation concisely and stop after the confirmation request.\n- Do not keep exploring tools after you have determined that publish confirmation is missing.\n- If the user only asked to write or draft, stay in draft mode and do not treat it as publish mode.\n- Do not repeat the same sentence or section in your final answer.";
const REPAIR_CITY_DISPATCH_EXECUTION_PROMPT: &str = "95598 repair city dispatch execution contract:\n- Treat this as a browser workflow, not a text-only task.\n- You must call `95598-repair-city-dispatch.collect_repair_orders` first when the tool is available.\n- Use generic browser probing only after the scene-specific collection tool fails or is unavailable.\n- Collect the live repair order queue before summarizing or reporting status.";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeEngine {
@@ -59,7 +61,7 @@ impl RuntimeEngine {
&self,
provider: Box<dyn Provider>,
config: &ZeroClawConfig,
skills_dir: &Path,
skills_dirs: &[PathBuf],
mut tools: Vec<Box<dyn zeroclaw::tools::Tool>>,
browser_surface_present: bool,
instruction: &str,
@@ -71,7 +73,7 @@ impl RuntimeEngine {
&config.workspace_dir,
));
let observer: Arc<dyn Observer> = Arc::new(NoopObserver);
let skills = load_runtime_skills(config, skills_dir);
let skills = self.load_skills_for_surface(config, skills_dirs, browser_surface_present);
let (mut runtime_tools, _, _, _, _, _) = tools::all_tools_with_runtime(
Arc::new(config.clone()),
&security,
@@ -90,15 +92,21 @@ impl RuntimeEngine {
);
runtime_tools.append(&mut tools);
let default_skills_dir = config.workspace_dir.join("skills");
let has_custom_skills_dir = skills_dirs.iter().any(|d| *d != default_skills_dir);
if matches!(
config.skills.prompt_injection_mode,
SkillsPromptInjectionMode::Compact
) && skills_dir != config.workspace_dir.join("skills")
) && has_custom_skills_dir
{
let first_custom = skills_dirs
.iter()
.find(|d| **d != default_skills_dir)
.cloned();
runtime_tools.retain(|tool| tool.name() != READ_SKILL_TOOL_NAME);
runtime_tools.push(Box::new(ReadSkillTool::with_runtime_skills_dir(
config.workspace_dir.clone(),
Some(skills_dir.to_path_buf()),
first_custom,
config.skills.allow_scripts,
config.skills.open_skills_enabled,
config.skills.open_skills_dir.clone(),
@@ -124,7 +132,7 @@ impl RuntimeEngine {
.skills_prompt_mode(config.skills.prompt_injection_mode)
.allowed_tools(self.allowed_tools_for_config(
config,
skills_dir,
skills_dirs,
browser_surface_present,
instruction,
))
@@ -145,6 +153,9 @@ impl RuntimeEngine {
}
let mut sections = vec![BROWSER_TOOL_CONTRACT_PROMPT.to_string()];
if let Some(scene_contract) = build_scene_execution_contract(trimmed_instruction) {
sections.push(scene_contract);
}
if is_zhihu_hotlist_task(trimmed_instruction, page_url, page_title) {
sections.push(ZHIHU_HOTLIST_EXECUTION_PROMPT.to_string());
}
@@ -167,16 +178,9 @@ impl RuntimeEngine {
pub fn loaded_skills(
&self,
config: &ZeroClawConfig,
skills_dir: &Path,
skills_dirs: &[PathBuf],
) -> Vec<zeroclaw::skills::Skill> {
let mut skills = load_runtime_skills(config, skills_dir);
skills.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then(left.version.cmp(&right.version))
});
skills.dedup_by(|left, right| left.name == right.name && left.version == right.version);
skills
self.load_skills_for_surface(config, skills_dirs, self.browser_surface_enabled())
}
pub fn should_attach_openxml_office_tool(&self, instruction: &str) -> bool {
@@ -190,11 +194,12 @@ impl RuntimeEngine {
fn allowed_tools_for_config(
&self,
config: &ZeroClawConfig,
skills_dir: &Path,
skills_dirs: &[PathBuf],
browser_surface_present: bool,
instruction: &str,
) -> Option<Vec<String>> {
let mut allowed_tools = self.tool_policy.allowed_tools.clone();
let skills = self.load_skills_for_surface(config, skills_dirs, browser_surface_present);
if !browser_surface_present {
allowed_tools.retain(|tool| {
tool != BROWSER_ACTION_TOOL_NAME && tool != SUPERRPA_BROWSER_TOOL_NAME
@@ -216,9 +221,7 @@ impl RuntimeEngine {
allowed_tools.push("file_read".to_string());
}
if browser_surface_present {
allowed_tools.extend(browser_script_tool_names(&load_runtime_skills(
config, skills_dir,
)));
allowed_tools.extend(browser_script_tool_names(&skills));
}
allowed_tools.dedup();
@@ -230,6 +233,28 @@ impl RuntimeEngine {
Some(allowed_tools)
}
}
fn load_skills_for_surface(
&self,
config: &ZeroClawConfig,
skills_dirs: &[PathBuf],
browser_surface_present: bool,
) -> Vec<zeroclaw::skills::Skill> {
let mut skills = load_runtime_skills(config, skills_dirs);
if !browser_surface_present {
skills.iter_mut().for_each(|skill| {
skill.tools.retain(|tool| tool.kind != "browser_script");
});
skills.retain(|skill| !skill.tools.is_empty());
}
skills.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then(left.version.cmp(&right.version))
});
skills.dedup_by(|left, right| left.name == right.name && left.version == right.version);
skills
}
}
fn browser_script_tool_names(skills: &[zeroclaw::skills::Skill]) -> Vec<String> {
@@ -251,6 +276,17 @@ fn task_needs_local_file_read(instruction: &str) -> bool {
normalized.contains("/home/") || normalized.contains("./") || normalized.contains("../")
}
fn build_scene_execution_contract(instruction: &str) -> Option<String> {
let scene = match_scene_instruction(instruction)?;
if scene.id == "95598-repair-city-dispatch"
&& matches!(scene.dispatch_mode, DispatchMode::AgentBrowser)
{
Some(REPAIR_CITY_DISPATCH_EXECUTION_PROMPT.to_string())
} else {
None
}
}
pub fn is_zhihu_hotlist_task(
instruction: &str,
page_url: Option<&str>,
@@ -338,12 +374,17 @@ pub fn is_zhihu_write_task(
is_zhihu && is_write
}
fn load_runtime_skills(config: &ZeroClawConfig, skills_dir: &Path) -> Vec<zeroclaw::skills::Skill> {
fn load_runtime_skills(config: &ZeroClawConfig, skills_dirs: &[PathBuf]) -> Vec<zeroclaw::skills::Skill> {
let default_skills_dir = config.workspace_dir.join("skills");
if skills_dir == default_skills_dir {
// When using only the default workspace skills directory, use the
// config-aware loader which respects open_skills configuration.
if skills_dirs.len() == 1 && skills_dirs[0] == default_skills_dir {
return zeroclaw::skills::load_skills_with_config(&config.workspace_dir, config);
}
// Start with workspace skills, then filter out those from the default dir
// so they don't duplicate skills loaded from the configured directories.
let mut skills = zeroclaw::skills::load_skills_with_config(&config.workspace_dir, config);
skills.retain(|skill| {
skill
@@ -352,10 +393,24 @@ fn load_runtime_skills(config: &ZeroClawConfig, skills_dir: &Path) -> Vec<zerocl
.map(|location| !location.starts_with(&default_skills_dir))
.unwrap_or(true)
});
skills.extend(zeroclaw::skills::load_skills_from_directory(
skills_dir,
config.skills.allow_scripts,
));
for dir in skills_dirs {
if *dir == default_skills_dir {
continue;
}
skills.extend(zeroclaw::skills::load_skills_from_directory(
dir,
config.skills.allow_scripts,
));
let scene_skills_dir = resolve_scene_skills_dir_path(dir.clone());
if scene_skills_dir != *dir {
skills.extend(zeroclaw::skills::load_skills_from_directory(
&scene_skills_dir,
config.skills.allow_scripts,
));
}
}
skills
}

View File

@@ -1,9 +1,14 @@
mod engine;
mod profile;
mod scene_registry;
mod tool_policy;
pub use engine::{
is_zhihu_hotlist_task, is_zhihu_write_task, task_requests_zhihu_article_publish, RuntimeEngine,
};
pub use profile::RuntimeProfile;
pub use scene_registry::{
load_first_slice_scene_registry, load_scene_registry_from_root, match_scene_instruction,
match_scene_instruction_in_registry, DispatchMode, SceneRegistryEntry,
};
pub use tool_policy::ToolPolicy;

View File

@@ -0,0 +1,242 @@
use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde_json::{Map, Value};
const STAGED_SCENE_ROOT: &str = "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DispatchMode {
DirectBrowser,
AgentBrowser,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SceneRegistryEntry {
pub id: String,
pub name: String,
pub summary: String,
pub tags: Vec<String>,
pub inputs: Vec<String>,
pub outputs: Vec<String>,
pub skill_package: String,
pub skill_tool: String,
pub skill_artifact_type: String,
pub dispatch_mode: DispatchMode,
pub expected_domain: String,
pub aliases: Vec<String>,
pub default_args: Map<String, Value>,
}
#[derive(Debug, Deserialize)]
struct SceneMetadata {
id: String,
name: String,
summary: String,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
inputs: Vec<String>,
#[serde(default)]
outputs: Vec<String>,
skill: SceneSkillMetadata,
}
#[derive(Debug, Deserialize)]
struct SceneSkillMetadata {
package: String,
tool: String,
artifact_type: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct SceneMatchScore {
matched_terms: usize,
longest_term: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct SceneMatchResult {
score: SceneMatchScore,
has_strong_phrase_hit: bool,
}
#[derive(Debug, Clone)]
struct SceneRuntimePolicy {
scene_id: &'static str,
dispatch_mode: DispatchMode,
expected_domain: &'static str,
aliases: &'static [&'static str],
}
const FIRST_SLICE_POLICIES: [SceneRuntimePolicy; 2] = [
SceneRuntimePolicy {
scene_id: "fault-details-report",
dispatch_mode: DispatchMode::DirectBrowser,
expected_domain: "sgcc.example.invalid",
aliases: &["故障明细", "故障明细报表", "导出故障明细"],
},
SceneRuntimePolicy {
scene_id: "95598-repair-city-dispatch",
dispatch_mode: DispatchMode::AgentBrowser,
expected_domain: "95598.example.invalid",
aliases: &["95598抢修市指", "市指抢修监测", "95598抢修队列", "95598抢修市指监测"],
},
];
pub fn load_first_slice_scene_registry() -> Vec<SceneRegistryEntry> {
load_scene_registry_from_root(Path::new(STAGED_SCENE_ROOT))
}
pub fn load_scene_registry_from_root(root: &Path) -> Vec<SceneRegistryEntry> {
let mut registry = Vec::new();
for policy in FIRST_SLICE_POLICIES {
if let Some(entry) = load_scene_entry(root, &policy) {
registry.push(entry);
}
}
registry
}
pub fn match_scene_instruction(instruction: &str) -> Option<SceneRegistryEntry> {
let registry = load_first_slice_scene_registry();
match_scene_instruction_in_registry(&registry, instruction)
}
pub fn match_scene_instruction_in_registry(
registry: &[SceneRegistryEntry],
instruction: &str,
) -> Option<SceneRegistryEntry> {
let normalized_instruction = normalize_for_match(instruction);
if normalized_instruction.is_empty() {
return None;
}
let mut best_match: Option<(SceneMatchResult, &SceneRegistryEntry)> = None;
let mut ambiguous = false;
let mut strong_phrase_hits = 0;
for entry in registry {
let Some(result) = score_scene_instruction(entry, &normalized_instruction) else {
continue;
};
if result.has_strong_phrase_hit {
strong_phrase_hits += 1;
}
match &best_match {
None => {
best_match = Some((result, entry));
ambiguous = false;
}
Some((current_result, _)) if result.score > current_result.score => {
best_match = Some((result, entry));
ambiguous = false;
}
Some((current_result, current_entry)) if result.score == current_result.score => {
if result.has_strong_phrase_hit && current_result.has_strong_phrase_hit {
if current_entry.id != entry.id {
ambiguous = true;
}
} else if result.has_strong_phrase_hit && !current_result.has_strong_phrase_hit {
best_match = Some((result, entry));
ambiguous = false;
} else if current_result.has_strong_phrase_hit && !result.has_strong_phrase_hit {
ambiguous = false;
} else if current_entry.id != entry.id {
ambiguous = true;
}
}
Some(_) => {}
}
}
if ambiguous || strong_phrase_hits > 1 {
None
} else {
best_match.map(|(_, entry)| entry.clone())
}
}
fn load_scene_entry(root: &Path, policy: &SceneRuntimePolicy) -> Option<SceneRegistryEntry> {
let scene_path = scene_json_path(root, policy.scene_id);
let contents = fs::read_to_string(scene_path).ok()?;
let metadata: SceneMetadata = serde_json::from_str(&contents).ok()?;
if metadata.id != policy.scene_id {
return None;
}
Some(SceneRegistryEntry {
id: policy.scene_id.to_string(),
name: metadata.name,
summary: metadata.summary,
tags: metadata.tags,
inputs: metadata.inputs,
outputs: metadata.outputs,
skill_package: metadata.skill.package,
skill_tool: metadata.skill.tool,
skill_artifact_type: metadata.skill.artifact_type,
dispatch_mode: policy.dispatch_mode,
expected_domain: policy.expected_domain.to_string(),
aliases: policy.aliases.iter().map(|alias| (*alias).to_string()).collect(),
default_args: Map::new(),
})
}
fn scene_json_path(root: &Path, scene_id: &str) -> PathBuf {
root.join("scenes").join(scene_id).join("scene.json")
}
fn score_scene_instruction(
entry: &SceneRegistryEntry,
normalized_instruction: &str,
) -> Option<SceneMatchResult> {
let mut matched_terms = 0;
let mut longest_term = 0;
let mut has_strong_phrase_hit = false;
for term in candidate_match_terms(entry) {
let normalized_term = normalize_for_match(&term);
if normalized_term.len() < 2 {
continue;
}
if normalized_instruction.contains(&normalized_term) {
matched_terms += 1;
longest_term = longest_term.max(normalized_term.len());
if normalized_term.len() >= 6 {
has_strong_phrase_hit = true;
}
}
}
if matched_terms == 0 {
None
} else {
Some(SceneMatchResult {
score: SceneMatchScore {
matched_terms,
longest_term,
},
has_strong_phrase_hit,
})
}
}
fn candidate_match_terms(entry: &SceneRegistryEntry) -> Vec<String> {
let mut terms = Vec::new();
terms.push(entry.id.clone());
terms.push(entry.name.clone());
terms.push(entry.summary.clone());
terms.extend(entry.tags.iter().cloned());
terms.extend(entry.aliases.iter().cloned());
terms
}
fn normalize_for_match(value: &str) -> String {
value
.chars()
.filter(|ch| !ch.is_whitespace() && *ch != '-' && *ch != '_' && *ch != ':')
.flat_map(|ch| ch.to_lowercase())
.collect()
}