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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
242
src/runtime/scene_registry.rs
Normal file
242
src/runtime/scene_registry.rs
Normal 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(®istry, 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()
|
||||
}
|
||||
Reference in New Issue
Block a user