fix: load DeepSeek config from browser runtime

This commit is contained in:
zyl
2026-03-27 00:34:14 +08:00
parent 11c0b0fc70
commit 0e3af5a391
8 changed files with 531 additions and 52 deletions

View File

@@ -1,19 +1,95 @@
pub mod planner;
pub mod runtime;
use std::ffi::OsString;
use std::path::PathBuf;
use crate::config::DeepSeekSettings;
use crate::pipe::{AgentMessage, BrowserMessage, BrowserPipeTool, PipeError, Transport};
pub fn execute_task<T: Transport>(
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentRuntimeContext {
config_path: Option<PathBuf>,
workspace_root: PathBuf,
}
impl AgentRuntimeContext {
pub fn new(config_path: Option<PathBuf>, workspace_root: PathBuf) -> Self {
Self {
config_path,
workspace_root,
}
}
pub fn from_process_args<I, S>(args: I) -> Result<Self, PipeError>
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
let mut config_path = None;
let mut args = args.into_iter().map(Into::into);
let _ = args.next();
while let Some(arg) = args.next() {
if arg == OsString::from("--config-path") {
let Some(value) = args.next() else {
return Err(PipeError::Protocol(
"missing value for --config-path".to_string(),
));
};
config_path = Some(PathBuf::from(value));
continue;
}
let arg_string = arg.to_string_lossy();
if let Some(value) = arg_string.strip_prefix("--config-path=") {
config_path = Some(PathBuf::from(value));
}
}
let workspace_root = config_path
.as_ref()
.and_then(|path| path.parent().map(|parent| parent.to_path_buf()))
.unwrap_or_else(default_workspace_root);
Ok(Self::new(config_path, workspace_root))
}
fn load_deepseek_settings(&self) -> Result<Option<DeepSeekSettings>, PipeError> {
DeepSeekSettings::load(self.config_path.as_deref())
.map_err(|err| PipeError::Protocol(err.to_string()))
}
fn deepseek_source_label(&self) -> String {
match &self.config_path {
Some(path) if path.exists() => path.display().to_string(),
_ => "environment".to_string(),
}
}
}
impl Default for AgentRuntimeContext {
fn default() -> Self {
Self::new(None, default_workspace_root())
}
}
fn default_workspace_root() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
fn send_mode_log<T: Transport>(transport: &T, mode: &str) -> Result<(), PipeError> {
transport.send(&AgentMessage::LogEntry {
level: "mode".to_string(),
message: mode.to_string(),
})
}
fn execute_plan<T: Transport>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
instruction: &str,
plan: &planner::TaskPlan,
) -> Result<String, PipeError> {
let plan = planner::plan_instruction(instruction)
.map_err(|err| PipeError::Protocol(err.to_string()))?;
for step in &plan.steps {
transport.send(&AgentMessage::LogEntry {
level: "info".to_string(),
@@ -33,42 +109,98 @@ pub fn execute_task<T: Transport>(
}
}
Ok(plan.summary)
Ok(plan.summary.clone())
}
pub fn execute_task<T: Transport>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
instruction: &str,
) -> Result<String, PipeError> {
let plan = planner::plan_instruction(instruction)
.map_err(|err| PipeError::Protocol(err.to_string()))?;
execute_plan(transport, browser_tool, &plan)
}
pub fn handle_browser_message<T: Transport + 'static>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
message: BrowserMessage,
) -> Result<(), PipeError> {
handle_browser_message_with_context(
transport,
browser_tool,
&AgentRuntimeContext::default(),
message,
)
}
pub fn handle_browser_message_with_context<T: Transport + 'static>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
context: &AgentRuntimeContext,
message: BrowserMessage,
) -> Result<(), PipeError> {
match message {
BrowserMessage::SubmitTask { instruction } => {
let completion = match DeepSeekSettings::from_env() {
Ok(_) => match crate::compat::runtime::execute_task(
transport,
browser_tool.clone(),
&instruction,
&std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
) {
Ok(summary) => AgentMessage::TaskComplete {
success: true,
summary,
},
let completion = match context.load_deepseek_settings() {
Ok(Some(settings)) => {
let _ = transport.send(&AgentMessage::LogEntry {
level: "info".to_string(),
message: format!(
"DeepSeek config loaded from {} model={} base_url={}",
context.deepseek_source_label(),
settings.model,
settings.base_url
),
});
let _ = send_mode_log(transport, "compat_llm_primary");
match crate::compat::runtime::execute_task(
transport,
browser_tool.clone(),
&instruction,
&context.workspace_root,
&settings,
) {
Ok(summary) => AgentMessage::TaskComplete {
success: true,
summary,
},
Err(err) => AgentMessage::TaskComplete {
success: false,
summary: err.to_string(),
},
}
}
Ok(None) => match planner::plan_instruction(&instruction) {
Ok(plan) => {
let _ = send_mode_log(transport, "deterministic_planner");
match execute_plan(transport, browser_tool, &plan) {
Ok(summary) => AgentMessage::TaskComplete {
success: true,
summary,
},
Err(err) => AgentMessage::TaskComplete {
success: false,
summary: err.to_string(),
},
}
}
Err(err) => AgentMessage::TaskComplete {
success: false,
summary: err.to_string(),
summary: PipeError::Protocol(err.to_string()).to_string(),
},
},
Err(_) => match execute_task(transport, browser_tool, &instruction) {
Ok(summary) => AgentMessage::TaskComplete {
success: true,
summary,
},
Err(err) => AgentMessage::TaskComplete {
Err(err) => {
let _ = transport.send(&AgentMessage::LogEntry {
level: "error".to_string(),
message: format!("failed to load DeepSeek config: {err}"),
});
AgentMessage::TaskComplete {
success: false,
summary: err.to_string(),
},
},
}
}
};
transport.send(&completion)
}

View File

@@ -80,7 +80,8 @@ impl<T: Transport + 'static> Tool for ZeroClawBrowserTool<T> {
Ok(ToolResult {
success: result.success,
output,
error: (!result.success).then(|| "browser action returned success=false".to_string()),
error: (!result.success)
.then(|| format_browser_action_error(&result.data)),
})
}
}
@@ -145,6 +146,21 @@ fn failed_tool_result(error: String) -> ToolResult {
}
}
fn format_browser_action_error(data: &Value) -> String {
if let Some(error) = data.get("error") {
if let Some(message) = error.get("message").and_then(Value::as_str) {
return message.to_string();
}
return format!("browser action failed: {error}");
}
if data.is_null() {
return "browser action returned success=false".to_string();
}
format!("browser action failed: {data}")
}
#[derive(Debug, thiserror::Error)]
enum BrowserActionAdapterError {
#[error("unsupported action: {0}")]

View File

@@ -15,7 +15,8 @@ use zeroclaw::providers::traits::{
};
use crate::compat::browser_tool_adapter::{ZeroClawBrowserTool, BROWSER_ACTION_TOOL_NAME};
use crate::compat::config_adapter::build_zeroclaw_config;
use crate::compat::config_adapter::build_zeroclaw_config_from_settings;
use crate::config::DeepSeekSettings;
use crate::compat::event_bridge::log_entry_for_turn_event;
use crate::compat::memory_adapter::build_memory;
use crate::pipe::{BrowserPipeTool, PipeError, Transport};
@@ -25,9 +26,9 @@ pub fn execute_task<T: Transport + 'static>(
browser_tool: BrowserPipeTool<T>,
instruction: &str,
workspace_root: &Path,
settings: &DeepSeekSettings,
) -> Result<String, PipeError> {
let config = build_zeroclaw_config(workspace_root)
.map_err(|err| PipeError::Protocol(err.to_string()))?;
let config = build_zeroclaw_config_from_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}")))?;

View File

@@ -1,3 +1,6 @@
use std::path::{Path, PathBuf};
use serde::Deserialize;
use thiserror::Error;
const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com";
@@ -12,20 +15,65 @@ pub struct DeepSeekSettings {
impl DeepSeekSettings {
pub fn from_env() -> Result<Self, ConfigError> {
let api_key = std::env::var("DEEPSEEK_API_KEY")
.map_err(|_| ConfigError::MissingEnv("DEEPSEEK_API_KEY"))?;
Self::maybe_from_env()?.ok_or(ConfigError::MissingEnv("DEEPSEEK_API_KEY"))
}
pub fn load(config_path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
if let Some(path) = config_path {
if path.exists() {
return Self::from_config_path(path).map(Some);
}
}
Self::maybe_from_env()
}
fn maybe_from_env() -> Result<Option<Self>, ConfigError> {
let api_key = match std::env::var("DEEPSEEK_API_KEY") {
Ok(value) => value,
Err(std::env::VarError::NotPresent) => return Ok(None),
Err(std::env::VarError::NotUnicode(_)) => {
return Err(ConfigError::InvalidEnv("DEEPSEEK_API_KEY"))
}
};
let base_url = std::env::var("DEEPSEEK_BASE_URL")
.unwrap_or_else(|_| DEFAULT_DEEPSEEK_BASE_URL.to_string());
let model =
std::env::var("DEEPSEEK_MODEL").unwrap_or_else(|_| DEFAULT_DEEPSEEK_MODEL.to_string());
if api_key.trim().is_empty() {
Ok(Some(Self::new(api_key, base_url, model)?))
}
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)
.map_err(|err| ConfigError::ConfigParse(path.to_path_buf(), err.to_string()))?;
Self::new(config.api_key, config.base_url, config.model)
.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()
} else {
base_url.trim().to_string()
};
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.trim().is_empty() {
if base_url.is_empty() {
return Err(ConfigError::EmptyValue("DEEPSEEK_BASE_URL"));
}
if model.trim().is_empty() {
if model.is_empty() {
return Err(ConfigError::EmptyValue("DEEPSEEK_MODEL"));
}
@@ -37,10 +85,37 @@ impl DeepSeekSettings {
}
}
#[derive(Debug, Deserialize)]
struct RawDeepSeekSettings {
#[serde(rename = "apiKey", default)]
api_key: String,
#[serde(rename = "baseUrl", default)]
base_url: String,
#[serde(default)]
model: String,
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum ConfigError {
#[error("missing environment variable: {0}")]
MissingEnv(&'static str),
#[error("environment variable must not be empty: {0}")]
EmptyValue(&'static str),
#[error("invalid non-utf8 environment variable: {0}")]
InvalidEnv(&'static str),
#[error("failed to read DeepSeek config file {0}: {1}")]
ConfigRead(PathBuf, String),
#[error("invalid DeepSeek config JSON in {0}: {1}")]
ConfigParse(PathBuf, String),
#[error("DeepSeek config value must not be empty: {0} ({1})")]
ConfigValueEmpty(&'static str, PathBuf),
}
impl ConfigError {
fn with_path(self, path: &Path) -> Self {
match self {
Self::EmptyValue(field) => Self::ConfigValueEmpty(field, path.to_path_buf()),
other => other,
}
}
}

View File

@@ -9,18 +9,25 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use agent::handle_browser_message;
use agent::{handle_browser_message_with_context, AgentRuntimeContext};
use pipe::{perform_handshake, BrowserPipeTool, PipeError, StdioTransport, Transport};
use security::MacPolicy;
fn default_rules_path_from_executable(executable_path: PathBuf) -> PathBuf {
executable_path
.parent()
.map(|dir| dir.join("resources").join("rules.json"))
.unwrap_or_else(|| PathBuf::from("resources").join("rules.json"))
}
fn default_rules_path() -> PathBuf {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("resources")
.join("rules.json")
std::env::current_exe()
.map(default_rules_path_from_executable)
.unwrap_or_else(|_| PathBuf::from("resources").join("rules.json"))
}
pub fn run() -> Result<(), PipeError> {
let runtime_context = AgentRuntimeContext::from_process_args(std::env::args_os())?;
let transport = Arc::new(StdioTransport::new(std::io::stdin(), std::io::stdout()));
let handshake = perform_handshake(transport.as_ref(), Duration::from_secs(5))?;
let mac_policy = MacPolicy::load_from_path(default_rules_path())?;
@@ -32,7 +39,12 @@ pub fn run() -> Result<(), PipeError> {
loop {
match transport.recv_timeout(Duration::from_secs(3600)) {
Ok(message) => {
handle_browser_message(transport.as_ref(), &browser_tool, message)?;
handle_browser_message_with_context(
transport.as_ref(),
&browser_tool,
&runtime_context,
message,
)?;
}
Err(PipeError::Timeout) => continue,
Err(PipeError::PipeClosed) => return Ok(()),
@@ -40,3 +52,21 @@ pub fn run() -> Result<(), PipeError> {
}
}
}
#[cfg(test)]
mod tests {
use super::default_rules_path_from_executable;
use std::path::PathBuf;
#[test]
fn default_rules_path_uses_executable_directory_instead_of_cwd() {
let executable_path = PathBuf::from("/tmp/out/KylinRelease/sgclaw");
let resolved = default_rules_path_from_executable(executable_path);
assert_eq!(
resolved,
PathBuf::from("/tmp/out/KylinRelease/resources/rules.json")
);
}
}