diff --git a/docs/L3-数据流与Skill体系层.md b/docs/L3-数据流与Skill体系层.md index 4b755ac..7cebb1d 100644 --- a/docs/L3-数据流与Skill体系层.md +++ b/docs/L3-数据流与Skill体系层.md @@ -32,7 +32,7 @@ log_entry / task_complete 当前代码与上述目标之间仍有过渡态偏差: - 浏览器是当前唯一成熟的特权工具面。 -- `planner fallback` 与 browser-first `compat runtime` 仍然并存。 +- `planner/runtime` 旧链路仍保留在仓库中,但已收敛为 `legacy/dev-only` 辅助模块。 - `zeroclaw` 已 vendored,但运行时还没有完全按 zeroclaw-first 方式释放能力。 因此 L3 既要说明目标数据流,也要明确指出当前代码仍处于过渡收口阶段。 @@ -62,16 +62,17 @@ Rust 侧在 [`src/agent/mod.rs`](/home/zyl/projects/sgClaw/claw/src/agent/mod.rs ### 2.3 当前执行路径选择(过渡态) -#### 路径 A:planner fallback +#### 路径 A:legacy planner/runtime(非生产 submit 主链) -条件:没有可用的 `DEEPSEEK_*` 环境配置。 -行为:使用仓库内置 planner 直接产生若干步骤,并逐个调用 `BrowserPipeTool`。 +条件:仅用于 dev/test 验证或保留回归覆盖。 +行为:使用仓库内置 planner 或轻量 runtime 直接产生若干步骤,并逐个调用 `BrowserPipeTool`。 特点: - 依赖更少。 - 逻辑可预测。 - 适合协议联调和最小功能验证。 +- 不再承接生产浏览器 submit 流量。 #### 路径 B:ZeroClaw compat runtime @@ -126,6 +127,7 @@ Rust 侧在 [`src/agent/mod.rs`](/home/zyl/projects/sgClaw/claw/src/agent/mod.rs 典型内容: - 当前准备执行的动作。 +- `planner_mode=zeroclaw_plan_first` 时由 sgClaw 先发出的计划预览。 - compat runtime 中转译出的事件摘要。 - 执行中的信息性提示。 @@ -181,13 +183,23 @@ Rust 侧在 [`src/agent/mod.rs`](/home/zyl/projects/sgClaw/claw/src/agent/mod.rs ### 6.1 配置 -当前真正参与执行的关键配置来自 [`src/config/settings.rs`](/home/zyl/projects/sgClaw/claw/src/config/settings.rs): +当前真正参与执行的关键配置来自 [`src/config/settings.rs`](/home/zyl/projects/sgClaw/claw/src/config/settings.rs)。它已经不再只是单一 `DEEPSEEK_*` shim,而是开始承载 sgClaw 自己的运行时策略: -- `DEEPSEEK_API_KEY` -- `DEEPSEEK_BASE_URL` -- `DEEPSEEK_MODEL` +- `providers` / `active_provider` +- `planner_mode` +- `browser_backend` +- `office_backend` +- `skills_prompt_mode` +- `runtime_profile` -这些配置当前决定是否启用 compat runtime,以及模型请求如何路由。长期看,它们应收敛为 zeroclaw-first 的 sgClaw runtime 配置,而不是永远停留在 DeepSeek shim。 +当前默认语义是: + +- `providers` 为空时,仍兼容旧的 `apiKey/baseUrl/model` DeepSeek 单模型配置。 +- `planner_mode=zeroclaw_plan_first` 时,由 sgClaw 在真实执行前先向宿主发送可展示的计划预览,前端只负责渲染。 +- `browser_backend=superrpa` 时,浏览器高权限动作仍以宿主 pipe 为边界;sgClaw 只决定运行时策略,不把特权上移到前端。 +- `office_backend=openxml` 时,导出类任务仍由 sgClaw 运行时选择实际导出工具。 + +这部分配置的目标很明确:让模型切换、planner 策略和运行时 backend 选择回到 sgClaw 自己,而不是继续散落在 SuperRPA 编译期常量或前端逻辑里。 ### 6.2 记忆 diff --git a/src/agent/planner.rs b/src/agent/planner.rs index 9558198..aebb40d 100644 --- a/src/agent/planner.rs +++ b/src/agent/planner.rs @@ -2,13 +2,19 @@ use reqwest::Url; use serde_json::{json, Value}; use thiserror::Error; +use crate::config::PlannerMode; use crate::pipe::Action; +/// Legacy deterministic planner kept for dev-only verification and fixture coverage. +/// Production browser submit flow no longer routes into this planner. +pub const LEGACY_DEV_ONLY: bool = true; + const BAIDU_URL: &str = "https://www.baidu.com"; const BAIDU_DOMAIN: &str = "www.baidu.com"; const BAIDU_INPUT_SELECTOR: &str = "#kw"; const BAIDU_SEARCH_BUTTON_SELECTOR: &str = "#su"; -const ZHIHU_URL: &str = "https://www.zhihu.com/search"; +const ZHIHU_HOME_URL: &str = "https://www.zhihu.com"; +const ZHIHU_SEARCH_URL: &str = "https://www.zhihu.com/search"; const ZHIHU_DOMAIN: &str = "www.zhihu.com"; #[derive(Debug, Clone, PartialEq)] @@ -25,6 +31,12 @@ pub struct TaskPlan { pub steps: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExecutionPreview { + pub summary: String, + pub steps: Vec, +} + #[derive(Debug, Error, Clone, PartialEq, Eq)] pub enum PlannerError { #[error("unsupported instruction: {0}")] @@ -35,10 +47,22 @@ pub enum PlannerError { pub fn plan_instruction(instruction: &str) -> Result { let trimmed = instruction.trim(); + if matches_exact(trimmed, &["打开百度"]) { + return Ok(plan_homepage("已打开百度首页", BAIDU_URL, BAIDU_DOMAIN)); + } + if let Some(query) = extract_query(trimmed, &["打开百度搜索", "打开百度并搜索"])? { return Ok(plan_baidu_search(query)); } + if matches_exact(trimmed, &["打开知乎"]) { + return Ok(plan_homepage( + "已打开知乎首页", + ZHIHU_HOME_URL, + ZHIHU_DOMAIN, + )); + } + if let Some(query) = extract_query(trimmed, &["打开知乎搜索", "打开知乎并搜索"])? { return Ok(plan_zhihu_search(query)); } @@ -46,6 +70,42 @@ pub fn plan_instruction(instruction: &str) -> Result { Err(PlannerError::UnsupportedInstruction(trimmed.to_string())) } +pub fn build_execution_preview( + mode: PlannerMode, + instruction: &str, + page_url: Option<&str>, + page_title: Option<&str>, +) -> Option { + if matches!(mode, PlannerMode::LegacyDeterministic) { + return None; + } + + let trimmed = instruction.trim(); + if crate::runtime::is_zhihu_hotlist_task(trimmed, page_url, page_title) { + return Some(build_zhihu_hotlist_preview(trimmed)); + } + + if let Ok(plan) = plan_instruction(trimmed) { + return Some(ExecutionPreview { + summary: format!("先规划再执行:{}", plan.summary), + steps: plan + .steps + .into_iter() + .map(|step| step.log_message) + .collect(), + }); + } + + Some(ExecutionPreview { + summary: "先规划再执行当前任务".to_string(), + steps: vec![ + "inspect current browser context".to_string(), + "choose the required sgclaw runtime tools".to_string(), + "execute and return the concrete result".to_string(), + ], + }) +} + fn extract_query<'a>( instruction: &'a str, prefixes: &[&str], @@ -65,6 +125,22 @@ fn extract_query<'a>( Ok(Some(query)) } +fn matches_exact(instruction: &str, candidates: &[&str]) -> bool { + candidates.iter().any(|candidate| instruction == *candidate) +} + +fn plan_homepage(summary: &str, url: &str, domain: &str) -> TaskPlan { + TaskPlan { + summary: summary.to_string(), + steps: vec![PlannedStep { + action: Action::Navigate, + params: json!({ "url": url }), + expected_domain: domain.to_string(), + log_message: format!("navigate {url}"), + }], + } +} + fn plan_baidu_search(query: &str) -> TaskPlan { TaskPlan { summary: format!("已在百度搜索{query}"), @@ -96,7 +172,7 @@ fn plan_baidu_search(query: &str) -> TaskPlan { } fn plan_zhihu_search(query: &str) -> TaskPlan { - let url = Url::parse_with_params(ZHIHU_URL, &[("type", "content"), ("q", query)]) + let url = Url::parse_with_params(ZHIHU_SEARCH_URL, &[("type", "content"), ("q", query)]) .expect("valid Zhihu search URL"); let url: String = url.into(); @@ -110,3 +186,28 @@ fn plan_zhihu_search(query: &str) -> TaskPlan { }], } } + +fn build_zhihu_hotlist_preview(instruction: &str) -> ExecutionPreview { + let normalized = instruction.to_ascii_lowercase(); + if normalized.contains("dashboard") || instruction.contains("大屏") || instruction.contains("新标签页") { + return ExecutionPreview { + summary: "先规划再执行知乎热榜大屏生成".to_string(), + steps: vec![ + "navigate https://www.zhihu.com/hot".to_string(), + "getText main".to_string(), + "call screen_html_export".to_string(), + "return generated local .html path".to_string(), + ], + }; + } + + ExecutionPreview { + summary: "先规划再执行知乎热榜 Excel 导出".to_string(), + steps: vec![ + "navigate https://www.zhihu.com/hot".to_string(), + "getText main".to_string(), + "call openxml_office".to_string(), + "return generated local .xlsx path".to_string(), + ], + } +} diff --git a/src/compat/config_adapter.rs b/src/compat/config_adapter.rs index be6fda1..328a1fc 100644 --- a/src/compat/config_adapter.rs +++ b/src/compat/config_adapter.rs @@ -1,16 +1,21 @@ +use std::collections::HashMap; +use std::ffi::OsStr; use std::path::{Path, PathBuf}; use zeroclaw::Config as ZeroClawConfig; +use zeroclaw::config::schema::ModelProviderConfig; use crate::compat::cron_adapter::configure_embedded_cron; use crate::compat::memory_adapter::configure_embedded_memory; -use crate::config::DeepSeekSettings; +use crate::config::{BrowserBackend, DeepSeekSettings, SgClawSettings}; +use crate::runtime::RuntimeProfile; const SGCLAW_ZEROCLAW_WORKSPACE_DIR: &str = ".sgclaw-zeroclaw-workspace"; +const SKILLS_DIR_NAME: &str = "skills"; pub fn build_zeroclaw_config(workspace_root: &Path) -> Result { - let settings = DeepSeekSettings::from_env()?; - Ok(build_zeroclaw_config_from_settings( + let settings = SgClawSettings::from_env()?; + Ok(build_zeroclaw_config_from_sgclaw_settings( workspace_root, &settings, )) @@ -19,15 +24,54 @@ pub fn build_zeroclaw_config(workspace_root: &Path) -> Result ZeroClawConfig { + build_zeroclaw_config_from_sgclaw_settings(workspace_root, &SgClawSettings::from(settings)) +} + +pub fn build_zeroclaw_config_from_sgclaw_settings( + workspace_root: &Path, + settings: &SgClawSettings, ) -> ZeroClawConfig { let workspace_dir = zeroclaw_workspace_dir(workspace_root); + let active_provider = settings.active_provider_settings(); let mut config = ZeroClawConfig::default(); config.workspace_dir = workspace_dir.clone(); config.config_path = workspace_dir.join("config.toml"); - config.default_provider = Some("deepseek".to_string()); - config.default_model = Some(settings.model.clone()); - config.api_key = Some(settings.api_key.clone()); - config.api_url = Some(settings.base_url.clone()); + config.default_provider = Some(active_provider.provider.clone()); + config.default_model = Some(active_provider.model.clone()); + config.api_key = Some(active_provider.api_key.clone()); + config.api_url = active_provider.base_url.clone(); + config.api_path = active_provider.api_path.clone(); + config.default_temperature = match settings.runtime_profile { + RuntimeProfile::BrowserAttached | RuntimeProfile::BrowserHeavy => 0.0, + RuntimeProfile::GeneralAssistant => config.default_temperature, + }; + config.skills.prompt_injection_mode = settings.skills_prompt_mode; + config.model_providers = settings + .providers + .iter() + .map(|provider| { + ( + provider.id.clone(), + ModelProviderConfig { + name: (!provider.provider.starts_with("custom:")) + .then(|| provider.provider.clone()), + base_url: provider.base_url.clone(), + api_path: provider.api_path.clone(), + wire_api: provider.wire_api.clone(), + requires_openai_auth: provider.requires_openai_auth, + azure_openai_resource: None, + azure_openai_deployment: None, + azure_openai_api_version: None, + max_tokens: None, + }, + ) + }) + .collect::>(); + config.browser.enabled = !matches!(settings.browser_backend, BrowserBackend::SuperRpa); + if let Some(backend) = settings.browser_backend.zeroclaw_backend() { + config.browser.backend = backend.to_string(); + } configure_embedded_memory(&mut config); configure_embedded_cron(&mut config); config @@ -36,3 +80,37 @@ pub fn build_zeroclaw_config_from_settings( pub fn zeroclaw_workspace_dir(workspace_root: &Path) -> PathBuf { workspace_root.join(SGCLAW_ZEROCLAW_WORKSPACE_DIR) } + +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_from_sgclaw_settings( + workspace_root: &Path, + settings: &SgClawSettings, +) -> PathBuf { + resolve_skills_dir_path(workspace_root, settings.skills_dir.as_deref()) +} + +fn normalize_configured_skills_dir(configured_dir: &Path) -> PathBuf { + if configured_dir.file_name() == Some(OsStr::new(SKILLS_DIR_NAME)) { + return configured_dir.to_path_buf(); + } + + let nested_skills_dir = configured_dir.join(SKILLS_DIR_NAME); + if nested_skills_dir.is_dir() { + nested_skills_dir + } else { + configured_dir.to_path_buf() + } +} + +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)) +} diff --git a/src/compat/runtime.rs b/src/compat/runtime.rs index 3ce1c3c..48c9968 100644 --- a/src/compat/runtime.rs +++ b/src/compat/runtime.rs @@ -1,12 +1,9 @@ -use std::path::Path; -use std::sync::Arc; +use std::path::{Path, PathBuf}; use async_trait::async_trait; use futures_util::{stream, StreamExt}; -use zeroclaw::agent::dispatcher::NativeToolDispatcher; -use zeroclaw::agent::{Agent, TurnEvent}; +use zeroclaw::agent::TurnEvent; use zeroclaw::config::Config as ZeroClawConfig; -use zeroclaw::observability::{NoopObserver, Observer}; use zeroclaw::providers::{ self, ChatMessage, ChatRequest, ChatResponse, Provider, }; @@ -14,12 +11,17 @@ use zeroclaw::providers::traits::{ ProviderCapabilities, StreamEvent, StreamOptions, StreamResult, }; -use crate::compat::browser_tool_adapter::{ZeroClawBrowserTool, BROWSER_ACTION_TOOL_NAME}; -use crate::compat::config_adapter::build_zeroclaw_config_from_settings; -use crate::config::DeepSeekSettings; +use crate::compat::browser_tool_adapter::ZeroClawBrowserTool; +use crate::compat::config_adapter::{ + build_zeroclaw_config_from_sgclaw_settings, + resolve_skills_dir_from_sgclaw_settings, +}; +use crate::compat::openxml_office_tool::OpenXmlOfficeTool; +use crate::compat::screen_html_export_tool::ScreenHtmlExportTool; +use crate::config::{DeepSeekSettings, OfficeBackend, SgClawSettings}; use crate::compat::event_bridge::log_entry_for_turn_event; -use crate::compat::memory_adapter::build_memory; use crate::pipe::{BrowserPipeTool, ConversationMessage, PipeError, Transport}; +use crate::runtime::RuntimeEngine; #[derive(Debug, Clone, Default)] pub struct CompatTaskContext { @@ -37,7 +39,27 @@ pub fn execute_task( workspace_root: &Path, settings: &DeepSeekSettings, ) -> Result { - let config = build_zeroclaw_config_from_settings(workspace_root, settings); + let sgclaw_settings = SgClawSettings::from(settings); + execute_task_with_sgclaw_settings( + transport, + browser_tool, + instruction, + task_context, + workspace_root, + &sgclaw_settings, + ) +} + +pub fn execute_task_with_sgclaw_settings( + transport: &T, + browser_tool: BrowserPipeTool, + instruction: &str, + task_context: &CompatTaskContext, + workspace_root: &Path, + settings: &SgClawSettings, +) -> Result { + let config = build_zeroclaw_config_from_sgclaw_settings(workspace_root, settings); + let skills_dir = resolve_skills_dir_from_sgclaw_settings(workspace_root, settings); let provider = build_provider(&config)?; let runtime = tokio::runtime::Runtime::new() .map_err(|err| PipeError::Protocol(format!("failed to create tokio runtime: {err}")))?; @@ -49,6 +71,8 @@ pub fn execute_task( instruction, task_context, config, + skills_dir, + settings.clone(), )) } @@ -59,8 +83,58 @@ pub async fn execute_task_with_provider( instruction: &str, task_context: &CompatTaskContext, config: ZeroClawConfig, + skills_dir: PathBuf, + settings: SgClawSettings, ) -> Result { - let mut agent = build_agent(browser_tool, provider, &config)?; + let engine = RuntimeEngine::new(settings.runtime_profile); + let browser_surface_present = engine.browser_surface_enabled(); + if let Some(preview) = crate::agent::planner::build_execution_preview( + settings.planner_mode, + instruction, + task_context.page_url.as_deref(), + task_context.page_title.as_deref(), + ) { + let mut message = preview.summary; + if !preview.steps.is_empty() { + message.push('\n'); + message.push_str(&preview.steps.join("\n")); + } + transport.send(&crate::pipe::AgentMessage::LogEntry { + level: "plan".to_string(), + message, + })?; + } + let loaded_skill_names = engine.loaded_skill_names(&config, &skills_dir); + if !loaded_skill_names.is_empty() { + transport.send(&crate::pipe::AgentMessage::LogEntry { + level: "info".to_string(), + message: format!("loaded skills: {}", loaded_skill_names.join(", ")), + })?; + } + let mut tools: Vec> = if browser_surface_present { + vec![ + Box::new(ZeroClawBrowserTool::new_superrpa(browser_tool.clone())), + Box::new(ZeroClawBrowserTool::new(browser_tool)), + ] + } else { + Vec::new() + }; + if matches!(settings.office_backend, OfficeBackend::OpenXml) && + engine.should_attach_openxml_office_tool(instruction) + { + tools.push(Box::new(OpenXmlOfficeTool::new(config.workspace_dir.clone()))); + } + if engine.should_attach_screen_html_export_tool(instruction) { + tools.push(Box::new(ScreenHtmlExportTool::new(config.workspace_dir.clone()))); + } + let mut agent = engine.build_agent( + provider, + &config, + &skills_dir, + tools, + browser_surface_present, + instruction, + )?; if let Some(conversation_id) = task_context .conversation_id .as_deref() @@ -70,13 +144,19 @@ pub async fn execute_task_with_provider( agent.set_memory_session_id(Some(conversation_id.to_string())); } - let seed_messages = build_seed_history(task_context); + let mut seed_messages = Vec::new(); + seed_messages.extend(build_seed_history(task_context)); if !seed_messages.is_empty() { agent.seed_history(&seed_messages); } let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::(32); - let instruction = instruction.to_string(); + let instruction = engine.build_instruction( + instruction, + task_context.page_url.as_deref(), + task_context.page_title.as_deref(), + browser_surface_present, + ); let task = tokio::spawn(async move { agent.turn_streamed(&instruction, event_tx).await }); @@ -91,36 +171,6 @@ pub async fn execute_task_with_provider( .map_err(|err| PipeError::Protocol(err.to_string())) } -fn build_agent( - browser_tool: BrowserPipeTool, - provider: Box, - config: &ZeroClawConfig, -) -> Result { - let memory = build_memory(config).map_err(map_anyhow_to_pipe_error)?; - let observer: Arc = Arc::new(NoopObserver); - let tools: Vec> = - vec![Box::new(ZeroClawBrowserTool::new(browser_tool))]; - - Agent::builder() - .provider(provider) - .tools(tools) - .memory(Arc::from(memory)) - .observer(observer) - .tool_dispatcher(Box::new(NativeToolDispatcher)) - .config(config.agent.clone()) - .model_name( - config - .default_model - .clone() - .unwrap_or_else(|| "deepseek-chat".to_string()), - ) - .temperature(config.default_temperature) - .workspace_dir(config.workspace_dir.clone()) - .allowed_tools(Some(vec![BROWSER_ACTION_TOOL_NAME.to_string()])) - .build() - .map_err(map_anyhow_to_pipe_error) -} - fn build_provider(config: &ZeroClawConfig) -> Result, PipeError> { let provider_name = config.default_provider.as_deref().unwrap_or("deepseek"); let model_name = config diff --git a/src/config/mod.rs b/src/config/mod.rs index 045e4bd..4bf7bac 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,3 +1,12 @@ mod settings; -pub use settings::{ConfigError, DeepSeekSettings}; +pub use settings::{ + BrowserBackend, + ConfigError, + DeepSeekSettings, + OfficeBackend, + PlannerMode, + ProviderSettings, + SgClawSettings, + SkillsPromptMode, +}; diff --git a/src/config/settings.rs b/src/config/settings.rs index 219c1f1..9231699 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -3,17 +3,137 @@ use std::path::{Path, PathBuf}; use serde::Deserialize; use thiserror::Error; +use crate::runtime::RuntimeProfile; + +pub use zeroclaw::config::SkillsPromptInjectionMode as SkillsPromptMode; + const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com"; const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-chat"; +const DEFAULT_PROVIDER_ID: &str = "deepseek"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlannerMode { + ZeroclawPlanFirst, + LegacyDeterministic, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserBackend { + SuperRpa, + AgentBrowser, + RustNative, + ComputerUse, + Auto, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OfficeBackend { + OpenXml, + Disabled, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderSettings { + pub id: String, + pub provider: String, + pub api_key: String, + pub base_url: Option, + pub model: String, + pub api_path: Option, + pub wire_api: Option, + pub requires_openai_auth: bool, +} + +impl ProviderSettings { + fn from_legacy_deepseek( + api_key: String, + base_url: String, + model: String, + ) -> Result { + let api_key = normalize_required_value("DEEPSEEK_API_KEY", api_key)?; + let base_url = normalize_base_url(base_url); + let model = normalize_model(model); + + Ok(Self { + id: DEFAULT_PROVIDER_ID.to_string(), + provider: DEFAULT_PROVIDER_ID.to_string(), + api_key, + base_url: Some(base_url), + model, + api_path: None, + wire_api: None, + requires_openai_auth: false, + }) + } + + fn from_raw(raw: RawProviderSettings) -> Result { + let id = raw.id.trim().to_string(); + if id.is_empty() { + return Err(ConfigError::InvalidValue( + "providers[].id", + "must not be empty".to_string(), + )); + } + + let api_key = normalize_required_value("providers[].apiKey", raw.api_key)?; + let model = normalize_required_value("providers[].model", raw.model)?; + let base_url = normalize_optional_value(raw.base_url); + let provider = normalize_optional_value(raw.provider) + .or_else(|| base_url.as_ref().map(|url| format!("custom:{url}"))) + .ok_or_else(|| { + ConfigError::InvalidValue( + "providers[].provider", + format!("provider {} must define provider or baseUrl", id), + ) + })?; + + Ok(Self { + id, + provider, + api_key, + base_url, + model, + api_path: normalize_optional_value(raw.api_path), + wire_api: normalize_optional_value(raw.wire_api), + requires_openai_auth: raw.requires_openai_auth, + }) + } +} #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeepSeekSettings { pub api_key: String, pub base_url: String, pub model: String, + pub skills_dir: Option, } impl DeepSeekSettings { + pub fn from_env() -> Result { + Ok(Self::from(&SgClawSettings::from_env()?)) + } + + pub fn load(config_path: Option<&Path>) -> Result, ConfigError> { + SgClawSettings::load(config_path).map(|settings| settings.map(|settings| Self::from(&settings))) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SgClawSettings { + pub provider_api_key: String, + pub provider_base_url: String, + pub provider_model: String, + pub skills_dir: Option, + pub skills_prompt_mode: SkillsPromptMode, + pub runtime_profile: RuntimeProfile, + pub planner_mode: PlannerMode, + pub providers: Vec, + pub active_provider: String, + pub browser_backend: BrowserBackend, + pub office_backend: OfficeBackend, +} + +impl SgClawSettings { pub fn from_env() -> Result { Self::maybe_from_env()?.ok_or(ConfigError::MissingEnv("DEEPSEEK_API_KEY")) } @@ -28,6 +148,34 @@ impl DeepSeekSettings { Self::maybe_from_env() } + pub fn from_legacy_deepseek_fields( + api_key: String, + base_url: String, + model: String, + skills_dir: Option, + ) -> Result { + Self::new( + api_key, + base_url, + model, + skills_dir, + None, + None, + None, + Vec::new(), + None, + None, + None, + ) + } + + pub fn active_provider_settings(&self) -> &ProviderSettings { + self.providers + .iter() + .find(|provider| provider.id == self.active_provider) + .expect("active_provider should always resolve to a configured provider") + } + fn maybe_from_env() -> Result, ConfigError> { let api_key = match std::env::var("DEEPSEEK_API_KEY") { Ok(value) => value, @@ -41,58 +189,320 @@ impl DeepSeekSettings { let model = std::env::var("DEEPSEEK_MODEL").unwrap_or_else(|_| DEFAULT_DEEPSEEK_MODEL.to_string()); - Ok(Some(Self::new(api_key, base_url, model)?)) + Ok(Some(Self::new( + api_key, + base_url, + model, + None, + None, + None, + None, + Vec::new(), + None, + None, + None, + )?)) } fn from_config_path(path: &Path) -> Result { let raw = std::fs::read_to_string(path) .map_err(|err| ConfigError::ConfigRead(path.to_path_buf(), err.to_string()))?; - let config: RawDeepSeekSettings = serde_json::from_str(&raw) + let config: RawSgClawSettings = serde_json::from_str(&raw) .map_err(|err| ConfigError::ConfigParse(path.to_path_buf(), err.to_string()))?; + let config_dir = path.parent().unwrap_or_else(|| Path::new(".")); + let runtime_profile = config + .runtime_profile + .as_deref() + .map(parse_runtime_profile) + .transpose() + .map_err(|value| { + ConfigError::ConfigParse(path.to_path_buf(), format!("invalid runtimeProfile: {value}")) + })?; + let skills_prompt_mode = config + .skills_prompt_mode + .as_deref() + .map(parse_skills_prompt_mode) + .transpose() + .map_err(|value| { + ConfigError::ConfigParse( + path.to_path_buf(), + format!("invalid skillsPromptMode: {value}"), + ) + })?; + let planner_mode = config + .planner_mode + .as_deref() + .map(parse_planner_mode) + .transpose() + .map_err(|value| { + ConfigError::ConfigParse(path.to_path_buf(), format!("invalid plannerMode: {value}")) + })?; + let browser_backend = config + .browser_backend + .as_deref() + .map(parse_browser_backend) + .transpose() + .map_err(|value| { + ConfigError::ConfigParse(path.to_path_buf(), format!("invalid browserBackend: {value}")) + })?; + let office_backend = config + .office_backend + .as_deref() + .map(parse_office_backend) + .transpose() + .map_err(|value| { + ConfigError::ConfigParse(path.to_path_buf(), format!("invalid officeBackend: {value}")) + })?; + let providers = config + .providers + .into_iter() + .map(ProviderSettings::from_raw) + .collect::, _>>() + .map_err(|err| err.with_path(path))?; - Self::new(config.api_key, config.base_url, config.model) - .map_err(|err| err.with_path(path)) + Self::new( + config.api_key, + config.base_url, + config.model, + resolve_configured_skills_dir(config.skills_dir, config_dir), + skills_prompt_mode, + runtime_profile, + planner_mode, + providers, + config.active_provider, + browser_backend, + office_backend, + ) + .map_err(|err| err.with_path(path)) } - fn new(api_key: String, base_url: String, model: String) -> Result { - let api_key = api_key.trim().to_string(); - let base_url = if base_url.trim().is_empty() { - DEFAULT_DEEPSEEK_BASE_URL.to_string() + fn new( + api_key: String, + base_url: String, + model: String, + skills_dir: Option, + skills_prompt_mode: Option, + runtime_profile: Option, + planner_mode: Option, + providers: Vec, + active_provider: Option, + browser_backend: Option, + office_backend: Option, + ) -> Result { + let providers = if providers.is_empty() { + vec![ProviderSettings::from_legacy_deepseek(api_key, base_url, model)?] } else { - base_url.trim().to_string() + providers }; - let model = if model.trim().is_empty() { - DEFAULT_DEEPSEEK_MODEL.to_string() - } else { - model.trim().to_string() - }; - - if api_key.is_empty() { - return Err(ConfigError::EmptyValue("DEEPSEEK_API_KEY")); - } - if base_url.is_empty() { - return Err(ConfigError::EmptyValue("DEEPSEEK_BASE_URL")); - } - if model.is_empty() { - return Err(ConfigError::EmptyValue("DEEPSEEK_MODEL")); - } + let active_provider = normalize_optional_value(active_provider) + .unwrap_or_else(|| providers[0].id.clone()); + let active_provider_settings = providers + .iter() + .find(|provider| provider.id == active_provider) + .ok_or_else(|| { + ConfigError::InvalidValue( + "activeProvider", + format!("unknown provider id: {active_provider}"), + ) + })?; Ok(Self { - api_key, - base_url, - model, + provider_api_key: active_provider_settings.api_key.clone(), + provider_base_url: active_provider_settings.base_url.clone().unwrap_or_default(), + provider_model: active_provider_settings.model.clone(), + skills_dir, + skills_prompt_mode: skills_prompt_mode.unwrap_or(SkillsPromptMode::Compact), + runtime_profile: runtime_profile.unwrap_or(RuntimeProfile::BrowserAttached), + planner_mode: planner_mode.unwrap_or(PlannerMode::ZeroclawPlanFirst), + providers, + active_provider, + browser_backend: browser_backend.unwrap_or(BrowserBackend::SuperRpa), + office_backend: office_backend.unwrap_or(OfficeBackend::OpenXml), }) } } +impl From<&SgClawSettings> for DeepSeekSettings { + fn from(value: &SgClawSettings) -> Self { + Self { + api_key: value.provider_api_key.clone(), + base_url: value.provider_base_url.clone(), + model: value.provider_model.clone(), + skills_dir: value.skills_dir.clone(), + } + } +} + +impl From<&DeepSeekSettings> for SgClawSettings { + fn from(value: &DeepSeekSettings) -> Self { + Self::from_legacy_deepseek_fields( + value.api_key.clone(), + value.base_url.clone(), + value.model.clone(), + value.skills_dir.clone(), + ) + .expect("DeepSeekSettings should already be validated") + } +} + +fn parse_runtime_profile(raw: &str) -> Result { + let normalized = raw + .trim() + .chars() + .filter(|ch| *ch != '_' && *ch != '-') + .collect::() + .to_ascii_lowercase(); + + match normalized.as_str() { + "browserattached" => Ok(RuntimeProfile::BrowserAttached), + "browserheavy" => Ok(RuntimeProfile::BrowserHeavy), + "generalassistant" => Ok(RuntimeProfile::GeneralAssistant), + _ => Err(raw.to_string()), + } +} + +fn parse_skills_prompt_mode(raw: &str) -> Result { + match raw.trim().to_ascii_lowercase().as_str() { + "compact" => Ok(SkillsPromptMode::Compact), + "full" => Ok(SkillsPromptMode::Full), + _ => Err(raw.to_string()), + } +} + +fn parse_planner_mode(raw: &str) -> Result { + let normalized = normalize_enum_token(raw); + + match normalized.as_str() { + "zeroclawplanfirst" | "planfirst" | "plannerfirst" => Ok(PlannerMode::ZeroclawPlanFirst), + "legacy" | "legacydeterministic" | "deterministic" => Ok(PlannerMode::LegacyDeterministic), + _ => Err(raw.to_string()), + } +} + +fn parse_browser_backend(raw: &str) -> Result { + let normalized = normalize_enum_token(raw); + + match normalized.as_str() { + "superrpa" | "superrpapipe" | "host" | "hostpipe" => Ok(BrowserBackend::SuperRpa), + "agentbrowser" => Ok(BrowserBackend::AgentBrowser), + "rustnative" => Ok(BrowserBackend::RustNative), + "computeruse" => Ok(BrowserBackend::ComputerUse), + "auto" => Ok(BrowserBackend::Auto), + _ => Err(raw.to_string()), + } +} + +fn parse_office_backend(raw: &str) -> Result { + let normalized = normalize_enum_token(raw); + + match normalized.as_str() { + "openxml" => Ok(OfficeBackend::OpenXml), + "disabled" | "none" => Ok(OfficeBackend::Disabled), + _ => Err(raw.to_string()), + } +} + +fn resolve_configured_skills_dir(raw: Option, config_dir: &Path) -> Option { + 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 normalize_required_value(field: &'static str, raw: String) -> Result { + let trimmed = raw.trim().to_string(); + if trimmed.is_empty() { + return Err(ConfigError::EmptyValue(field)); + } + Ok(trimmed) +} + +fn normalize_optional_value(raw: Option) -> Option { + raw.map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn normalize_base_url(raw: String) -> String { + let trimmed = raw.trim(); + if trimmed.is_empty() { + DEFAULT_DEEPSEEK_BASE_URL.to_string() + } else { + trimmed.to_string() + } +} + +fn normalize_model(raw: String) -> String { + let trimmed = raw.trim(); + if trimmed.is_empty() { + DEFAULT_DEEPSEEK_MODEL.to_string() + } else { + trimmed.to_string() + } +} + +fn normalize_enum_token(raw: &str) -> String { + raw.trim() + .chars() + .filter(|ch| *ch != '_' && *ch != '-') + .collect::() + .to_ascii_lowercase() +} + #[derive(Debug, Deserialize)] -struct RawDeepSeekSettings { +struct RawSgClawSettings { #[serde(rename = "apiKey", default)] api_key: String, #[serde(rename = "baseUrl", default)] base_url: String, #[serde(default)] model: String, + #[serde(rename = "skillsDir", alias = "skills_dir", default)] + skills_dir: Option, + #[serde(rename = "skillsPromptMode", alias = "skills_prompt_mode", default)] + skills_prompt_mode: Option, + #[serde(rename = "runtimeProfile", alias = "runtime_profile", default)] + runtime_profile: Option, + #[serde(rename = "plannerMode", alias = "planner_mode", default)] + planner_mode: Option, + #[serde(rename = "activeProvider", alias = "active_provider", default)] + active_provider: Option, + #[serde(rename = "browserBackend", alias = "browser_backend", default)] + browser_backend: Option, + #[serde(rename = "officeBackend", alias = "office_backend", default)] + office_backend: Option, + #[serde(default)] + providers: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawProviderSettings { + #[serde(default)] + id: String, + #[serde(default)] + provider: Option, + #[serde(rename = "apiKey", default)] + api_key: String, + #[serde(rename = "baseUrl", default)] + base_url: Option, + #[serde(default)] + model: String, + #[serde(rename = "apiPath", alias = "api_path", default)] + api_path: Option, + #[serde(rename = "wireApi", alias = "wire_api", default)] + wire_api: Option, + #[serde( + rename = "requiresOpenaiAuth", + alias = "requires_openai_auth", + default + )] + requires_openai_auth: bool, } #[derive(Debug, Error, Clone, PartialEq, Eq)] @@ -109,13 +519,32 @@ pub enum ConfigError { ConfigParse(PathBuf, String), #[error("DeepSeek config value must not be empty: {0} ({1})")] ConfigValueEmpty(&'static str, PathBuf), + #[error("invalid config value for {0}: {1}")] + InvalidValue(&'static str, String), + #[error("invalid DeepSeek config value for {0}: {2} ({1})")] + ConfigInvalidValue(&'static str, PathBuf, String), } impl ConfigError { fn with_path(self, path: &Path) -> Self { match self { Self::EmptyValue(field) => Self::ConfigValueEmpty(field, path.to_path_buf()), + Self::InvalidValue(field, detail) => { + Self::ConfigInvalidValue(field, path.to_path_buf(), detail) + } other => other, } } } + +impl BrowserBackend { + pub fn zeroclaw_backend(self) -> Option<&'static str> { + match self { + Self::SuperRpa => None, + Self::AgentBrowser => Some("agent_browser"), + Self::RustNative => Some("rust_native"), + Self::ComputerUse => Some("computer_use"), + Self::Auto => Some("auto"), + } + } +} diff --git a/tests/compat_config_test.rs b/tests/compat_config_test.rs index 41ac578..4d2a1f9 100644 --- a/tests/compat_config_test.rs +++ b/tests/compat_config_test.rs @@ -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"); } diff --git a/tests/planner_test.rs b/tests/planner_test.rs index b275cd7..474fccc 100644 --- a/tests/planner_test.rs +++ b/tests/planner_test.rs @@ -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()); +} diff --git a/tests/runtime_profile_test.rs b/tests/runtime_profile_test.rs new file mode 100644 index 0000000..aa80f0b --- /dev/null +++ b/tests/runtime_profile_test.rs @@ -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); +}