sgclaw: move runtime policy into config

This commit is contained in:
zyl
2026-03-29 22:38:20 +08:00
parent 54049a1e1e
commit 7d9036b2d4
9 changed files with 1095 additions and 93 deletions

View File

@@ -4,10 +4,21 @@ use std::sync::{Mutex, OnceLock};
use sgclaw::compat::config_adapter::{
build_zeroclaw_config,
build_zeroclaw_config_from_sgclaw_settings,
build_zeroclaw_config_from_settings,
resolve_skills_dir,
zeroclaw_default_skills_dir,
zeroclaw_workspace_dir,
};
use sgclaw::config::DeepSeekSettings;
use sgclaw::config::{
BrowserBackend,
DeepSeekSettings,
OfficeBackend,
PlannerMode,
SgClawSettings,
SkillsPromptMode,
};
use sgclaw::runtime::RuntimeProfile;
use uuid::Uuid;
fn env_lock() -> &'static Mutex<()> {
@@ -44,6 +55,7 @@ fn zeroclaw_config_adapter_uses_deterministic_workspace_dir() {
api_key: "key".to_string(),
base_url: "https://proxy.example.com/v1".to_string(),
model: "deepseek-reasoner".to_string(),
skills_dir: None,
};
let workspace_dir = zeroclaw_workspace_dir(Path::new("/var/lib/sgclaw"));
@@ -54,6 +66,10 @@ fn zeroclaw_config_adapter_uses_deterministic_workspace_dir() {
assert_eq!(config.default_provider.as_deref(), Some("deepseek"));
assert_eq!(config.default_model.as_deref(), Some("deepseek-reasoner"));
assert_eq!(config.api_url.as_deref(), Some("https://proxy.example.com/v1"));
assert_eq!(
resolve_skills_dir(Path::new("/var/lib/sgclaw"), &settings),
zeroclaw_default_skills_dir(Path::new("/var/lib/sgclaw"))
);
}
#[test]
@@ -78,13 +94,15 @@ fn deepseek_settings_reload_from_browser_config_path_after_file_changes() {
assert_eq!(first.api_key, "sk-first");
assert_eq!(first.base_url, "https://api.deepseek.com");
assert_eq!(first.model, "deepseek-chat");
assert_eq!(first.skills_dir, None);
fs::write(
&config_path,
r#"{
"apiKey": "sk-second",
"baseUrl": "https://proxy.example.com/v1",
"model": "deepseek-reasoner"
"model": "deepseek-reasoner",
"skillsDir": "skill_lib"
}"#,
)
.unwrap();
@@ -95,4 +113,184 @@ fn deepseek_settings_reload_from_browser_config_path_after_file_changes() {
assert_eq!(second.api_key, "sk-second");
assert_eq!(second.base_url, "https://proxy.example.com/v1");
assert_eq!(second.model, "deepseek-reasoner");
assert_eq!(second.skills_dir, Some(root.join("skill_lib")));
}
#[test]
fn resolve_skills_dir_prefers_nested_skills_subdirectory_for_configured_repo_root() {
let root = std::env::temp_dir().join(format!("sgclaw-skills-{}", Uuid::new_v4()));
fs::create_dir_all(root.join("skill_lib/skills")).unwrap();
let settings = DeepSeekSettings {
api_key: "key".to_string(),
base_url: "https://api.deepseek.com".to_string(),
model: "deepseek-chat".to_string(),
skills_dir: Some(root.join("skill_lib")),
};
let resolved = resolve_skills_dir(&root, &settings);
assert_eq!(resolved, root.join("skill_lib/skills"));
}
#[test]
fn resolve_skills_dir_preserves_absolute_configured_skills_directory() {
let root = std::env::temp_dir().join(format!("sgclaw-skills-{}", Uuid::new_v4()));
let external_skills = root.join("external-skill-lib/skills");
fs::create_dir_all(&external_skills).unwrap();
let settings = DeepSeekSettings {
api_key: "key".to_string(),
base_url: "https://api.deepseek.com".to_string(),
model: "deepseek-chat".to_string(),
skills_dir: Some(external_skills.clone()),
};
let resolved = resolve_skills_dir(&root, &settings);
assert_eq!(resolved, external_skills);
}
#[test]
fn sgclaw_settings_default_to_compact_skills_and_browser_attached_profile() {
let settings = SgClawSettings::from_legacy_deepseek_fields(
"sk-test".to_string(),
"https://api.deepseek.com".to_string(),
"deepseek-chat".to_string(),
None,
)
.unwrap();
assert_eq!(settings.runtime_profile, RuntimeProfile::BrowserAttached);
assert_eq!(settings.skills_prompt_mode, SkillsPromptMode::Compact);
}
#[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()));
fs::create_dir_all(&root).unwrap();
let config_path = root.join("sgclaw_config.json");
fs::write(
&config_path,
r#"{
"apiKey": "sk-runtime",
"baseUrl": "https://api.deepseek.com",
"model": "deepseek-chat",
"skillsDir": "skill_lib",
"runtimeProfile": "generalAssistant",
"skillsPromptMode": "full"
}"#,
)
.unwrap();
let settings = SgClawSettings::load(Some(config_path.as_path()))
.unwrap()
.expect("expected sgclaw settings from config file");
let config = build_zeroclaw_config_from_sgclaw_settings(&root, &settings);
assert_eq!(settings.runtime_profile, RuntimeProfile::GeneralAssistant);
assert_eq!(settings.skills_prompt_mode, SkillsPromptMode::Full);
assert_eq!(settings.skills_dir, Some(root.join("skill_lib")));
assert_eq!(config.skills.prompt_injection_mode, SkillsPromptMode::Full);
}
#[test]
fn browser_attached_config_uses_low_temperature_for_deterministic_execution() {
let settings = SgClawSettings::from_legacy_deepseek_fields(
"sk-test".to_string(),
"https://api.deepseek.com".to_string(),
"deepseek-chat".to_string(),
None,
)
.unwrap();
let config = build_zeroclaw_config_from_sgclaw_settings(Path::new("/tmp/sgclaw"), &settings);
assert_eq!(config.default_temperature, 0.0);
}
#[test]
fn sgclaw_settings_load_provider_switching_and_backend_policy_from_browser_config() {
let root = std::env::temp_dir().join(format!("sgclaw-provider-config-{}", Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
let config_path = root.join("sgclaw_config.json");
fs::write(
&config_path,
r#"{
"apiKey": "sk-legacy",
"baseUrl": "https://api.deepseek.com",
"model": "deepseek-chat",
"plannerMode": "zeroclawPlanFirst",
"activeProvider": "glm-prod",
"browserBackend": "superrpa",
"officeBackend": "openxml",
"providers": [
{
"id": "deepseek-default",
"provider": "deepseek",
"apiKey": "sk-deepseek",
"baseUrl": "https://api.deepseek.com",
"model": "deepseek-chat"
},
{
"id": "glm-prod",
"provider": "glm",
"apiKey": "sk-glm",
"baseUrl": "https://open.bigmodel.cn/api/paas/v4",
"model": "glm-4.5"
}
]
}"#,
)
.unwrap();
let settings = SgClawSettings::load(Some(config_path.as_path()))
.unwrap()
.expect("expected sgclaw settings from config file");
let config = build_zeroclaw_config_from_sgclaw_settings(&root, &settings);
assert_eq!(settings.planner_mode, PlannerMode::ZeroclawPlanFirst);
assert_eq!(settings.active_provider, "glm-prod");
assert_eq!(settings.providers.len(), 2);
assert_eq!(settings.provider_base_url, "https://open.bigmodel.cn/api/paas/v4");
assert_eq!(settings.provider_model, "glm-4.5");
assert_eq!(settings.browser_backend, BrowserBackend::SuperRpa);
assert_eq!(settings.office_backend, OfficeBackend::OpenXml);
assert_eq!(config.default_provider.as_deref(), Some("glm"));
assert_eq!(config.default_model.as_deref(), Some("glm-4.5"));
assert_eq!(config.api_key.as_deref(), Some("sk-glm"));
assert_eq!(
config.api_url.as_deref(),
Some("https://open.bigmodel.cn/api/paas/v4")
);
assert!(!config.browser.enabled);
assert!(config.model_providers.contains_key("deepseek-default"));
assert!(config.model_providers.contains_key("glm-prod"));
}
#[test]
fn sgclaw_settings_enable_non_host_browser_backend_when_requested() {
let root = std::env::temp_dir().join(format!("sgclaw-browser-backend-{}", Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
let config_path = root.join("sgclaw_config.json");
fs::write(
&config_path,
r#"{
"apiKey": "sk-runtime",
"baseUrl": "https://api.deepseek.com",
"model": "deepseek-chat",
"browserBackend": "rustNative"
}"#,
)
.unwrap();
let settings = SgClawSettings::load(Some(config_path.as_path()))
.unwrap()
.expect("expected sgclaw settings from config file");
let config = build_zeroclaw_config_from_sgclaw_settings(&root, &settings);
assert_eq!(settings.browser_backend, BrowserBackend::RustNative);
assert!(config.browser.enabled);
assert_eq!(config.browser.backend, "rust_native");
}

View File

@@ -1,7 +1,13 @@
use serde_json::json;
use sgclaw::agent::planner::{plan_instruction, PlannerError};
use sgclaw::agent::planner::{build_execution_preview, plan_instruction, PlannerError};
use sgclaw::config::PlannerMode;
use sgclaw::pipe::Action;
#[test]
fn planner_module_is_explicitly_legacy_dev_only() {
assert!(sgclaw::agent::planner::LEGACY_DEV_ONLY);
}
#[test]
fn planner_converts_baidu_search_instruction_into_three_steps() {
let plan = plan_instruction("打开百度搜索天气").unwrap();
@@ -48,6 +54,36 @@ fn planner_supports_zhihu_search_instruction_with_direct_search_url() {
);
}
#[test]
fn planner_supports_open_zhihu_homepage_instruction() {
let plan = plan_instruction("打开知乎").unwrap();
assert_eq!(plan.summary, "已打开知乎首页");
assert_eq!(plan.steps.len(), 1);
assert_eq!(plan.steps[0].action, Action::Navigate);
assert_eq!(
plan.steps[0].params,
json!({ "url": "https://www.zhihu.com" })
);
assert_eq!(plan.steps[0].expected_domain, "www.zhihu.com");
assert_eq!(plan.steps[0].log_message, "navigate https://www.zhihu.com");
}
#[test]
fn planner_supports_open_baidu_homepage_instruction() {
let plan = plan_instruction("打开百度").unwrap();
assert_eq!(plan.summary, "已打开百度首页");
assert_eq!(plan.steps.len(), 1);
assert_eq!(plan.steps[0].action, Action::Navigate);
assert_eq!(
plan.steps[0].params,
json!({ "url": "https://www.baidu.com" })
);
assert_eq!(plan.steps[0].expected_domain, "www.baidu.com");
assert_eq!(plan.steps[0].log_message, "navigate https://www.baidu.com");
}
#[test]
fn planner_rejects_unrelated_instruction() {
let err = plan_instruction("打开谷歌搜索天气").unwrap_err();
@@ -57,3 +93,37 @@ fn planner_rejects_unrelated_instruction() {
PlannerError::UnsupportedInstruction("打开谷歌搜索天气".to_string())
);
}
#[test]
fn plan_first_mode_builds_visible_preview_for_zhihu_excel_flow() {
let preview = build_execution_preview(
PlannerMode::ZeroclawPlanFirst,
"读取知乎热榜数据,并导出 excel 文件",
Some("https://www.zhihu.com/hot"),
Some("知乎热榜"),
)
.expect("expected plan preview");
assert_eq!(preview.summary, "先规划再执行知乎热榜 Excel 导出");
assert!(preview
.steps
.iter()
.any(|step| step.contains("navigate https://www.zhihu.com/hot")));
assert!(preview.steps.iter().any(|step| step.contains("getText main")));
assert!(preview
.steps
.iter()
.any(|step| step.contains("call openxml_office")));
}
#[test]
fn legacy_planner_mode_skips_runtime_preview() {
let preview = build_execution_preview(
PlannerMode::LegacyDeterministic,
"打开百度搜索天气",
None,
None,
);
assert!(preview.is_none());
}

View File

@@ -0,0 +1,55 @@
use sgclaw::runtime::{RuntimeEngine, RuntimeProfile, ToolPolicy};
use sgclaw::config::{BrowserBackend, OfficeBackend, PlannerMode, SgClawSettings};
#[test]
fn browser_attached_profile_exposes_browser_surface_without_becoming_browser_only() {
let profile = RuntimeProfile::BrowserAttached;
let policy = ToolPolicy::for_profile(profile);
assert!(policy.allowed_tools.contains(&"browser_action".to_string()));
assert!(policy
.allowed_tools
.contains(&"superrpa_browser".to_string()));
assert!(policy.may_use_non_browser_tools);
}
#[test]
fn general_assistant_profile_does_not_require_browser_surface() {
let profile = RuntimeProfile::GeneralAssistant;
let policy = ToolPolicy::for_profile(profile);
assert!(!policy.requires_browser_surface);
}
#[test]
fn browser_attached_export_prompt_requires_openxml_completion() {
let engine = RuntimeEngine::new(RuntimeProfile::BrowserAttached);
let instruction = engine.build_instruction(
"读取知乎热榜数据,并导出 excel 文件",
Some("https://www.zhihu.com/hot"),
Some("知乎热榜"),
true,
);
assert!(instruction.contains("must call openxml_office"));
assert!(instruction.contains("Do not stop after describing how you will parse"));
assert!(instruction.contains("Never fabricate, simulate, or invent substitute hotlist data"));
assert!(instruction.contains("Do not repeat the same sentence or section"));
assert!(instruction.contains("final answer must include the generated local .xlsx path"));
}
#[test]
fn legacy_settings_default_to_plan_first_superrpa_and_openxml_backends() {
let settings = SgClawSettings::from_legacy_deepseek_fields(
"sk-test".to_string(),
"https://api.deepseek.com".to_string(),
"deepseek-chat".to_string(),
None,
)
.unwrap();
assert_eq!(settings.planner_mode, PlannerMode::ZeroclawPlanFirst);
assert_eq!(settings.browser_backend, BrowserBackend::SuperRpa);
assert_eq!(settings.office_backend, OfficeBackend::OpenXml);
}