feat: add config-owned direct skill submit path

Add fixed direct-submit skill loading from configured staged skills and validate directSubmitSkill early so malformed configs fail before routing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
木炎
2026-04-09 19:02:30 +08:00
parent 2ae71fb1c9
commit 4becf81066
11 changed files with 1962 additions and 97 deletions

View File

@@ -1,11 +1,19 @@
mod common;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use uuid::Uuid;
use common::MockTransport;
use sgclaw::agent::handle_browser_message;
use sgclaw::agent::{
handle_browser_message, handle_browser_message_with_context, AgentRuntimeContext,
};
use sgclaw::agent::runtime::{browser_action_tool_definition, execute_task_with_provider};
use sgclaw::compat::runtime::CompatTaskContext;
use sgclaw::config::SgClawSettings;
use sgclaw::llm::{ChatMessage, LlmError, LlmProvider, ToolDefinition, ToolFunctionCall};
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
use sgclaw::security::MacPolicy;
@@ -24,20 +32,346 @@ impl LlmProvider for FakeProvider {
}
}
fn test_policy() -> MacPolicy {
fn provider_path_test_policy() -> MacPolicy {
policy_for_domains(&["www.baidu.com"])
}
fn direct_runtime_test_policy() -> MacPolicy {
policy_for_domains(&["95598.sgcc.com.cn"])
}
fn policy_for_domains(domains: &[&str]) -> MacPolicy {
MacPolicy::from_json_str(
r#"{
&serde_json::json!({
"version": "1.0",
"domains": { "allowed": ["www.baidu.com"] },
"domains": { "allowed": domains },
"pipe_actions": {
"allowed": ["click", "type", "navigate", "getText"],
"allowed": ["click", "type", "navigate", "getText", "eval"],
"blocked": []
}
}"#,
})
.to_string(),
)
.unwrap()
}
fn build_direct_runtime_skill_root() -> PathBuf {
let root = std::env::temp_dir().join(format!(
"sgclaw-agent-runtime-skill-root-{}",
Uuid::new_v4()
));
let skill_dir = root.join("fault-details-report");
let script_dir = skill_dir.join("scripts");
fs::create_dir_all(&script_dir).unwrap();
fs::write(
skill_dir.join("SKILL.toml"),
r#"
[skill]
name = "fault-details-report"
description = "Collect 95598 fault detail data via browser eval."
version = "0.1.0"
[[tools]]
name = "collect_fault_details"
description = "Collect structured fault detail rows for a specific period."
kind = "browser_script"
command = "scripts/collect_fault_details.js"
[tools.args]
period = "YYYY-MM period to collect."
"#,
)
.unwrap();
fs::write(
script_dir.join("collect_fault_details.js"),
r#"
return {
fault_type: "outage",
observed_at: `${args.period}-15 09:00`,
affected_scope: "line-7",
expected_domain: args.expected_domain,
artifact_payload: "report artifact payload"
};
"#,
)
.unwrap();
root
}
fn write_direct_submit_config(workspace_root: &std::path::Path, skill_root: &std::path::Path) -> PathBuf {
let config_path = workspace_root.join("sgclaw_config.json");
fs::write(
&config_path,
serde_json::json!({
"providers": [],
"skillsDir": skill_root,
"directSubmitSkill": "fault-details-report.collect_fault_details"
})
.to_string(),
)
.unwrap();
config_path
}
fn direct_submit_runtime_context(skill_root: &std::path::Path) -> AgentRuntimeContext {
let workspace_root = std::env::temp_dir().join(format!(
"sgclaw-agent-runtime-workspace-{}",
Uuid::new_v4()
));
fs::create_dir_all(&workspace_root).unwrap();
let config_path = write_direct_submit_config(&workspace_root, skill_root);
AgentRuntimeContext::new(Some(config_path), workspace_root)
}
fn submit_fault_details_message() -> BrowserMessage {
BrowserMessage::SubmitTask {
instruction: "请采集 2026-03 的故障明细并返回结果".to_string(),
conversation_id: String::new(),
messages: vec![],
page_url: "https://95598.sgcc.com.cn/".to_string(),
page_title: "网上国网".to_string(),
}
}
fn direct_submit_mode_logs(sent: &[AgentMessage]) -> Vec<String> {
sent.iter()
.filter_map(|message| match message {
AgentMessage::LogEntry { level, message } if level == "mode" => Some(message.clone()),
_ => None,
})
.collect()
}
fn direct_submit_completion(sent: &[AgentMessage]) -> Option<(bool, String)> {
sent.iter().find_map(|message| match message {
AgentMessage::TaskComplete { success, summary } => Some((*success, summary.clone())),
_ => None,
})
}
fn success_browser_response(seq: u64, data: serde_json::Value) -> BrowserMessage {
BrowserMessage::Response {
seq,
success: true,
data,
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 10,
},
}
}
#[test]
fn direct_submit_runtime_executes_fault_details_skill_without_provider_path() {
let skill_root = build_direct_runtime_skill_root();
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
1,
serde_json::json!({
"text": {
"fault_type": "outage",
"observed_at": "2026-03-15 09:00",
"affected_scope": "line-7"
}
}),
)]));
let browser_tool = BrowserPipeTool::new(
transport.clone(),
direct_runtime_test_policy(),
vec![1, 2, 3, 4, 5, 6, 7, 8],
)
.with_response_timeout(Duration::from_secs(1));
let mut settings = SgClawSettings::from_legacy_deepseek_fields(
"unused-key".to_string(),
"http://127.0.0.1:9".to_string(),
"unused-model".to_string(),
Some(skill_root.clone()),
)
.unwrap();
settings.direct_submit_skill = Some("fault-details-report.collect_fault_details".to_string());
let summary = sgclaw::compat::direct_skill_runtime::execute_direct_submit_skill(
browser_tool,
"请采集 2026-03 的故障明细并返回结果",
&CompatTaskContext {
page_url: Some("https://95598.sgcc.com.cn/".to_string()),
..CompatTaskContext::default()
},
PathBuf::from(env!("CARGO_MANIFEST_DIR")).as_path(),
&settings,
)
.unwrap();
assert!(summary.contains("fault_type"));
let sent = transport.sent_messages();
assert!(sent.iter().all(|message| !matches!(message, AgentMessage::LogEntry { level, message } if level == "info" && message.contains("DeepSeek config loaded"))));
assert!(matches!(
&sent[0],
AgentMessage::Command {
seq,
action,
params,
security,
} if *seq == 1
&& action == &Action::Eval
&& security.expected_domain == "95598.sgcc.com.cn"
&& params["script"].as_str().is_some_and(|script| script.contains("2026-03"))
));
}
#[test]
fn submit_task_uses_direct_skill_mode_without_llm_configuration() {
std::env::remove_var("DEEPSEEK_API_KEY");
std::env::remove_var("DEEPSEEK_BASE_URL");
std::env::remove_var("DEEPSEEK_MODEL");
let skill_root = build_direct_runtime_skill_root();
let runtime_context = direct_submit_runtime_context(&skill_root);
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
1,
serde_json::json!({
"text": {
"fault_type": "outage",
"observed_at": "2026-03-15 09:00",
"affected_scope": "line-7",
"artifact_payload": "report artifact payload"
}
}),
)]));
let browser_tool = BrowserPipeTool::new(
transport.clone(),
direct_runtime_test_policy(),
vec![1, 2, 3, 4, 5, 6, 7, 8],
)
.with_response_timeout(Duration::from_secs(1));
handle_browser_message_with_context(
transport.as_ref(),
&browser_tool,
&runtime_context,
submit_fault_details_message(),
)
.unwrap();
let sent = transport.sent_messages();
let completion = direct_submit_completion(&sent).expect("task completion");
assert!(completion.0, "expected direct submit task to succeed: {sent:?}");
assert!(
completion.1.contains("report artifact payload"),
"expected report artifact payload in summary: {}",
completion.1
);
assert!(
!completion.1.contains("未配置大语言模型"),
"did not expect missing-llm summary: {}",
completion.1
);
}
#[test]
fn submit_task_rejects_invalid_direct_submit_skill_config_before_routing() {
std::env::remove_var("DEEPSEEK_API_KEY");
std::env::remove_var("DEEPSEEK_BASE_URL");
std::env::remove_var("DEEPSEEK_MODEL");
let skill_root = build_direct_runtime_skill_root();
let workspace_root = std::env::temp_dir().join(format!(
"sgclaw-invalid-direct-submit-workspace-{}",
Uuid::new_v4()
));
fs::create_dir_all(&workspace_root).unwrap();
let config_path = workspace_root.join("sgclaw_config.json");
fs::write(
&config_path,
serde_json::json!({
"providers": [],
"skillsDir": skill_root,
"directSubmitSkill": "fault-details-report"
})
.to_string(),
)
.unwrap();
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root);
let transport = Arc::new(MockTransport::new(vec![]));
let browser_tool = BrowserPipeTool::new(
transport.clone(),
direct_runtime_test_policy(),
vec![1, 2, 3, 4, 5, 6, 7, 8],
)
.with_response_timeout(Duration::from_secs(1));
handle_browser_message_with_context(
transport.as_ref(),
&browser_tool,
&runtime_context,
submit_fault_details_message(),
)
.unwrap();
let sent = transport.sent_messages();
assert!(matches!(
sent.last(),
Some(AgentMessage::TaskComplete { success, summary })
if !success && summary.contains("skill.tool")
));
assert!(direct_submit_mode_logs(&sent).is_empty());
assert!(!sent.iter().any(|message| matches!(message, AgentMessage::Command { .. })));
}
#[test]
fn direct_skill_mode_logs_direct_skill_primary() {
std::env::remove_var("DEEPSEEK_API_KEY");
std::env::remove_var("DEEPSEEK_BASE_URL");
std::env::remove_var("DEEPSEEK_MODEL");
let skill_root = build_direct_runtime_skill_root();
let runtime_context = direct_submit_runtime_context(&skill_root);
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
1,
serde_json::json!({
"text": {
"fault_type": "outage",
"observed_at": "2026-03-15 09:00",
"affected_scope": "line-7",
"artifact_payload": "report artifact payload"
}
}),
)]));
let browser_tool = BrowserPipeTool::new(
transport.clone(),
direct_runtime_test_policy(),
vec![1, 2, 3, 4, 5, 6, 7, 8],
)
.with_response_timeout(Duration::from_secs(1));
handle_browser_message_with_context(
transport.as_ref(),
&browser_tool,
&runtime_context,
submit_fault_details_message(),
)
.unwrap();
let sent = transport.sent_messages();
let mode_logs = direct_submit_mode_logs(&sent);
assert_eq!(mode_logs, vec!["direct_skill_primary".to_string()]);
assert!(
!mode_logs.iter().any(|mode| mode == "compat_llm_primary"),
"unexpected compat mode logs: {mode_logs:?}"
);
assert!(
!mode_logs
.iter()
.any(|mode| mode == "zeroclaw_process_message_primary"),
"unexpected zeroclaw mode logs: {mode_logs:?}"
);
}
#[test]
fn browser_action_tool_definition_uses_expected_name() {
let tool = browser_action_tool_definition();
@@ -73,7 +407,7 @@ fn runtime_executes_provider_tool_calls_and_returns_summary() {
]));
let browser_tool = BrowserPipeTool::new(
transport.clone(),
test_policy(),
provider_path_test_policy(),
vec![1, 2, 3, 4, 5, 6, 7, 8],
)
.with_response_timeout(Duration::from_secs(1));
@@ -148,7 +482,7 @@ fn production_submit_task_does_not_route_into_legacy_runtime_without_llm_config(
let transport = Arc::new(MockTransport::new(vec![]));
let browser_tool = BrowserPipeTool::new(
transport.clone(),
test_policy(),
provider_path_test_policy(),
vec![1, 2, 3, 4, 5, 6, 7, 8],
)
.with_response_timeout(Duration::from_secs(1));

View File

@@ -9,7 +9,9 @@ use std::time::{SystemTime, UNIX_EPOCH};
use common::MockTransport;
use serde_json::json;
use sgclaw::compat::browser_script_skill_tool::BrowserScriptSkillTool;
use sgclaw::compat::browser_script_skill_tool::{
execute_browser_script_tool, BrowserScriptSkillTool,
};
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
use sgclaw::security::MacPolicy;
use zeroclaw::skills::SkillTool;
@@ -29,6 +31,174 @@ fn test_policy() -> MacPolicy {
.unwrap()
}
#[tokio::test]
async fn execute_browser_script_tool_runs_packaged_script_with_expected_domain() {
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper");
let scripts_dir = skill_dir.join("scripts");
fs::create_dir_all(&scripts_dir).unwrap();
fs::write(
scripts_dir.join("extract_hotlist.js"),
"return { wrapped_args: args, source: \"packaged script\" };\n",
)
.unwrap();
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
seq: 1,
success: true,
data: json!({
"text": {
"sheet_name": "知乎热榜",
"rows": [[1, "标题", "10条"]]
}
}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 5,
},
}]));
let browser_tool = BrowserPipeTool::new(
transport.clone(),
test_policy(),
vec![1, 2, 3, 4, 5, 6, 7, 8],
)
.with_response_timeout(Duration::from_secs(1));
let mut tool_args = HashMap::new();
tool_args.insert("top_n".to_string(), "How many rows to extract".to_string());
let skill_tool = SkillTool {
name: "extract_hotlist".to_string(),
description: "Extract structured hotlist rows".to_string(),
kind: "browser_script".to_string(),
command: "scripts/extract_hotlist.js".to_string(),
args: tool_args,
};
let result = execute_browser_script_tool(
&skill_tool,
&skill_dir,
browser_tool,
json!({
"expected_domain": "https://WWW.ZHIHU.COM/hot?foo=bar",
"top_n": "10"
}),
)
.await
.unwrap();
let sent = transport.sent_messages();
assert!(result.success);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&result.output).unwrap(),
json!({
"sheet_name": "知乎热榜",
"rows": [[1, "标题", "10条"]]
})
);
assert!(matches!(
&sent[0],
AgentMessage::Command {
action,
params,
security,
..
} if action == &Action::Eval
&& security.expected_domain == "www.zhihu.com"
&& params["script"].as_str().unwrap().contains("const args = {\"top_n\":\"10\"};")
&& params["script"].as_str().unwrap().contains("source: \"packaged script\"")
));
}
#[tokio::test]
async fn execute_browser_script_tool_rejects_non_browser_script_tool_kind() {
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper-invalid-kind");
let scripts_dir = skill_dir.join("scripts");
fs::create_dir_all(&scripts_dir).unwrap();
fs::write(scripts_dir.join("extract_hotlist.js"), "return 'unused';\n").unwrap();
let transport = Arc::new(MockTransport::new(vec![]));
let browser_tool = BrowserPipeTool::new(
transport.clone(),
test_policy(),
vec![1, 2, 3, 4, 5, 6, 7, 8],
)
.with_response_timeout(Duration::from_secs(1));
let mut tool_args = HashMap::new();
tool_args.insert("top_n".to_string(), "How many rows to extract".to_string());
let skill_tool = SkillTool {
name: "extract_hotlist".to_string(),
description: "Extract structured hotlist rows".to_string(),
kind: "shell".to_string(),
command: "scripts/extract_hotlist.js".to_string(),
args: tool_args,
};
let result = execute_browser_script_tool(
&skill_tool,
&skill_dir,
browser_tool,
json!({
"expected_domain": "www.zhihu.com",
"top_n": "10"
}),
)
.await
.unwrap();
assert!(!result.success);
assert_eq!(
result.error.as_deref(),
Some("browser script tool kind must be browser_script, got shell")
);
assert!(transport.sent_messages().is_empty());
}
#[tokio::test]
async fn execute_browser_script_tool_rejects_missing_expected_domain() {
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper-invalid-domain");
let scripts_dir = skill_dir.join("scripts");
fs::create_dir_all(&scripts_dir).unwrap();
fs::write(scripts_dir.join("extract_hotlist.js"), "return 'unused';\n").unwrap();
let transport = Arc::new(MockTransport::new(vec![]));
let browser_tool = BrowserPipeTool::new(
transport.clone(),
test_policy(),
vec![1, 2, 3, 4, 5, 6, 7, 8],
)
.with_response_timeout(Duration::from_secs(1));
let mut tool_args = HashMap::new();
tool_args.insert("top_n".to_string(), "How many rows to extract".to_string());
let skill_tool = SkillTool {
name: "extract_hotlist".to_string(),
description: "Extract structured hotlist rows".to_string(),
kind: "browser_script".to_string(),
command: "scripts/extract_hotlist.js".to_string(),
args: tool_args,
};
let result = execute_browser_script_tool(
&skill_tool,
&skill_dir,
browser_tool,
json!({
"expected_domain": " ",
"top_n": "10"
}),
)
.await
.unwrap();
assert!(!result.success);
assert_eq!(
result.error.as_deref(),
Some("expected_domain must be a non-empty string, got \" \"")
);
assert!(transport.sent_messages().is_empty());
}
#[tokio::test]
async fn browser_script_skill_tool_executes_packaged_script_via_eval() {
let skill_dir = unique_temp_dir("sgclaw-browser-script-skill");
@@ -111,6 +281,87 @@ return {
));
}
#[tokio::test]
async fn browser_script_skill_tool_executes_script_directly_under_skill_root() {
let skill_root = unique_temp_dir("sgclaw-browser-script-direct-root");
let script_name = "extract_hotlist_direct.js";
let script_path = skill_root.join(script_name);
fs::write(
&script_path,
r#"
return {
sheet_name: "知乎热榜",
rows: [[1, "标题", args.top_n]]
};
"#,
)
.unwrap();
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
seq: 1,
success: true,
data: json!({
"text": {
"sheet_name": "知乎热榜",
"rows": [[1, "标题", "10条"]]
}
}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 5,
},
}]));
let browser_tool = BrowserPipeTool::new(
transport.clone(),
test_policy(),
vec![1, 2, 3, 4, 5, 6, 7, 8],
)
.with_response_timeout(Duration::from_secs(1));
let mut args = HashMap::new();
args.insert("top_n".to_string(), "How many rows to extract".to_string());
let skill_tool = SkillTool {
name: "extract_hotlist".to_string(),
description: "Extract structured hotlist rows".to_string(),
kind: "browser_script".to_string(),
command: script_name.to_string(),
args,
};
let tool = BrowserScriptSkillTool::new("zhihu-hotlist", &skill_tool, &skill_root, browser_tool)
.unwrap();
let result = tool
.execute(json!({
"expected_domain": "https://www.zhihu.com/hot",
"top_n": "10条"
}))
.await
.unwrap();
let sent = transport.sent_messages();
assert!(result.success);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&result.output).unwrap(),
json!({
"sheet_name": "知乎热榜",
"rows": [[1, "标题", "10条"]]
})
);
assert!(matches!(
&sent[0],
AgentMessage::Command {
action,
params,
security,
..
} if action == &Action::Eval
&& security.expected_domain == "www.zhihu.com"
&& params["script"].as_str().unwrap().contains("const args = {\"top_n\":\"10条\"};")
&& params["script"].as_str().unwrap().contains("rows: [[1, \"标题\", args.top_n]]")
));
}
fn unique_temp_dir(prefix: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)

View File

@@ -161,6 +161,60 @@ fn sgclaw_settings_default_to_compact_skills_and_browser_attached_profile() {
assert_eq!(settings.skills_prompt_mode, SkillsPromptMode::Compact);
}
#[test]
fn sgclaw_settings_load_direct_submit_only_config_and_resolve_relative_skills_dir() {
let root = std::env::temp_dir().join(format!("sgclaw-direct-submit-only-config-{}", Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
let config_path = root.join("sgclaw_config.json");
fs::write(
&config_path,
r#"{
"providers": [],
"skillsDir": "skill_lib",
"directSubmitSkill": "fault-details-report.collect_fault_details"
}"#,
)
.unwrap();
let settings = SgClawSettings::load(Some(config_path.as_path()))
.unwrap()
.expect("expected sgclaw settings from config file");
assert_eq!(
settings.direct_submit_skill.as_deref(),
Some("fault-details-report.collect_fault_details")
);
assert_eq!(settings.skills_dir, Some(root.join("skill_lib")));
}
#[test]
fn sgclaw_settings_reject_invalid_direct_submit_skill_format() {
let root = std::env::temp_dir().join(format!(
"sgclaw-invalid-direct-submit-skill-{}",
Uuid::new_v4()
));
fs::create_dir_all(&root).unwrap();
let config_path = root.join("sgclaw_config.json");
fs::write(
&config_path,
r#"{
"providers": [],
"skillsDir": "skill_lib",
"directSubmitSkill": "fault-details-report"
}"#,
)
.unwrap();
let err = SgClawSettings::load(Some(config_path.as_path()))
.expect_err("expected invalid directSubmitSkill format");
let message = err.to_string();
assert!(message.contains("directSubmitSkill"));
assert!(message.contains("skill.tool"));
}
#[test]
fn sgclaw_settings_load_new_runtime_fields_from_browser_config() {
let root = std::env::temp_dir().join(format!("sgclaw-runtime-config-{}", Uuid::new_v4()));