feat: add browser script skill execution
This commit is contained in:
@@ -47,7 +47,7 @@ fn policy_for_domains(domains: &[&str]) -> MacPolicy {
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": domains },
|
||||
"pipe_actions": {
|
||||
"allowed": ["click", "type", "navigate", "getText", "waitForSelector"],
|
||||
"allowed": ["click", "type", "navigate", "getText", "waitForSelector", "eval"],
|
||||
"blocked": []
|
||||
}
|
||||
})
|
||||
@@ -97,6 +97,25 @@ fn write_skill_package(skills_dir: &std::path::Path, skill_name: &str, body: &st
|
||||
fs::write(skill_dir.join("SKILL.md"), body).unwrap();
|
||||
}
|
||||
|
||||
fn write_skill_manifest_package(
|
||||
skills_dir: &std::path::Path,
|
||||
skill_name: &str,
|
||||
manifest: &str,
|
||||
) -> PathBuf {
|
||||
let skill_dir = skills_dir.join(skill_name);
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
fs::write(skill_dir.join("SKILL.toml"), manifest).unwrap();
|
||||
skill_dir
|
||||
}
|
||||
|
||||
fn write_skill_script(skill_dir: &std::path::Path, relative_path: &str, body: &str) {
|
||||
let script_path = skill_dir.join(relative_path);
|
||||
if let Some(parent) = script_path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(script_path, body).unwrap();
|
||||
}
|
||||
|
||||
fn real_skill_lib_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
@@ -1271,6 +1290,209 @@ fn compat_runtime_allows_read_skill_under_compact_mode_policy() {
|
||||
assert!(tool_names.contains(&"browser_action".to_string()));
|
||||
assert!(tool_names.contains(&"superrpa_browser".to_string()));
|
||||
assert!(tool_names.contains(&"read_skill".to_string()));
|
||||
assert!(tool_names.contains(&"zhihu-hotlist.extract_hotlist".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compat_runtime_exposes_browser_script_skill_tools_in_browser_attached_mode() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "已看到 browser_script skill 工具"
|
||||
}
|
||||
}]
|
||||
});
|
||||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let default_skills_dir = workspace_root.join(".sgclaw-zeroclaw-workspace").join("skills");
|
||||
let skill_dir = write_skill_manifest_package(
|
||||
&default_skills_dir,
|
||||
"workspace-zhihu-skill",
|
||||
r#"
|
||||
[skill]
|
||||
name = "workspace-zhihu-skill"
|
||||
description = "Extract Zhihu hotlist rows with a packaged browser script."
|
||||
version = "0.1.0"
|
||||
|
||||
[[tools]]
|
||||
name = "extract_hotlist"
|
||||
description = "Extract structured hotlist rows from the current Zhihu page."
|
||||
kind = "browser_script"
|
||||
command = "scripts/extract_hotlist.js"
|
||||
|
||||
[tools.args]
|
||||
top_n = "How many hotlist rows to extract."
|
||||
"#,
|
||||
);
|
||||
write_skill_script(
|
||||
&skill_dir,
|
||||
"scripts/extract_hotlist.js",
|
||||
"return { rows: [] };",
|
||||
);
|
||||
|
||||
let mut settings = SgClawSettings::from_legacy_deepseek_fields(
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
zhihu_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
let summary = execute_task_with_sgclaw_settings(
|
||||
transport.as_ref(),
|
||||
browser_tool,
|
||||
"告诉我当前有哪些知乎热榜工具",
|
||||
&CompatTaskContext::default(),
|
||||
&workspace_root,
|
||||
&settings,
|
||||
)
|
||||
.unwrap();
|
||||
server_handle.join().unwrap();
|
||||
|
||||
let request_bodies = requests.lock().unwrap().clone();
|
||||
let tool_names = request_tool_names(&request_bodies[0]);
|
||||
|
||||
assert_eq!(summary, "已看到 browser_script skill 工具");
|
||||
assert!(tool_names.contains(&"browser_action".to_string()));
|
||||
assert!(tool_names.contains(&"superrpa_browser".to_string()));
|
||||
assert!(tool_names.contains(&"read_skill".to_string()));
|
||||
assert!(tool_names.contains(&"workspace-zhihu-skill.extract_hotlist".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compat_runtime_executes_browser_script_skill_via_eval_without_gettext_probing() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let first_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "workspace-zhihu-skill.extract_hotlist",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"expected_domain": "www.zhihu.com",
|
||||
"top_n": "10"
|
||||
})).unwrap()
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
let second_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "已执行 browser_script skill"
|
||||
}
|
||||
}]
|
||||
});
|
||||
let (base_url, requests, server_handle) =
|
||||
start_fake_deepseek_server(vec![first_response, second_response]);
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let default_skills_dir = workspace_root.join(".sgclaw-zeroclaw-workspace").join("skills");
|
||||
let skill_dir = write_skill_manifest_package(
|
||||
&default_skills_dir,
|
||||
"workspace-zhihu-skill",
|
||||
r#"
|
||||
[skill]
|
||||
name = "workspace-zhihu-skill"
|
||||
description = "Extract Zhihu hotlist rows with a packaged browser script."
|
||||
version = "0.1.0"
|
||||
|
||||
[[tools]]
|
||||
name = "extract_hotlist"
|
||||
description = "Extract structured hotlist rows from the current Zhihu page."
|
||||
kind = "browser_script"
|
||||
command = "scripts/extract_hotlist.js"
|
||||
|
||||
[tools.args]
|
||||
top_n = "How many hotlist rows to extract."
|
||||
"#,
|
||||
);
|
||||
write_skill_script(
|
||||
&skill_dir,
|
||||
"scripts/extract_hotlist.js",
|
||||
r#"
|
||||
const topN = Number(args.top_n || 10);
|
||||
return {
|
||||
source: "https://www.zhihu.com/hot",
|
||||
sheet_name: "知乎热榜",
|
||||
columns: ["rank", "title", "heat"],
|
||||
rows: [[1, "标题", `${topN}条`]]
|
||||
};
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut settings = SgClawSettings::from_legacy_deepseek_fields(
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||||
1,
|
||||
json!({
|
||||
"text": {
|
||||
"source": "https://www.zhihu.com/hot",
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [[1, "标题", "10条"]]
|
||||
}
|
||||
}),
|
||||
)]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
zhihu_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
let summary = execute_task_with_sgclaw_settings(
|
||||
transport.as_ref(),
|
||||
browser_tool,
|
||||
"用知乎热榜 skill 提取前十条结构化数据",
|
||||
&CompatTaskContext::default(),
|
||||
&workspace_root,
|
||||
&settings,
|
||||
)
|
||||
.unwrap();
|
||||
server_handle.join().unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
let request_bodies = requests.lock().unwrap().clone();
|
||||
let tool_names = request_tool_names(&request_bodies[0]);
|
||||
|
||||
assert_eq!(summary, "已执行 browser_script skill");
|
||||
assert!(tool_names.contains(&"workspace-zhihu-skill.extract_hotlist".to_string()));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(message, AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "call workspace-zhihu-skill.extract_hotlist")
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(message, AgentMessage::Command { action, .. } if action == &Action::Eval)
|
||||
}));
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(message, AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message.starts_with("getText "))
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1322,6 +1544,7 @@ fn zhihu_hotlist_browser_skill_flow_does_not_expose_shell_or_glob_tools() {
|
||||
assert!(tool_names.contains(&"superrpa_browser".to_string()));
|
||||
assert!(tool_names.contains(&"browser_action".to_string()));
|
||||
assert!(tool_names.contains(&"read_skill".to_string()));
|
||||
assert!(tool_names.contains(&"zhihu-hotlist.extract_hotlist".to_string()));
|
||||
assert!(!tool_names.contains(&"shell".to_string()));
|
||||
assert!(!tool_names.contains(&"glob_search".to_string()));
|
||||
}
|
||||
@@ -1426,6 +1649,7 @@ fn browser_attached_export_flow_exposes_browser_and_office_tools_only() {
|
||||
assert!(tool_names.contains(&"superrpa_browser".to_string()));
|
||||
assert!(tool_names.contains(&"browser_action".to_string()));
|
||||
assert!(tool_names.contains(&"read_skill".to_string()));
|
||||
assert!(tool_names.contains(&"zhihu-hotlist.extract_hotlist".to_string()));
|
||||
assert!(tool_names.contains(&"openxml_office".to_string()));
|
||||
assert!(!tool_names.contains(&"shell".to_string()));
|
||||
assert!(!tool_names.contains(&"glob_search".to_string()));
|
||||
@@ -1480,6 +1704,7 @@ fn compat_runtime_allows_zhihu_hotlist_screen_export_tool_in_browser_profile() {
|
||||
assert!(tool_names.contains(&"superrpa_browser".to_string()));
|
||||
assert!(tool_names.contains(&"browser_action".to_string()));
|
||||
assert!(tool_names.contains(&"read_skill".to_string()));
|
||||
assert!(tool_names.contains(&"zhihu-hotlist.extract_hotlist".to_string()));
|
||||
assert!(tool_names.contains(&"screen_html_export".to_string()));
|
||||
assert!(!tool_names.contains(&"shell".to_string()));
|
||||
assert!(!tool_names.contains(&"glob_search".to_string()));
|
||||
@@ -1706,9 +1931,10 @@ fn handle_browser_message_executes_real_zhihu_hotlist_skill_flow() {
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_skill",
|
||||
"name": "zhihu-hotlist.extract_hotlist",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"name": "zhihu-hotlist"
|
||||
"expected_domain": "www.zhihu.com",
|
||||
"top_n": "10"
|
||||
})).unwrap()
|
||||
}
|
||||
}]
|
||||
@@ -1716,50 +1942,14 @@ fn handle_browser_message_executes_real_zhihu_hotlist_skill_flow() {
|
||||
}]
|
||||
});
|
||||
let second_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_2",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "browser_action",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"action": "navigate",
|
||||
"expected_domain": "www.zhihu.com",
|
||||
"url": "https://www.zhihu.com/hot"
|
||||
})).unwrap()
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "call_3",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "browser_action",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"action": "getText",
|
||||
"expected_domain": "www.zhihu.com",
|
||||
"selector": ".HotList-list .HotItem"
|
||||
})).unwrap()
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}]
|
||||
});
|
||||
let third_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "已完成知乎热榜采集"
|
||||
}
|
||||
}]
|
||||
});
|
||||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![
|
||||
first_response,
|
||||
second_response,
|
||||
third_response,
|
||||
]);
|
||||
let (base_url, requests, server_handle) =
|
||||
start_fake_deepseek_server(vec![first_response, second_response]);
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let skills_dir = real_skill_lib_root();
|
||||
@@ -1772,13 +1962,14 @@ fn handle_browser_message_executes_real_zhihu_hotlist_skill_flow() {
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
success_browser_response(1, json!({ "navigated": true })),
|
||||
success_browser_response(
|
||||
2,
|
||||
json!({ "text": "热榜项目 1\n热榜项目 2\n热榜项目 3" }),
|
||||
),
|
||||
]));
|
||||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(1, json!({
|
||||
"text": {
|
||||
"source": "https://www.zhihu.com/hot",
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [[1, "热榜项目 1", "1707万"], [2, "热榜项目 2", "1150万"]]
|
||||
}
|
||||
}))]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
zhihu_test_policy(),
|
||||
@@ -1816,32 +2007,22 @@ fn handle_browser_message_executes_real_zhihu_hotlist_skill_flow() {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "read_skill zhihu-hotlist@0.1.0"
|
||||
if level == "info" && message == "call zhihu-hotlist.extract_hotlist"
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, params, .. }
|
||||
if action == &Action::Navigate &&
|
||||
params["url"].as_str() == Some("https://www.zhihu.com/hot")
|
||||
if action == &Action::Eval &&
|
||||
params["script"].as_str().unwrap_or_default().contains("columns: ['rank', 'title', 'heat']")
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, params, .. }
|
||||
if action == &Action::GetText &&
|
||||
params["selector"].as_str() == Some(".HotList-list .HotItem")
|
||||
)
|
||||
}));
|
||||
assert_eq!(request_bodies.len(), 3);
|
||||
assert!(tool_content.len() > 100);
|
||||
assert!(tool_content.contains("hot list items"));
|
||||
assert!(tool_content.contains("Export Artifact"));
|
||||
assert!(tool_content.contains("\"sheet_name\": \"知乎热榜\""));
|
||||
assert!(tool_content.contains("\"columns\": [\"rank\", \"title\", \"heat\"]"));
|
||||
assert!(tool_content.contains("structured artifact is primary"));
|
||||
assert_eq!(request_bodies.len(), 2);
|
||||
assert!(tool_content.contains("知乎热榜"));
|
||||
assert!(tool_content.contains("rank"));
|
||||
assert!(tool_content.contains("heat"));
|
||||
assert!(tool_content.contains("热榜项目 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1859,30 +2040,10 @@ fn handle_browser_message_chains_hotlist_skill_into_office_export_tool() {
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "superrpa_browser",
|
||||
"name": "zhihu-hotlist.extract_hotlist",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"action": "navigate",
|
||||
"expected_domain": "www.zhihu.com",
|
||||
"url": "https://www.zhihu.com/hot"
|
||||
})).unwrap()
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
let second_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "call_2",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "superrpa_browser",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"action": "getText",
|
||||
"expected_domain": "www.zhihu.com",
|
||||
"selector": "main"
|
||||
"top_n": "10"
|
||||
})).unwrap()
|
||||
}
|
||||
}]
|
||||
@@ -1921,7 +2082,6 @@ fn handle_browser_message_chains_hotlist_skill_into_office_export_tool() {
|
||||
});
|
||||
let (base_url, _requests, server_handle) = start_fake_deepseek_server(vec![
|
||||
first_response,
|
||||
second_response,
|
||||
third_response,
|
||||
fourth_response,
|
||||
]);
|
||||
@@ -1935,11 +2095,14 @@ fn handle_browser_message_chains_hotlist_skill_into_office_export_tool() {
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
success_browser_response(1, json!({ "navigated": true })),
|
||||
success_browser_response(
|
||||
2,
|
||||
json!({ "text": "知乎热榜\n1\n问题一\n344万热度\n2\n问题二\n266万热度" }),
|
||||
),
|
||||
success_browser_response(1, json!({
|
||||
"text": {
|
||||
"source": "https://www.zhihu.com/hot",
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [[1, "问题一", "344万"], [2, "问题二", "266万"]]
|
||||
}
|
||||
})),
|
||||
]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
@@ -1979,6 +2142,19 @@ fn handle_browser_message_chains_hotlist_skill_into_office_export_tool() {
|
||||
if level == "mode" && message == "zeroclaw_process_message_primary"
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "call zhihu-hotlist.extract_hotlist"
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, .. } if action == &Action::Eval
|
||||
)
|
||||
}));
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
|
||||
Reference in New Issue
Block a user