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

@@ -9,6 +9,7 @@ use crate::config::SgClawSettings;
use crate::pipe::{
AgentMessage, BrowserPipeTool, ConversationMessage, PipeError, Transport,
};
use crate::runtime::RuntimeEngine;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentRuntimeContext {
@@ -175,7 +176,7 @@ pub fn run_submit_task<T: Transport + 'static>(
let completion = match context.load_sgclaw_settings() {
Ok(Some(settings)) => {
let resolved_skills_dir =
let resolved_skills_dirs =
resolve_skills_dir_from_sgclaw_settings(&context.workspace_root, &settings);
let _ = sink.send(&AgentMessage::LogEntry {
level: "info".to_string(),
@@ -188,7 +189,7 @@ pub fn run_submit_task<T: Transport + 'static>(
});
let _ = sink.send(&AgentMessage::LogEntry {
level: "info".to_string(),
message: format!("skills dir resolved to {}", resolved_skills_dir.display()),
message: format!("skills dirs resolved to [{}]", resolved_skills_dirs.iter().map(|d| d.display().to_string()).collect::<Vec<_>>().join(", ")),
});
let _ = sink.send(&AgentMessage::LogEntry {
level: "info".to_string(),
@@ -197,11 +198,13 @@ pub fn run_submit_task<T: Transport + 'static>(
settings.runtime_profile, settings.skills_prompt_mode
),
});
if crate::compat::orchestration::should_use_primary_orchestration(
&instruction,
task_context.page_url.as_deref(),
task_context.page_title.as_deref(),
) {
if RuntimeEngine::new(settings.runtime_profile).browser_surface_enabled()
&& crate::compat::orchestration::should_use_primary_orchestration(
&instruction,
task_context.page_url.as_deref(),
task_context.page_title.as_deref(),
)
{
let _ = send_mode_log(sink, "zeroclaw_process_message_primary");
match crate::compat::orchestration::execute_task_with_sgclaw_settings(
transport,
@@ -307,7 +310,7 @@ pub fn run_submit_task_with_browser_backend<T: Transport + 'static>(
let completion = match context.load_sgclaw_settings() {
Ok(Some(settings)) => {
let resolved_skills_dir =
let resolved_skills_dirs =
resolve_skills_dir_from_sgclaw_settings(&context.workspace_root, &settings);
let _ = sink.send(&AgentMessage::LogEntry {
level: "info".to_string(),
@@ -320,7 +323,7 @@ pub fn run_submit_task_with_browser_backend<T: Transport + 'static>(
});
let _ = sink.send(&AgentMessage::LogEntry {
level: "info".to_string(),
message: format!("skills dir resolved to {}", resolved_skills_dir.display()),
message: format!("skills dirs resolved to [{}]", resolved_skills_dirs.iter().map(|d| d.display().to_string()).collect::<Vec<_>>().join(", ")),
});
let _ = sink.send(&AgentMessage::LogEntry {
level: "info".to_string(),
@@ -329,11 +332,13 @@ pub fn run_submit_task_with_browser_backend<T: Transport + 'static>(
settings.runtime_profile, settings.skills_prompt_mode
),
});
if crate::compat::orchestration::should_use_primary_orchestration(
&instruction,
task_context.page_url.as_deref(),
task_context.page_title.as_deref(),
) {
if RuntimeEngine::new(settings.runtime_profile).browser_surface_enabled()
&& crate::compat::orchestration::should_use_primary_orchestration(
&instruction,
task_context.page_url.as_deref(),
task_context.page_title.as_deref(),
)
{
let _ = send_mode_log(sink, "zeroclaw_process_message_primary");
match crate::compat::orchestration::execute_task_with_browser_backend(
sink,

View File

@@ -12,25 +12,31 @@ use zeroclaw::tools::{Tool, ToolResult};
use crate::browser::BrowserBackend;
use crate::pipe::Action;
pub struct BrowserScriptInvocation<'a> {
pub tool: &'a SkillTool,
pub skill_root: &'a Path,
}
pub struct BrowserScriptSkillTool {
tool_name: String,
tool_description: String,
script_path: PathBuf,
tool: SkillTool,
skill_root: PathBuf,
args: HashMap<String, String>,
browser_tool: Arc<dyn BrowserBackend>,
}
impl BrowserScriptSkillTool {
pub fn new(
skill_name: &str,
tool: &SkillTool,
skill_root: &Path,
browser_tool: Arc<dyn BrowserBackend>,
) -> anyhow::Result<Self> {
let script_path = skill_root.join(&tool.command);
let canonical_skill_root = skill_root
impl BrowserScriptInvocation<'_> {
fn script_path(&self) -> PathBuf {
self.skill_root.join(&self.tool.command)
}
fn canonical_script_path(&self) -> anyhow::Result<PathBuf> {
let script_path = self.script_path();
let canonical_skill_root = self
.skill_root
.canonicalize()
.unwrap_or_else(|_| skill_root.to_path_buf());
.unwrap_or_else(|_| self.skill_root.to_path_buf());
let canonical_script_path = script_path.canonicalize().map_err(|err| {
anyhow::anyhow!(
"failed to resolve browser script {}: {err}",
@@ -43,11 +49,25 @@ impl BrowserScriptSkillTool {
canonical_script_path.display()
);
}
Ok(canonical_script_path)
}
}
impl BrowserScriptSkillTool {
pub fn new(
skill_name: &str,
tool: &SkillTool,
skill_root: &Path,
browser_tool: Arc<dyn BrowserBackend>,
) -> anyhow::Result<Self> {
let invocation = BrowserScriptInvocation { tool, skill_root };
invocation.canonical_script_path()?;
Ok(Self {
tool_name: format!("{}.{}", skill_name, tool.name),
tool_description: tool.description.clone(),
script_path: canonical_script_path,
tool: tool.clone(),
skill_root: skill_root.to_path_buf(),
args: tool.args.clone(),
browser_tool,
})
@@ -99,81 +119,12 @@ impl Tool for BrowserScriptSkillTool {
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let mut args = match args {
Value::Object(args) => args,
other => {
return Ok(failed_tool_result(format!(
"expected object arguments, got {other}"
)))
}
};
let raw_expected_domain = match args.remove("expected_domain") {
Some(Value::String(value)) if !value.trim().is_empty() => value,
Some(other) => {
return Ok(failed_tool_result(format!(
"expected_domain must be a non-empty string, got {other}"
)))
}
None => {
return Ok(failed_tool_result(
"missing required field expected_domain".to_string(),
))
}
};
let expected_domain = match normalize_domain_like(&raw_expected_domain) {
Some(value) => value,
None => {
return Ok(failed_tool_result(format!(
"expected_domain must resolve to a hostname, got {raw_expected_domain:?}"
)))
}
};
for required_arg in self.args.keys() {
if !args.contains_key(required_arg) {
return Ok(failed_tool_result(format!(
"missing required field {required_arg}"
)));
}
}
let script_body = match fs::read_to_string(&self.script_path) {
Ok(value) => value,
Err(err) => {
return Ok(failed_tool_result(format!(
"failed to read browser script {}: {err}",
self.script_path.display()
)))
}
};
let wrapped_script = wrap_browser_script(&script_body, &Value::Object(args.clone()));
let result = match self.browser_tool.invoke(
Action::Eval,
json!({ "script": wrapped_script }),
&expected_domain,
) {
Ok(result) => result,
Err(err) => return Ok(failed_tool_result(err.to_string())),
};
if !result.success {
return Ok(failed_tool_result(format_browser_script_error(
&result.data,
)));
}
let payload = result
.data
.get("text")
.cloned()
.unwrap_or_else(|| result.data.clone());
Ok(ToolResult {
success: true,
output: stringify_tool_payload(&payload)?,
error: None,
})
execute_browser_script_impl(
&self.tool,
&self.skill_root,
self.browser_tool.clone(),
args,
)
}
}
@@ -211,6 +162,99 @@ pub fn build_browser_script_skill_tools(
Ok(tools)
}
pub async fn execute_browser_script_tool(
tool: &SkillTool,
skill_root: &Path,
browser_tool: Arc<dyn BrowserBackend>,
args: Value,
) -> anyhow::Result<ToolResult> {
execute_browser_script_impl(tool, skill_root, browser_tool, args)
}
fn execute_browser_script_impl(
tool: &SkillTool,
skill_root: &Path,
browser_tool: Arc<dyn BrowserBackend>,
args: Value,
) -> anyhow::Result<ToolResult> {
let invocation = BrowserScriptInvocation { tool, skill_root };
let script_path = invocation.canonical_script_path()?;
let mut args = match args {
Value::Object(args) => args,
other => {
return Ok(failed_tool_result(format!(
"expected object arguments, got {other}"
)))
}
};
let raw_expected_domain = match args.remove("expected_domain") {
Some(Value::String(value)) if !value.trim().is_empty() => value,
Some(other) => {
return Ok(failed_tool_result(format!(
"expected_domain must be a non-empty string, got {other}"
)))
}
None => {
return Ok(failed_tool_result(
"missing required field expected_domain".to_string(),
))
}
};
let expected_domain = match normalize_domain_like(&raw_expected_domain) {
Some(value) => value,
None => {
return Ok(failed_tool_result(format!(
"expected_domain must resolve to a hostname, got {raw_expected_domain:?}"
)))
}
};
for required_arg in tool.args.keys() {
if !args.contains_key(required_arg) {
return Ok(failed_tool_result(format!(
"missing required field {required_arg}"
)));
}
}
let script_body = match fs::read_to_string(&script_path) {
Ok(value) => value,
Err(err) => {
return Ok(failed_tool_result(format!(
"failed to read browser script {}: {err}",
script_path.display()
)))
}
};
let wrapped_script = wrap_browser_script(&script_body, &Value::Object(args.clone()));
let result = match browser_tool.invoke(
Action::Eval,
json!({ "script": wrapped_script }),
&expected_domain,
) {
Ok(result) => result,
Err(err) => return Ok(failed_tool_result(err.to_string())),
};
if !result.success {
return Ok(failed_tool_result(format_browser_script_error(&result.data)));
}
let payload = result
.data
.get("text")
.cloned()
.unwrap_or_else(|| result.data.clone());
Ok(ToolResult {
success: true,
output: stringify_tool_payload(&payload)?,
error: None,
})
}
fn wrap_browser_script(script_body: &str, args: &Value) -> String {
format!(
"(function() {{\nconst args = {};\n{}\n}})()",

View File

@@ -12,6 +12,7 @@ use crate::runtime::RuntimeProfile;
const SGCLAW_ZEROCLAW_WORKSPACE_DIR: &str = ".sgclaw-zeroclaw-workspace";
const SKILLS_DIR_NAME: &str = "skills";
const STAGED_SKILLS_DIR_NAME: &str = "skill_staging";
pub fn build_zeroclaw_config(
workspace_root: &Path,
@@ -87,15 +88,41 @@ pub fn zeroclaw_default_skills_dir(workspace_root: &Path) -> PathBuf {
zeroclaw_workspace_dir(workspace_root).join(SKILLS_DIR_NAME)
}
pub fn resolve_skills_dir(workspace_root: &Path, settings: &DeepSeekSettings) -> PathBuf {
resolve_skills_dir_path(workspace_root, settings.skills_dir.as_deref())
pub fn resolve_skills_dir(workspace_root: &Path, settings: &DeepSeekSettings) -> Vec<PathBuf> {
resolve_skills_dir_paths(workspace_root, &settings.skills_dir)
}
pub fn resolve_skills_dir_from_sgclaw_settings(
workspace_root: &Path,
settings: &SgClawSettings,
) -> PathBuf {
resolve_skills_dir_path(workspace_root, settings.skills_dir.as_deref())
) -> Vec<PathBuf> {
resolve_skills_dir_paths(workspace_root, &settings.skills_dir)
}
pub fn resolve_scene_skills_dir_from_sgclaw_settings(
workspace_root: &Path,
settings: &SgClawSettings,
) -> Vec<PathBuf> {
resolve_skills_dir_from_sgclaw_settings(workspace_root, settings)
.into_iter()
.flat_map(|dir| {
let scene_dir = resolve_scene_skills_dir_path(dir.clone());
if scene_dir != dir {
vec![dir, scene_dir]
} else {
vec![dir]
}
})
.collect()
}
pub fn resolve_scene_skills_dir_path(skills_dir: PathBuf) -> PathBuf {
let staged_skills_dir = skills_dir.join(STAGED_SKILLS_DIR_NAME).join(SKILLS_DIR_NAME);
if staged_skills_dir.is_dir() {
staged_skills_dir
} else {
skills_dir
}
}
fn normalize_configured_skills_dir(configured_dir: &Path) -> PathBuf {
@@ -111,8 +138,13 @@ fn normalize_configured_skills_dir(configured_dir: &Path) -> PathBuf {
}
}
fn resolve_skills_dir_path(workspace_root: &Path, configured_dir: Option<&Path>) -> PathBuf {
configured_dir
.map(normalize_configured_skills_dir)
.unwrap_or_else(|| zeroclaw_default_skills_dir(workspace_root))
fn resolve_skills_dir_paths(workspace_root: &Path, configured_dirs: &[PathBuf]) -> Vec<PathBuf> {
if configured_dirs.is_empty() {
vec![zeroclaw_default_skills_dir(workspace_root)]
} else {
configured_dirs
.iter()
.map(|d| normalize_configured_skills_dir(d))
.collect()
}
}

View File

@@ -146,7 +146,7 @@ pub async fn execute_task_with_provider(
instruction: &str,
task_context: &CompatTaskContext,
config: ZeroClawConfig,
skills_dir: PathBuf,
skills_dir: Vec<PathBuf>,
settings: SgClawSettings,
) -> Result<String, PipeError> {
let engine = RuntimeEngine::new(settings.runtime_profile);

View File

@@ -5,11 +5,15 @@ use std::thread;
use std::time::Duration;
use regex::Regex;
use reqwest::Url;
use serde_json::{json, Value};
use zeroclaw::skills::load_skills_from_directory;
use zeroclaw::tools::Tool;
use crate::browser::{BrowserBackend, PipeBrowserBackend};
use crate::compat::artifact_open::{open_exported_xlsx, open_local_dashboard, PostExportOpen};
use crate::compat::browser_script_skill_tool::execute_browser_script_tool;
use crate::compat::config_adapter::resolve_scene_skills_dir_from_sgclaw_settings;
use crate::compat::openxml_office_tool::OpenXmlOfficeTool;
use crate::compat::runtime::CompatTaskContext;
use crate::compat::screen_html_export_tool::ScreenHtmlExportTool;
@@ -23,17 +27,19 @@ const ZHIHU_EDITOR_DOMAIN: &str = "zhuanlan.zhihu.com";
const ZHIHU_HOT_URL: &str = "https://www.zhihu.com/hot";
const ZHIHU_CREATOR_URL: &str = "https://www.zhihu.com/creator";
const ZHIHU_EDITOR_URL: &str = "https://zhuanlan.zhihu.com/write";
const FAULT_DETAILS_SCENE_ID: &str = "fault-details-report";
const HOTLIST_READY_POLL_ATTEMPTS: usize = 10;
const HOTLIST_READY_POLL_INTERVAL: Duration = Duration::from_millis(500);
// Simplified readiness pattern: only checks that *some* heat metric exists
// (e.g. "3440万热度", "2.1亿"). The full rank-title-heat structure is validated
// later by the extraction script. Using a simple pattern avoids problems with
// the multi-line innerText format where rank, title, and heat are on separate
// lines (`.` does not cross newlines by default).
const EDITOR_READY_POLL_ATTEMPTS: usize = 12;
const EDITOR_READY_POLL_INTERVAL: Duration = Duration::from_millis(500);
// Readiness pattern: requires the "热度" suffix so that sidebar "大家都在搜"
// entries (which show bare "414万" without "热度") do NOT trigger a premature
// readiness signal. The main hotlist always renders "538万热度".
const HOTLIST_TEXT_READY_PATTERN: &str =
r"\d+(?:\.\d+)?\s*(?:万|亿|k|K|m|M)\s*(?:热度)?";
r"\d+(?:\.\d+)?\s*(?:万|亿|k|K|m|M)\s*热度";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkflowRoute {
FaultDetailsReport,
ZhihuHotlistExportXlsx,
ZhihuHotlistScreen,
ZhihuArticleEntry,
@@ -60,6 +66,13 @@ pub fn detect_route(
page_url: Option<&str>,
page_title: Option<&str>,
) -> Option<WorkflowRoute> {
if let Some(scene) = crate::runtime::match_scene_instruction(instruction) {
if scene.id == FAULT_DETAILS_SCENE_ID
&& matches!(scene.dispatch_mode, crate::runtime::DispatchMode::DirectBrowser)
{
return Some(WorkflowRoute::FaultDetailsReport);
}
}
if crate::runtime::is_zhihu_hotlist_task(instruction, page_url, page_title) {
let normalized = instruction.to_ascii_lowercase();
if normalized.contains("dashboard")
@@ -93,7 +106,8 @@ pub fn detect_route(
pub fn prefers_direct_execution(route: &WorkflowRoute) -> bool {
matches!(
route,
WorkflowRoute::ZhihuHotlistExportXlsx
WorkflowRoute::FaultDetailsReport
| WorkflowRoute::ZhihuHotlistExportXlsx
| WorkflowRoute::ZhihuHotlistScreen
| WorkflowRoute::ZhihuArticleEntry
| WorkflowRoute::ZhihuArticleDraft
@@ -119,7 +133,8 @@ pub fn should_fallback_after_summary(summary: &str, route: &WorkflowRoute) -> bo
looks_like_denial
|| matches!(
route,
WorkflowRoute::ZhihuHotlistExportXlsx
WorkflowRoute::FaultDetailsReport
| WorkflowRoute::ZhihuHotlistExportXlsx
| WorkflowRoute::ZhihuHotlistScreen
| WorkflowRoute::ZhihuArticleEntry
| WorkflowRoute::ZhihuArticleDraft
@@ -138,6 +153,13 @@ pub fn execute_route_with_browser_backend(
settings: &SgClawSettings,
) -> Result<String, PipeError> {
match route {
WorkflowRoute::FaultDetailsReport => execute_fault_details_route(
browser_backend.clone(),
instruction,
workspace_root,
settings,
task_context.page_url.as_deref(),
),
WorkflowRoute::ZhihuHotlistExportXlsx | WorkflowRoute::ZhihuHotlistScreen => {
let top_n = extract_top_n(instruction);
let items = collect_hotlist_items(transport, browser_backend.as_ref(), top_n, task_context)?;
@@ -210,6 +232,157 @@ pub fn execute_route<T: Transport + 'static>(
)
}
fn execute_fault_details_route(
browser_backend: Arc<dyn BrowserBackend>,
instruction: &str,
workspace_root: &Path,
settings: &SgClawSettings,
page_url: Option<&str>,
) -> Result<String, PipeError> {
let scene = crate::runtime::match_scene_instruction(instruction).ok_or_else(|| {
PipeError::Protocol("故障明细直连路由失败:未找到场景元数据。".to_string())
})?;
if scene.id != FAULT_DETAILS_SCENE_ID {
return Err(PipeError::Protocol(format!(
"故障明细直连路由失败场景不匹配got {}",
scene.id
)));
}
let period = derive_fault_details_period(instruction).ok_or_else(|| {
PipeError::Protocol(
"故障明细直连路由失败:无法从当前指令安全推导必填参数 period请明确提供例如“导出 2026-04 故障明细”。"
.to_string(),
)
})?;
let skills_dirs = resolve_scene_skills_dir_from_sgclaw_settings(workspace_root, settings);
let skill = skills_dirs
.iter()
.flat_map(|dir| load_skills_from_directory(dir, true))
.find(|skill| skill.name == scene.skill_package)
.ok_or_else(|| {
PipeError::Protocol(format!(
"故障明细直连路由失败:未找到技能包 {} in [{}]",
scene.skill_package,
skills_dirs.iter().map(|d| d.display().to_string()).collect::<Vec<_>>().join(", ")
))
})?;
let skill_root = skill
.location
.as_deref()
.and_then(Path::parent)
.ok_or_else(|| {
PipeError::Protocol(format!(
"故障明细直连路由失败:技能包 {} 缺少有效位置元数据",
scene.skill_package
))
})?;
let tool = skill
.tools
.iter()
.find(|tool| tool.name == scene.skill_tool)
.ok_or_else(|| {
PipeError::Protocol(format!(
"故障明细直连路由失败:技能包 {} 缺少工具 {}",
scene.skill_package, scene.skill_tool
))
})?;
if tool.kind != "browser_script" {
return Err(PipeError::Protocol(format!(
"故障明细直连路由失败:工具 {} 必须是 browser_script当前为 {}",
scene.skill_tool, tool.kind
)));
}
let expected_domain = fault_details_expected_domain(page_url, &scene.expected_domain)
.ok_or_else(|| {
PipeError::Protocol(
"故障明细直连路由失败:无法从当前页面上下文解析可用域名。".to_string(),
)
})?;
let runtime = tokio::runtime::Runtime::new()
.map_err(|err| PipeError::Protocol(format!("failed to create tokio runtime: {err}")))?;
let result = runtime
.block_on(execute_browser_script_tool(
tool,
skill_root,
browser_backend,
json!({
"expected_domain": expected_domain,
"period": period,
}),
))
.map_err(|err| PipeError::Protocol(err.to_string()))?;
if !result.success {
return Err(PipeError::Protocol(
result
.error
.unwrap_or_else(|| "fault-details-report browser script failed".to_string()),
));
}
Ok(result.output)
}
fn fault_details_expected_domain(page_url: Option<&str>, fallback: &str) -> Option<String> {
page_url
.and_then(host_from_url)
.or_else(|| host_from_url(fallback))
}
fn host_from_url(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
if let Ok(url) = Url::parse(trimmed) {
return url.host_str().map(|host| host.to_ascii_lowercase());
}
let host = trimmed
.trim_start_matches("https://")
.trim_start_matches("http://")
.split(['/', '?', '#'])
.next()
.unwrap_or_default()
.split(':')
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
(!host.is_empty()).then_some(host)
}
fn derive_fault_details_period(instruction: &str) -> Option<String> {
let month_re = Regex::new(r"(20\d{2})[-/年](0?[1-9]|1[0-2])").expect("valid fault details month regex");
let derived = month_re.captures_iter(instruction).find_map(|capture| {
let matched = capture.get(0)?;
let before_is_digit = instruction[..matched.start()]
.chars()
.next_back()
.is_some_and(|ch| ch.is_ascii_digit());
let after_is_digit = instruction[matched.end()..]
.chars()
.next()
.is_some_and(|ch| ch.is_ascii_digit());
if before_is_digit || after_is_digit {
return None;
}
let year = capture.get(1).map(|m| m.as_str()).unwrap_or_default();
let month = capture
.get(2)
.and_then(|m| m.as_str().parse::<u32>().ok())
.unwrap_or(1);
Some(format!("{year}-{month:02}"))
});
derived
}
fn collect_hotlist_items(
transport: &dyn crate::agent::AgentEventSink,
browser_tool: &dyn BrowserBackend,
@@ -258,10 +431,16 @@ fn ensure_hotlist_page_ready(
.as_deref()
.is_some_and(|title| title.contains("热榜"));
if starts_on_hotlist && poll_for_hotlist_readiness(browser_tool)? {
return Ok(None);
}
// Always validate via probe_hotlist_extractor rather than returning
// Ok(None) on a bare readiness pass. The readiness poll uses getText(body)
// which can be triggered by sidebar / nav-bar content before the main
// hotlist DOM has rendered. probe_hotlist_extractor runs the full
// extraction script and returns None when no valid rows are found,
// allowing the retry loop to kick in.
if starts_on_hotlist {
// Best-effort wait for content to appear; ignore the boolean result
// we always follow up with the probe.
let _ = poll_for_hotlist_readiness(browser_tool);
if let Some(items) = probe_hotlist_extractor(transport, browser_tool, top_n)? {
return Ok(Some(items));
}
@@ -270,19 +449,77 @@ fn ensure_hotlist_page_ready(
let mut last_error = None;
for attempt in 0..2 {
navigate_hotlist_page(transport, browser_tool)?;
if poll_for_hotlist_readiness(browser_tool)? {
return Ok(None);
}
let _ = poll_for_hotlist_readiness(browser_tool);
if let Some(items) = probe_hotlist_extractor(transport, browser_tool, top_n)? {
return Ok(Some(items));
}
last_error = Some(PipeError::Protocol(format!(
last_error = Some(format!(
"知乎热榜页面已打开但在短轮询窗口内仍未出现可读热榜内容attempt={}",
attempt + 1
)));
));
}
Err(last_error.unwrap_or_else(|| PipeError::Protocol("知乎热榜页面未就绪".to_string())))
// Log the last failure for diagnostics, then let caller try one final
// extraction as a last resort.
if let Some(msg) = last_error {
transport.send(&AgentMessage::LogEntry {
level: "warn".to_string(),
message: msg,
}).ok();
}
Ok(None)
}
/// Poll the Zhihu write page until `prepare_article_editor.js` reports
/// "editor_ready" or a terminal state (login_required). The editor page
/// is a React SPA whose title textarea and Draft.js body take noticeable
/// time to mount after navigation, so a single immediate check frequently
/// reports "editor_unavailable".
fn poll_for_editor_readiness(
browser_tool: &dyn BrowserBackend,
desired_mode: &str,
) -> Result<Value, PipeError> {
let args = json!({ "desired_mode": desired_mode });
let mut last_state: Option<Value> = None;
for attempt in 0..EDITOR_READY_POLL_ATTEMPTS {
match execute_browser_skill_script(
browser_tool,
"zhihu-write",
"prepare_article_editor.js",
args.clone(),
ZHIHU_EDITOR_DOMAIN,
) {
Ok(state) => {
let status = payload_status(&state);
if status == Some("editor_ready") || status == Some("login_required") {
return Ok(state);
}
last_state = Some(state);
}
Err(PipeError::PipeClosed) => return Err(PipeError::PipeClosed),
Err(_) => {
// Script may fail while the page is still navigating; tolerate.
}
}
if attempt + 1 < EDITOR_READY_POLL_ATTEMPTS {
thread::sleep(EDITOR_READY_POLL_INTERVAL);
}
}
// Return the last observed state so the caller can surface the
// "editor_unavailable" message; or make one final attempt.
match last_state {
Some(state) => Ok(state),
None => execute_browser_skill_script(
browser_tool,
"zhihu-write",
"prepare_article_editor.js",
args,
ZHIHU_EDITOR_DOMAIN,
),
}
}
fn probe_hotlist_extractor(
@@ -516,12 +753,9 @@ fn execute_zhihu_article_route(
level: "info".to_string(),
message: "call zhihu-write.prepare_article_editor".to_string(),
})?;
let editor_state = execute_browser_skill_script(
let editor_state = poll_for_editor_readiness(
browser_tool,
"zhihu-write",
"prepare_article_editor.js",
json!({ "desired_mode": if publish_mode { "publish" } else { "draft" } }),
ZHIHU_EDITOR_DOMAIN,
if publish_mode { "publish" } else { "draft" },
)?;
if is_login_required_payload(&editor_state) {
return Ok(build_login_block_message(payload_current_url(
@@ -669,12 +903,9 @@ fn execute_zhihu_article_entry_route(
level: "info".to_string(),
message: "call zhihu-write.prepare_article_editor".to_string(),
})?;
let editor_state = execute_browser_skill_script(
let editor_state = poll_for_editor_readiness(
browser_tool,
"zhihu-write",
"prepare_article_editor.js",
json!({ "desired_mode": "draft" }),
ZHIHU_EDITOR_DOMAIN,
"draft",
)?;
if is_login_required_payload(&editor_state) {
return Ok(build_login_block_message(payload_current_url(
@@ -1044,7 +1275,7 @@ mod tests {
"test-key".to_string(),
"http://127.0.0.1:9".to_string(),
"deepseek-chat".to_string(),
None,
Vec::new(),
)
.unwrap()
}

View File

@@ -1,6 +1,7 @@
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde::de;
use thiserror::Error;
use crate::runtime::RuntimeProfile;
@@ -105,7 +106,7 @@ pub struct DeepSeekSettings {
pub api_key: String,
pub base_url: String,
pub model: String,
pub skills_dir: Option<PathBuf>,
pub skills_dir: Vec<PathBuf>,
}
impl DeepSeekSettings {
@@ -124,7 +125,7 @@ pub struct SgClawSettings {
pub provider_api_key: String,
pub provider_base_url: String,
pub provider_model: String,
pub skills_dir: Option<PathBuf>,
pub skills_dir: Vec<PathBuf>,
pub skills_prompt_mode: SkillsPromptMode,
pub runtime_profile: RuntimeProfile,
pub planner_mode: PlannerMode,
@@ -155,7 +156,7 @@ impl SgClawSettings {
api_key: String,
base_url: String,
model: String,
skills_dir: Option<PathBuf>,
skills_dir: Vec<PathBuf>,
) -> Result<Self, ConfigError> {
Self::new(
api_key,
@@ -198,7 +199,7 @@ impl SgClawSettings {
api_key,
base_url,
model,
None,
Vec::new(),
None,
None,
None,
@@ -283,7 +284,7 @@ impl SgClawSettings {
config.api_key,
config.base_url,
config.model,
resolve_configured_skills_dir(config.skills_dir, config_dir),
resolve_configured_skills_dirs(config.skills_dir, config_dir),
skills_prompt_mode,
runtime_profile,
planner_mode,
@@ -301,7 +302,7 @@ impl SgClawSettings {
api_key: String,
base_url: String,
model: String,
skills_dir: Option<PathBuf>,
skills_dir: Vec<PathBuf>,
skills_prompt_mode: Option<SkillsPromptMode>,
runtime_profile: Option<RuntimeProfile>,
planner_mode: Option<PlannerMode>,
@@ -432,18 +433,18 @@ fn parse_office_backend(raw: &str) -> Result<OfficeBackend, String> {
}
}
fn resolve_configured_skills_dir(raw: Option<String>, config_dir: &Path) -> Option<PathBuf> {
let trimmed = raw
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())?;
let path = PathBuf::from(trimmed);
if path.is_absolute() {
Some(path)
} else {
Some(config_dir.join(path))
}
fn resolve_configured_skills_dirs(raw: Vec<String>, config_dir: &Path) -> Vec<PathBuf> {
raw.into_iter()
.filter(|s| !s.trim().is_empty())
.map(|s| {
let path = PathBuf::from(s.trim());
if path.is_absolute() {
path
} else {
config_dir.join(path)
}
})
.collect()
}
fn normalize_required_value(field: &'static str, raw: String) -> Result<String, ConfigError> {
@@ -485,6 +486,49 @@ fn normalize_enum_token(raw: &str) -> String {
.to_ascii_lowercase()
}
fn deserialize_skills_dirs<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: de::Deserializer<'de>,
{
struct StringOrVec;
impl<'de> de::Visitor<'de> for StringOrVec {
type Value = Vec<String>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string or array of strings")
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<Vec<String>, E> {
if value.trim().is_empty() {
Ok(Vec::new())
} else {
Ok(vec![value.to_string()])
}
}
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<String>, A::Error> {
let mut dirs = Vec::new();
while let Some(value) = seq.next_element::<String>()? {
if !value.trim().is_empty() {
dirs.push(value);
}
}
Ok(dirs)
}
fn visit_none<E: de::Error>(self) -> Result<Vec<String>, E> {
Ok(Vec::new())
}
fn visit_unit<E: de::Error>(self) -> Result<Vec<String>, E> {
Ok(Vec::new())
}
}
deserializer.deserialize_any(StringOrVec)
}
#[derive(Debug, Deserialize)]
struct RawSgClawSettings {
#[serde(rename = "apiKey", default)]
@@ -493,8 +537,8 @@ struct RawSgClawSettings {
base_url: String,
#[serde(default)]
model: String,
#[serde(rename = "skillsDir", alias = "skills_dir", default)]
skills_dir: Option<String>,
#[serde(rename = "skillsDir", alias = "skills_dir", default, deserialize_with = "deserialize_skills_dirs")]
skills_dir: Vec<String>,
#[serde(rename = "skillsPromptMode", alias = "skills_prompt_mode", default)]
skills_prompt_mode: Option<String>,
#[serde(rename = "runtimeProfile", alias = "runtime_profile", default)]

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()
}