sgclaw: move runtime policy into config
This commit is contained in:
@@ -32,7 +32,7 @@ log_entry / task_complete
|
|||||||
当前代码与上述目标之间仍有过渡态偏差:
|
当前代码与上述目标之间仍有过渡态偏差:
|
||||||
|
|
||||||
- 浏览器是当前唯一成熟的特权工具面。
|
- 浏览器是当前唯一成熟的特权工具面。
|
||||||
- `planner fallback` 与 browser-first `compat runtime` 仍然并存。
|
- `planner/runtime` 旧链路仍保留在仓库中,但已收敛为 `legacy/dev-only` 辅助模块。
|
||||||
- `zeroclaw` 已 vendored,但运行时还没有完全按 zeroclaw-first 方式释放能力。
|
- `zeroclaw` 已 vendored,但运行时还没有完全按 zeroclaw-first 方式释放能力。
|
||||||
|
|
||||||
因此 L3 既要说明目标数据流,也要明确指出当前代码仍处于过渡收口阶段。
|
因此 L3 既要说明目标数据流,也要明确指出当前代码仍处于过渡收口阶段。
|
||||||
@@ -62,16 +62,17 @@ Rust 侧在 [`src/agent/mod.rs`](/home/zyl/projects/sgClaw/claw/src/agent/mod.rs
|
|||||||
|
|
||||||
### 2.3 当前执行路径选择(过渡态)
|
### 2.3 当前执行路径选择(过渡态)
|
||||||
|
|
||||||
#### 路径 A:planner fallback
|
#### 路径 A:legacy planner/runtime(非生产 submit 主链)
|
||||||
|
|
||||||
条件:没有可用的 `DEEPSEEK_*` 环境配置。
|
条件:仅用于 dev/test 验证或保留回归覆盖。
|
||||||
行为:使用仓库内置 planner 直接产生若干步骤,并逐个调用 `BrowserPipeTool`。
|
行为:使用仓库内置 planner 或轻量 runtime 直接产生若干步骤,并逐个调用 `BrowserPipeTool`。
|
||||||
|
|
||||||
特点:
|
特点:
|
||||||
|
|
||||||
- 依赖更少。
|
- 依赖更少。
|
||||||
- 逻辑可预测。
|
- 逻辑可预测。
|
||||||
- 适合协议联调和最小功能验证。
|
- 适合协议联调和最小功能验证。
|
||||||
|
- 不再承接生产浏览器 submit 流量。
|
||||||
|
|
||||||
#### 路径 B:ZeroClaw compat runtime
|
#### 路径 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 中转译出的事件摘要。
|
- compat runtime 中转译出的事件摘要。
|
||||||
- 执行中的信息性提示。
|
- 执行中的信息性提示。
|
||||||
|
|
||||||
@@ -181,13 +183,23 @@ Rust 侧在 [`src/agent/mod.rs`](/home/zyl/projects/sgClaw/claw/src/agent/mod.rs
|
|||||||
|
|
||||||
### 6.1 配置
|
### 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`
|
- `providers` / `active_provider`
|
||||||
- `DEEPSEEK_BASE_URL`
|
- `planner_mode`
|
||||||
- `DEEPSEEK_MODEL`
|
- `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 记忆
|
### 6.2 记忆
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,19 @@ use reqwest::Url;
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::config::PlannerMode;
|
||||||
use crate::pipe::Action;
|
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_URL: &str = "https://www.baidu.com";
|
||||||
const BAIDU_DOMAIN: &str = "www.baidu.com";
|
const BAIDU_DOMAIN: &str = "www.baidu.com";
|
||||||
const BAIDU_INPUT_SELECTOR: &str = "#kw";
|
const BAIDU_INPUT_SELECTOR: &str = "#kw";
|
||||||
const BAIDU_SEARCH_BUTTON_SELECTOR: &str = "#su";
|
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";
|
const ZHIHU_DOMAIN: &str = "www.zhihu.com";
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -25,6 +31,12 @@ pub struct TaskPlan {
|
|||||||
pub steps: Vec<PlannedStep>,
|
pub steps: Vec<PlannedStep>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ExecutionPreview {
|
||||||
|
pub summary: String,
|
||||||
|
pub steps: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||||
pub enum PlannerError {
|
pub enum PlannerError {
|
||||||
#[error("unsupported instruction: {0}")]
|
#[error("unsupported instruction: {0}")]
|
||||||
@@ -35,10 +47,22 @@ pub enum PlannerError {
|
|||||||
|
|
||||||
pub fn plan_instruction(instruction: &str) -> Result<TaskPlan, PlannerError> {
|
pub fn plan_instruction(instruction: &str) -> Result<TaskPlan, PlannerError> {
|
||||||
let trimmed = instruction.trim();
|
let trimmed = instruction.trim();
|
||||||
|
if matches_exact(trimmed, &["打开百度"]) {
|
||||||
|
return Ok(plan_homepage("已打开百度首页", BAIDU_URL, BAIDU_DOMAIN));
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(query) = extract_query(trimmed, &["打开百度搜索", "打开百度并搜索"])? {
|
if let Some(query) = extract_query(trimmed, &["打开百度搜索", "打开百度并搜索"])? {
|
||||||
return Ok(plan_baidu_search(query));
|
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, &["打开知乎搜索", "打开知乎并搜索"])? {
|
if let Some(query) = extract_query(trimmed, &["打开知乎搜索", "打开知乎并搜索"])? {
|
||||||
return Ok(plan_zhihu_search(query));
|
return Ok(plan_zhihu_search(query));
|
||||||
}
|
}
|
||||||
@@ -46,6 +70,42 @@ pub fn plan_instruction(instruction: &str) -> Result<TaskPlan, PlannerError> {
|
|||||||
Err(PlannerError::UnsupportedInstruction(trimmed.to_string()))
|
Err(PlannerError::UnsupportedInstruction(trimmed.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn build_execution_preview(
|
||||||
|
mode: PlannerMode,
|
||||||
|
instruction: &str,
|
||||||
|
page_url: Option<&str>,
|
||||||
|
page_title: Option<&str>,
|
||||||
|
) -> Option<ExecutionPreview> {
|
||||||
|
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>(
|
fn extract_query<'a>(
|
||||||
instruction: &'a str,
|
instruction: &'a str,
|
||||||
prefixes: &[&str],
|
prefixes: &[&str],
|
||||||
@@ -65,6 +125,22 @@ fn extract_query<'a>(
|
|||||||
Ok(Some(query))
|
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 {
|
fn plan_baidu_search(query: &str) -> TaskPlan {
|
||||||
TaskPlan {
|
TaskPlan {
|
||||||
summary: format!("已在百度搜索{query}"),
|
summary: format!("已在百度搜索{query}"),
|
||||||
@@ -96,7 +172,7 @@ fn plan_baidu_search(query: &str) -> TaskPlan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn plan_zhihu_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");
|
.expect("valid Zhihu search URL");
|
||||||
let url: String = url.into();
|
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(),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::ffi::OsStr;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use zeroclaw::Config as ZeroClawConfig;
|
use zeroclaw::Config as ZeroClawConfig;
|
||||||
|
use zeroclaw::config::schema::ModelProviderConfig;
|
||||||
|
|
||||||
use crate::compat::cron_adapter::configure_embedded_cron;
|
use crate::compat::cron_adapter::configure_embedded_cron;
|
||||||
use crate::compat::memory_adapter::configure_embedded_memory;
|
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 SGCLAW_ZEROCLAW_WORKSPACE_DIR: &str = ".sgclaw-zeroclaw-workspace";
|
||||||
|
const SKILLS_DIR_NAME: &str = "skills";
|
||||||
|
|
||||||
pub fn build_zeroclaw_config(workspace_root: &Path) -> Result<ZeroClawConfig, crate::config::ConfigError> {
|
pub fn build_zeroclaw_config(workspace_root: &Path) -> Result<ZeroClawConfig, crate::config::ConfigError> {
|
||||||
let settings = DeepSeekSettings::from_env()?;
|
let settings = SgClawSettings::from_env()?;
|
||||||
Ok(build_zeroclaw_config_from_settings(
|
Ok(build_zeroclaw_config_from_sgclaw_settings(
|
||||||
workspace_root,
|
workspace_root,
|
||||||
&settings,
|
&settings,
|
||||||
))
|
))
|
||||||
@@ -19,15 +24,54 @@ pub fn build_zeroclaw_config(workspace_root: &Path) -> Result<ZeroClawConfig, cr
|
|||||||
pub fn build_zeroclaw_config_from_settings(
|
pub fn build_zeroclaw_config_from_settings(
|
||||||
workspace_root: &Path,
|
workspace_root: &Path,
|
||||||
settings: &DeepSeekSettings,
|
settings: &DeepSeekSettings,
|
||||||
|
) -> 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 {
|
) -> ZeroClawConfig {
|
||||||
let workspace_dir = zeroclaw_workspace_dir(workspace_root);
|
let workspace_dir = zeroclaw_workspace_dir(workspace_root);
|
||||||
|
let active_provider = settings.active_provider_settings();
|
||||||
let mut config = ZeroClawConfig::default();
|
let mut config = ZeroClawConfig::default();
|
||||||
config.workspace_dir = workspace_dir.clone();
|
config.workspace_dir = workspace_dir.clone();
|
||||||
config.config_path = workspace_dir.join("config.toml");
|
config.config_path = workspace_dir.join("config.toml");
|
||||||
config.default_provider = Some("deepseek".to_string());
|
config.default_provider = Some(active_provider.provider.clone());
|
||||||
config.default_model = Some(settings.model.clone());
|
config.default_model = Some(active_provider.model.clone());
|
||||||
config.api_key = Some(settings.api_key.clone());
|
config.api_key = Some(active_provider.api_key.clone());
|
||||||
config.api_url = Some(settings.base_url.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::<HashMap<_, _>>();
|
||||||
|
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_memory(&mut config);
|
||||||
configure_embedded_cron(&mut config);
|
configure_embedded_cron(&mut config);
|
||||||
config
|
config
|
||||||
@@ -36,3 +80,37 @@ pub fn build_zeroclaw_config_from_settings(
|
|||||||
pub fn zeroclaw_workspace_dir(workspace_root: &Path) -> PathBuf {
|
pub fn zeroclaw_workspace_dir(workspace_root: &Path) -> PathBuf {
|
||||||
workspace_root.join(SGCLAW_ZEROCLAW_WORKSPACE_DIR)
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures_util::{stream, StreamExt};
|
use futures_util::{stream, StreamExt};
|
||||||
use zeroclaw::agent::dispatcher::NativeToolDispatcher;
|
use zeroclaw::agent::TurnEvent;
|
||||||
use zeroclaw::agent::{Agent, TurnEvent};
|
|
||||||
use zeroclaw::config::Config as ZeroClawConfig;
|
use zeroclaw::config::Config as ZeroClawConfig;
|
||||||
use zeroclaw::observability::{NoopObserver, Observer};
|
|
||||||
use zeroclaw::providers::{
|
use zeroclaw::providers::{
|
||||||
self, ChatMessage, ChatRequest, ChatResponse, Provider,
|
self, ChatMessage, ChatRequest, ChatResponse, Provider,
|
||||||
};
|
};
|
||||||
@@ -14,12 +11,17 @@ use zeroclaw::providers::traits::{
|
|||||||
ProviderCapabilities, StreamEvent, StreamOptions, StreamResult,
|
ProviderCapabilities, StreamEvent, StreamOptions, StreamResult,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::compat::browser_tool_adapter::{ZeroClawBrowserTool, BROWSER_ACTION_TOOL_NAME};
|
use crate::compat::browser_tool_adapter::ZeroClawBrowserTool;
|
||||||
use crate::compat::config_adapter::build_zeroclaw_config_from_settings;
|
use crate::compat::config_adapter::{
|
||||||
use crate::config::DeepSeekSettings;
|
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::event_bridge::log_entry_for_turn_event;
|
||||||
use crate::compat::memory_adapter::build_memory;
|
|
||||||
use crate::pipe::{BrowserPipeTool, ConversationMessage, PipeError, Transport};
|
use crate::pipe::{BrowserPipeTool, ConversationMessage, PipeError, Transport};
|
||||||
|
use crate::runtime::RuntimeEngine;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct CompatTaskContext {
|
pub struct CompatTaskContext {
|
||||||
@@ -37,7 +39,27 @@ pub fn execute_task<T: Transport + 'static>(
|
|||||||
workspace_root: &Path,
|
workspace_root: &Path,
|
||||||
settings: &DeepSeekSettings,
|
settings: &DeepSeekSettings,
|
||||||
) -> Result<String, PipeError> {
|
) -> Result<String, PipeError> {
|
||||||
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<T: Transport + 'static>(
|
||||||
|
transport: &T,
|
||||||
|
browser_tool: BrowserPipeTool<T>,
|
||||||
|
instruction: &str,
|
||||||
|
task_context: &CompatTaskContext,
|
||||||
|
workspace_root: &Path,
|
||||||
|
settings: &SgClawSettings,
|
||||||
|
) -> Result<String, PipeError> {
|
||||||
|
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 provider = build_provider(&config)?;
|
||||||
let runtime = tokio::runtime::Runtime::new()
|
let runtime = tokio::runtime::Runtime::new()
|
||||||
.map_err(|err| PipeError::Protocol(format!("failed to create tokio runtime: {err}")))?;
|
.map_err(|err| PipeError::Protocol(format!("failed to create tokio runtime: {err}")))?;
|
||||||
@@ -49,6 +71,8 @@ pub fn execute_task<T: Transport + 'static>(
|
|||||||
instruction,
|
instruction,
|
||||||
task_context,
|
task_context,
|
||||||
config,
|
config,
|
||||||
|
skills_dir,
|
||||||
|
settings.clone(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,8 +83,58 @@ pub async fn execute_task_with_provider<T: Transport + 'static>(
|
|||||||
instruction: &str,
|
instruction: &str,
|
||||||
task_context: &CompatTaskContext,
|
task_context: &CompatTaskContext,
|
||||||
config: ZeroClawConfig,
|
config: ZeroClawConfig,
|
||||||
|
skills_dir: PathBuf,
|
||||||
|
settings: SgClawSettings,
|
||||||
) -> Result<String, PipeError> {
|
) -> Result<String, PipeError> {
|
||||||
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<Box<dyn zeroclaw::tools::Tool>> = 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
|
if let Some(conversation_id) = task_context
|
||||||
.conversation_id
|
.conversation_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -70,13 +144,19 @@ pub async fn execute_task_with_provider<T: Transport + 'static>(
|
|||||||
agent.set_memory_session_id(Some(conversation_id.to_string()));
|
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() {
|
if !seed_messages.is_empty() {
|
||||||
agent.seed_history(&seed_messages);
|
agent.seed_history(&seed_messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<TurnEvent>(32);
|
let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<TurnEvent>(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 });
|
let task = tokio::spawn(async move { agent.turn_streamed(&instruction, event_tx).await });
|
||||||
|
|
||||||
@@ -91,36 +171,6 @@ pub async fn execute_task_with_provider<T: Transport + 'static>(
|
|||||||
.map_err(|err| PipeError::Protocol(err.to_string()))
|
.map_err(|err| PipeError::Protocol(err.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_agent<T: Transport + 'static>(
|
|
||||||
browser_tool: BrowserPipeTool<T>,
|
|
||||||
provider: Box<dyn Provider>,
|
|
||||||
config: &ZeroClawConfig,
|
|
||||||
) -> Result<Agent, PipeError> {
|
|
||||||
let memory = build_memory(config).map_err(map_anyhow_to_pipe_error)?;
|
|
||||||
let observer: Arc<dyn Observer> = Arc::new(NoopObserver);
|
|
||||||
let tools: Vec<Box<dyn zeroclaw::tools::Tool>> =
|
|
||||||
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<Box<dyn Provider>, PipeError> {
|
fn build_provider(config: &ZeroClawConfig) -> Result<Box<dyn Provider>, PipeError> {
|
||||||
let provider_name = config.default_provider.as_deref().unwrap_or("deepseek");
|
let provider_name = config.default_provider.as_deref().unwrap_or("deepseek");
|
||||||
let model_name = config
|
let model_name = config
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
mod settings;
|
mod settings;
|
||||||
|
|
||||||
pub use settings::{ConfigError, DeepSeekSettings};
|
pub use settings::{
|
||||||
|
BrowserBackend,
|
||||||
|
ConfigError,
|
||||||
|
DeepSeekSettings,
|
||||||
|
OfficeBackend,
|
||||||
|
PlannerMode,
|
||||||
|
ProviderSettings,
|
||||||
|
SgClawSettings,
|
||||||
|
SkillsPromptMode,
|
||||||
|
};
|
||||||
|
|||||||
@@ -3,17 +3,137 @@ use std::path::{Path, PathBuf};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use thiserror::Error;
|
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_BASE_URL: &str = "https://api.deepseek.com";
|
||||||
const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-chat";
|
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<String>,
|
||||||
|
pub model: String,
|
||||||
|
pub api_path: Option<String>,
|
||||||
|
pub wire_api: Option<String>,
|
||||||
|
pub requires_openai_auth: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderSettings {
|
||||||
|
fn from_legacy_deepseek(
|
||||||
|
api_key: String,
|
||||||
|
base_url: String,
|
||||||
|
model: String,
|
||||||
|
) -> Result<Self, ConfigError> {
|
||||||
|
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<Self, ConfigError> {
|
||||||
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct DeepSeekSettings {
|
pub struct DeepSeekSettings {
|
||||||
pub api_key: String,
|
pub api_key: String,
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
|
pub skills_dir: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeepSeekSettings {
|
impl DeepSeekSettings {
|
||||||
|
pub fn from_env() -> Result<Self, ConfigError> {
|
||||||
|
Ok(Self::from(&SgClawSettings::from_env()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(config_path: Option<&Path>) -> Result<Option<Self>, 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<PathBuf>,
|
||||||
|
pub skills_prompt_mode: SkillsPromptMode,
|
||||||
|
pub runtime_profile: RuntimeProfile,
|
||||||
|
pub planner_mode: PlannerMode,
|
||||||
|
pub providers: Vec<ProviderSettings>,
|
||||||
|
pub active_provider: String,
|
||||||
|
pub browser_backend: BrowserBackend,
|
||||||
|
pub office_backend: OfficeBackend,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SgClawSettings {
|
||||||
pub fn from_env() -> Result<Self, ConfigError> {
|
pub fn from_env() -> Result<Self, ConfigError> {
|
||||||
Self::maybe_from_env()?.ok_or(ConfigError::MissingEnv("DEEPSEEK_API_KEY"))
|
Self::maybe_from_env()?.ok_or(ConfigError::MissingEnv("DEEPSEEK_API_KEY"))
|
||||||
}
|
}
|
||||||
@@ -28,6 +148,34 @@ impl DeepSeekSettings {
|
|||||||
Self::maybe_from_env()
|
Self::maybe_from_env()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_legacy_deepseek_fields(
|
||||||
|
api_key: String,
|
||||||
|
base_url: String,
|
||||||
|
model: String,
|
||||||
|
skills_dir: Option<PathBuf>,
|
||||||
|
) -> Result<Self, ConfigError> {
|
||||||
|
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<Option<Self>, ConfigError> {
|
fn maybe_from_env() -> Result<Option<Self>, ConfigError> {
|
||||||
let api_key = match std::env::var("DEEPSEEK_API_KEY") {
|
let api_key = match std::env::var("DEEPSEEK_API_KEY") {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
@@ -41,58 +189,320 @@ impl DeepSeekSettings {
|
|||||||
let model =
|
let model =
|
||||||
std::env::var("DEEPSEEK_MODEL").unwrap_or_else(|_| DEFAULT_DEEPSEEK_MODEL.to_string());
|
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<Self, ConfigError> {
|
fn from_config_path(path: &Path) -> Result<Self, ConfigError> {
|
||||||
let raw = std::fs::read_to_string(path)
|
let raw = std::fs::read_to_string(path)
|
||||||
.map_err(|err| ConfigError::ConfigRead(path.to_path_buf(), err.to_string()))?;
|
.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()))?;
|
.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::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|err| err.with_path(path))?;
|
||||||
|
|
||||||
Self::new(config.api_key, config.base_url, config.model)
|
Self::new(
|
||||||
.map_err(|err| err.with_path(path))
|
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<Self, ConfigError> {
|
fn new(
|
||||||
let api_key = api_key.trim().to_string();
|
api_key: String,
|
||||||
let base_url = if base_url.trim().is_empty() {
|
base_url: String,
|
||||||
DEFAULT_DEEPSEEK_BASE_URL.to_string()
|
model: String,
|
||||||
|
skills_dir: Option<PathBuf>,
|
||||||
|
skills_prompt_mode: Option<SkillsPromptMode>,
|
||||||
|
runtime_profile: Option<RuntimeProfile>,
|
||||||
|
planner_mode: Option<PlannerMode>,
|
||||||
|
providers: Vec<ProviderSettings>,
|
||||||
|
active_provider: Option<String>,
|
||||||
|
browser_backend: Option<BrowserBackend>,
|
||||||
|
office_backend: Option<OfficeBackend>,
|
||||||
|
) -> Result<Self, ConfigError> {
|
||||||
|
let providers = if providers.is_empty() {
|
||||||
|
vec![ProviderSettings::from_legacy_deepseek(api_key, base_url, model)?]
|
||||||
} else {
|
} else {
|
||||||
base_url.trim().to_string()
|
providers
|
||||||
};
|
};
|
||||||
let model = if model.trim().is_empty() {
|
let active_provider = normalize_optional_value(active_provider)
|
||||||
DEFAULT_DEEPSEEK_MODEL.to_string()
|
.unwrap_or_else(|| providers[0].id.clone());
|
||||||
} else {
|
let active_provider_settings = providers
|
||||||
model.trim().to_string()
|
.iter()
|
||||||
};
|
.find(|provider| provider.id == active_provider)
|
||||||
|
.ok_or_else(|| {
|
||||||
if api_key.is_empty() {
|
ConfigError::InvalidValue(
|
||||||
return Err(ConfigError::EmptyValue("DEEPSEEK_API_KEY"));
|
"activeProvider",
|
||||||
}
|
format!("unknown provider id: {active_provider}"),
|
||||||
if base_url.is_empty() {
|
)
|
||||||
return Err(ConfigError::EmptyValue("DEEPSEEK_BASE_URL"));
|
})?;
|
||||||
}
|
|
||||||
if model.is_empty() {
|
|
||||||
return Err(ConfigError::EmptyValue("DEEPSEEK_MODEL"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
api_key,
|
provider_api_key: active_provider_settings.api_key.clone(),
|
||||||
base_url,
|
provider_base_url: active_provider_settings.base_url.clone().unwrap_or_default(),
|
||||||
model,
|
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<RuntimeProfile, String> {
|
||||||
|
let normalized = raw
|
||||||
|
.trim()
|
||||||
|
.chars()
|
||||||
|
.filter(|ch| *ch != '_' && *ch != '-')
|
||||||
|
.collect::<String>()
|
||||||
|
.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<SkillsPromptMode, String> {
|
||||||
|
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<PlannerMode, String> {
|
||||||
|
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<BrowserBackend, String> {
|
||||||
|
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<OfficeBackend, String> {
|
||||||
|
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<String>, config_dir: &Path) -> Option<PathBuf> {
|
||||||
|
let trimmed = raw
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())?;
|
||||||
|
let path = PathBuf::from(trimmed);
|
||||||
|
|
||||||
|
if path.is_absolute() {
|
||||||
|
Some(path)
|
||||||
|
} else {
|
||||||
|
Some(config_dir.join(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_required_value(field: &'static str, raw: String) -> Result<String, ConfigError> {
|
||||||
|
let trimmed = raw.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(ConfigError::EmptyValue(field));
|
||||||
|
}
|
||||||
|
Ok(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_optional_value(raw: Option<String>) -> Option<String> {
|
||||||
|
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::<String>()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct RawDeepSeekSettings {
|
struct RawSgClawSettings {
|
||||||
#[serde(rename = "apiKey", default)]
|
#[serde(rename = "apiKey", default)]
|
||||||
api_key: String,
|
api_key: String,
|
||||||
#[serde(rename = "baseUrl", default)]
|
#[serde(rename = "baseUrl", default)]
|
||||||
base_url: String,
|
base_url: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
model: String,
|
model: String,
|
||||||
|
#[serde(rename = "skillsDir", alias = "skills_dir", default)]
|
||||||
|
skills_dir: Option<String>,
|
||||||
|
#[serde(rename = "skillsPromptMode", alias = "skills_prompt_mode", default)]
|
||||||
|
skills_prompt_mode: Option<String>,
|
||||||
|
#[serde(rename = "runtimeProfile", alias = "runtime_profile", default)]
|
||||||
|
runtime_profile: Option<String>,
|
||||||
|
#[serde(rename = "plannerMode", alias = "planner_mode", default)]
|
||||||
|
planner_mode: Option<String>,
|
||||||
|
#[serde(rename = "activeProvider", alias = "active_provider", default)]
|
||||||
|
active_provider: Option<String>,
|
||||||
|
#[serde(rename = "browserBackend", alias = "browser_backend", default)]
|
||||||
|
browser_backend: Option<String>,
|
||||||
|
#[serde(rename = "officeBackend", alias = "office_backend", default)]
|
||||||
|
office_backend: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
providers: Vec<RawProviderSettings>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RawProviderSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
provider: Option<String>,
|
||||||
|
#[serde(rename = "apiKey", default)]
|
||||||
|
api_key: String,
|
||||||
|
#[serde(rename = "baseUrl", default)]
|
||||||
|
base_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
model: String,
|
||||||
|
#[serde(rename = "apiPath", alias = "api_path", default)]
|
||||||
|
api_path: Option<String>,
|
||||||
|
#[serde(rename = "wireApi", alias = "wire_api", default)]
|
||||||
|
wire_api: Option<String>,
|
||||||
|
#[serde(
|
||||||
|
rename = "requiresOpenaiAuth",
|
||||||
|
alias = "requires_openai_auth",
|
||||||
|
default
|
||||||
|
)]
|
||||||
|
requires_openai_auth: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||||
@@ -109,13 +519,32 @@ pub enum ConfigError {
|
|||||||
ConfigParse(PathBuf, String),
|
ConfigParse(PathBuf, String),
|
||||||
#[error("DeepSeek config value must not be empty: {0} ({1})")]
|
#[error("DeepSeek config value must not be empty: {0} ({1})")]
|
||||||
ConfigValueEmpty(&'static str, PathBuf),
|
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 {
|
impl ConfigError {
|
||||||
fn with_path(self, path: &Path) -> Self {
|
fn with_path(self, path: &Path) -> Self {
|
||||||
match self {
|
match self {
|
||||||
Self::EmptyValue(field) => Self::ConfigValueEmpty(field, path.to_path_buf()),
|
Self::EmptyValue(field) => Self::ConfigValueEmpty(field, path.to_path_buf()),
|
||||||
|
Self::InvalidValue(field, detail) => {
|
||||||
|
Self::ConfigInvalidValue(field, path.to_path_buf(), detail)
|
||||||
|
}
|
||||||
other => other,
|
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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,10 +4,21 @@ use std::sync::{Mutex, OnceLock};
|
|||||||
|
|
||||||
use sgclaw::compat::config_adapter::{
|
use sgclaw::compat::config_adapter::{
|
||||||
build_zeroclaw_config,
|
build_zeroclaw_config,
|
||||||
|
build_zeroclaw_config_from_sgclaw_settings,
|
||||||
build_zeroclaw_config_from_settings,
|
build_zeroclaw_config_from_settings,
|
||||||
|
resolve_skills_dir,
|
||||||
|
zeroclaw_default_skills_dir,
|
||||||
zeroclaw_workspace_dir,
|
zeroclaw_workspace_dir,
|
||||||
};
|
};
|
||||||
use sgclaw::config::DeepSeekSettings;
|
use sgclaw::config::{
|
||||||
|
BrowserBackend,
|
||||||
|
DeepSeekSettings,
|
||||||
|
OfficeBackend,
|
||||||
|
PlannerMode,
|
||||||
|
SgClawSettings,
|
||||||
|
SkillsPromptMode,
|
||||||
|
};
|
||||||
|
use sgclaw::runtime::RuntimeProfile;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn env_lock() -> &'static Mutex<()> {
|
fn env_lock() -> &'static Mutex<()> {
|
||||||
@@ -44,6 +55,7 @@ fn zeroclaw_config_adapter_uses_deterministic_workspace_dir() {
|
|||||||
api_key: "key".to_string(),
|
api_key: "key".to_string(),
|
||||||
base_url: "https://proxy.example.com/v1".to_string(),
|
base_url: "https://proxy.example.com/v1".to_string(),
|
||||||
model: "deepseek-reasoner".to_string(),
|
model: "deepseek-reasoner".to_string(),
|
||||||
|
skills_dir: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let workspace_dir = zeroclaw_workspace_dir(Path::new("/var/lib/sgclaw"));
|
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_provider.as_deref(), Some("deepseek"));
|
||||||
assert_eq!(config.default_model.as_deref(), Some("deepseek-reasoner"));
|
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!(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]
|
#[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.api_key, "sk-first");
|
||||||
assert_eq!(first.base_url, "https://api.deepseek.com");
|
assert_eq!(first.base_url, "https://api.deepseek.com");
|
||||||
assert_eq!(first.model, "deepseek-chat");
|
assert_eq!(first.model, "deepseek-chat");
|
||||||
|
assert_eq!(first.skills_dir, None);
|
||||||
|
|
||||||
fs::write(
|
fs::write(
|
||||||
&config_path,
|
&config_path,
|
||||||
r#"{
|
r#"{
|
||||||
"apiKey": "sk-second",
|
"apiKey": "sk-second",
|
||||||
"baseUrl": "https://proxy.example.com/v1",
|
"baseUrl": "https://proxy.example.com/v1",
|
||||||
"model": "deepseek-reasoner"
|
"model": "deepseek-reasoner",
|
||||||
|
"skillsDir": "skill_lib"
|
||||||
}"#,
|
}"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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.api_key, "sk-second");
|
||||||
assert_eq!(second.base_url, "https://proxy.example.com/v1");
|
assert_eq!(second.base_url, "https://proxy.example.com/v1");
|
||||||
assert_eq!(second.model, "deepseek-reasoner");
|
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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
use serde_json::json;
|
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;
|
use sgclaw::pipe::Action;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn planner_module_is_explicitly_legacy_dev_only() {
|
||||||
|
assert!(sgclaw::agent::planner::LEGACY_DEV_ONLY);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn planner_converts_baidu_search_instruction_into_three_steps() {
|
fn planner_converts_baidu_search_instruction_into_three_steps() {
|
||||||
let plan = plan_instruction("打开百度搜索天气").unwrap();
|
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]
|
#[test]
|
||||||
fn planner_rejects_unrelated_instruction() {
|
fn planner_rejects_unrelated_instruction() {
|
||||||
let err = plan_instruction("打开谷歌搜索天气").unwrap_err();
|
let err = plan_instruction("打开谷歌搜索天气").unwrap_err();
|
||||||
@@ -57,3 +93,37 @@ fn planner_rejects_unrelated_instruction() {
|
|||||||
PlannerError::UnsupportedInstruction("打开谷歌搜索天气".to_string())
|
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());
|
||||||
|
}
|
||||||
|
|||||||
55
tests/runtime_profile_test.rs
Normal file
55
tests/runtime_profile_test.rs
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user