sgclaw: move runtime policy into config

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

View File

@@ -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<PlannedStep>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecutionPreview {
pub summary: String,
pub steps: Vec<String>,
}
#[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<TaskPlan, PlannerError> {
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<TaskPlan, PlannerError> {
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>(
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(),
],
}
}

View File

@@ -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<ZeroClawConfig, crate::config::ConfigError> {
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, cr
pub fn build_zeroclaw_config_from_settings(
workspace_root: &Path,
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 {
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::<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_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))
}

View File

@@ -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<T: Transport + 'static>(
workspace_root: &Path,
settings: &DeepSeekSettings,
) -> 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 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<T: Transport + 'static>(
instruction,
task_context,
config,
skills_dir,
settings.clone(),
))
}
@@ -59,8 +83,58 @@ pub async fn execute_task_with_provider<T: Transport + 'static>(
instruction: &str,
task_context: &CompatTaskContext,
config: ZeroClawConfig,
skills_dir: PathBuf,
settings: SgClawSettings,
) -> 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
.conversation_id
.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()));
}
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::<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 });
@@ -91,36 +171,6 @@ pub async fn execute_task_with_provider<T: Transport + 'static>(
.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> {
let provider_name = config.default_provider.as_deref().unwrap_or("deepseek");
let model_name = config

View File

@@ -1,3 +1,12 @@
mod settings;
pub use settings::{ConfigError, DeepSeekSettings};
pub use settings::{
BrowserBackend,
ConfigError,
DeepSeekSettings,
OfficeBackend,
PlannerMode,
ProviderSettings,
SgClawSettings,
SkillsPromptMode,
};

View File

@@ -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<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)]
pub struct DeepSeekSettings {
pub api_key: String,
pub base_url: String,
pub model: String,
pub skills_dir: Option<PathBuf>,
}
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> {
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<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> {
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<Self, ConfigError> {
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::<Result<Vec<_>, _>>()
.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<Self, ConfigError> {
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<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 {
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<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)]
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<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)]
@@ -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"),
}
}
}