fix: load DeepSeek config from browser runtime
This commit is contained in:
154
src/agent/mod.rs
154
src/agent/mod.rs
@@ -1,19 +1,95 @@
|
|||||||
pub mod planner;
|
pub mod planner;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
|
|
||||||
|
use std::ffi::OsString;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::config::DeepSeekSettings;
|
use crate::config::DeepSeekSettings;
|
||||||
use crate::pipe::{AgentMessage, BrowserMessage, BrowserPipeTool, PipeError, Transport};
|
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,
|
transport: &T,
|
||||||
browser_tool: &BrowserPipeTool<T>,
|
browser_tool: &BrowserPipeTool<T>,
|
||||||
instruction: &str,
|
plan: &planner::TaskPlan,
|
||||||
) -> Result<String, PipeError> {
|
) -> Result<String, PipeError> {
|
||||||
let plan = planner::plan_instruction(instruction)
|
|
||||||
.map_err(|err| PipeError::Protocol(err.to_string()))?;
|
|
||||||
|
|
||||||
for step in &plan.steps {
|
for step in &plan.steps {
|
||||||
transport.send(&AgentMessage::LogEntry {
|
transport.send(&AgentMessage::LogEntry {
|
||||||
level: "info".to_string(),
|
level: "info".to_string(),
|
||||||
@@ -33,22 +109,58 @@ 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>(
|
pub fn handle_browser_message<T: Transport + 'static>(
|
||||||
transport: &T,
|
transport: &T,
|
||||||
browser_tool: &BrowserPipeTool<T>,
|
browser_tool: &BrowserPipeTool<T>,
|
||||||
message: BrowserMessage,
|
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> {
|
) -> Result<(), PipeError> {
|
||||||
match message {
|
match message {
|
||||||
BrowserMessage::SubmitTask { instruction } => {
|
BrowserMessage::SubmitTask { instruction } => {
|
||||||
let completion = match DeepSeekSettings::from_env() {
|
let completion = match context.load_deepseek_settings() {
|
||||||
Ok(_) => match crate::compat::runtime::execute_task(
|
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,
|
transport,
|
||||||
browser_tool.clone(),
|
browser_tool.clone(),
|
||||||
&instruction,
|
&instruction,
|
||||||
&std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
&context.workspace_root,
|
||||||
|
&settings,
|
||||||
) {
|
) {
|
||||||
Ok(summary) => AgentMessage::TaskComplete {
|
Ok(summary) => AgentMessage::TaskComplete {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -58,8 +170,12 @@ pub fn handle_browser_message<T: Transport + 'static>(
|
|||||||
success: false,
|
success: false,
|
||||||
summary: err.to_string(),
|
summary: err.to_string(),
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
Err(_) => match execute_task(transport, browser_tool, &instruction) {
|
}
|
||||||
|
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 {
|
Ok(summary) => AgentMessage::TaskComplete {
|
||||||
success: true,
|
success: true,
|
||||||
summary,
|
summary,
|
||||||
@@ -68,7 +184,23 @@ pub fn handle_browser_message<T: Transport + 'static>(
|
|||||||
success: false,
|
success: false,
|
||||||
summary: err.to_string(),
|
summary: err.to_string(),
|
||||||
},
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => AgentMessage::TaskComplete {
|
||||||
|
success: false,
|
||||||
|
summary: PipeError::Protocol(err.to_string()).to_string(),
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
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)
|
transport.send(&completion)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,8 @@ impl<T: Transport + 'static> Tool for ZeroClawBrowserTool<T> {
|
|||||||
Ok(ToolResult {
|
Ok(ToolResult {
|
||||||
success: result.success,
|
success: result.success,
|
||||||
output,
|
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)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
enum BrowserActionAdapterError {
|
enum BrowserActionAdapterError {
|
||||||
#[error("unsupported action: {0}")]
|
#[error("unsupported action: {0}")]
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ use zeroclaw::providers::traits::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::compat::browser_tool_adapter::{ZeroClawBrowserTool, BROWSER_ACTION_TOOL_NAME};
|
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::event_bridge::log_entry_for_turn_event;
|
||||||
use crate::compat::memory_adapter::build_memory;
|
use crate::compat::memory_adapter::build_memory;
|
||||||
use crate::pipe::{BrowserPipeTool, PipeError, Transport};
|
use crate::pipe::{BrowserPipeTool, PipeError, Transport};
|
||||||
@@ -25,9 +26,9 @@ pub fn execute_task<T: Transport + 'static>(
|
|||||||
browser_tool: BrowserPipeTool<T>,
|
browser_tool: BrowserPipeTool<T>,
|
||||||
instruction: &str,
|
instruction: &str,
|
||||||
workspace_root: &Path,
|
workspace_root: &Path,
|
||||||
|
settings: &DeepSeekSettings,
|
||||||
) -> Result<String, PipeError> {
|
) -> Result<String, PipeError> {
|
||||||
let config = build_zeroclaw_config(workspace_root)
|
let config = build_zeroclaw_config_from_settings(workspace_root, settings);
|
||||||
.map_err(|err| PipeError::Protocol(err.to_string()))?;
|
|
||||||
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}")))?;
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com";
|
const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com";
|
||||||
@@ -12,20 +15,65 @@ pub struct DeepSeekSettings {
|
|||||||
|
|
||||||
impl DeepSeekSettings {
|
impl DeepSeekSettings {
|
||||||
pub fn from_env() -> Result<Self, ConfigError> {
|
pub fn from_env() -> Result<Self, ConfigError> {
|
||||||
let api_key = std::env::var("DEEPSEEK_API_KEY")
|
Self::maybe_from_env()?.ok_or(ConfigError::MissingEnv("DEEPSEEK_API_KEY"))
|
||||||
.map_err(|_| 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")
|
let base_url = std::env::var("DEEPSEEK_BASE_URL")
|
||||||
.unwrap_or_else(|_| DEFAULT_DEEPSEEK_BASE_URL.to_string());
|
.unwrap_or_else(|_| DEFAULT_DEEPSEEK_BASE_URL.to_string());
|
||||||
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());
|
||||||
|
|
||||||
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"));
|
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"));
|
return Err(ConfigError::EmptyValue("DEEPSEEK_BASE_URL"));
|
||||||
}
|
}
|
||||||
if model.trim().is_empty() {
|
if model.is_empty() {
|
||||||
return Err(ConfigError::EmptyValue("DEEPSEEK_MODEL"));
|
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)]
|
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||||
pub enum ConfigError {
|
pub enum ConfigError {
|
||||||
#[error("missing environment variable: {0}")]
|
#[error("missing environment variable: {0}")]
|
||||||
MissingEnv(&'static str),
|
MissingEnv(&'static str),
|
||||||
#[error("environment variable must not be empty: {0}")]
|
#[error("environment variable must not be empty: {0}")]
|
||||||
EmptyValue(&'static str),
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/lib.rs
42
src/lib.rs
@@ -9,18 +9,25 @@ use std::path::PathBuf;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
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 pipe::{perform_handshake, BrowserPipeTool, PipeError, StdioTransport, Transport};
|
||||||
use security::MacPolicy;
|
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 {
|
fn default_rules_path() -> PathBuf {
|
||||||
std::env::current_dir()
|
std::env::current_exe()
|
||||||
.unwrap_or_else(|_| PathBuf::from("."))
|
.map(default_rules_path_from_executable)
|
||||||
.join("resources")
|
.unwrap_or_else(|_| PathBuf::from("resources").join("rules.json"))
|
||||||
.join("rules.json")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run() -> Result<(), PipeError> {
|
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 transport = Arc::new(StdioTransport::new(std::io::stdin(), std::io::stdout()));
|
||||||
let handshake = perform_handshake(transport.as_ref(), Duration::from_secs(5))?;
|
let handshake = perform_handshake(transport.as_ref(), Duration::from_secs(5))?;
|
||||||
let mac_policy = MacPolicy::load_from_path(default_rules_path())?;
|
let mac_policy = MacPolicy::load_from_path(default_rules_path())?;
|
||||||
@@ -32,7 +39,12 @@ pub fn run() -> Result<(), PipeError> {
|
|||||||
loop {
|
loop {
|
||||||
match transport.recv_timeout(Duration::from_secs(3600)) {
|
match transport.recv_timeout(Duration::from_secs(3600)) {
|
||||||
Ok(message) => {
|
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::Timeout) => continue,
|
||||||
Err(PipeError::PipeClosed) => return Ok(()),
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ use sgclaw::compat::config_adapter::{
|
|||||||
zeroclaw_workspace_dir,
|
zeroclaw_workspace_dir,
|
||||||
};
|
};
|
||||||
use sgclaw::config::DeepSeekSettings;
|
use sgclaw::config::DeepSeekSettings;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn env_lock() -> &'static Mutex<()> {
|
fn env_lock() -> &'static Mutex<()> {
|
||||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
@@ -53,3 +55,44 @@ fn zeroclaw_config_adapter_uses_deterministic_workspace_dir() {
|
|||||||
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"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deepseek_settings_reload_from_browser_config_path_after_file_changes() {
|
||||||
|
let root = std::env::temp_dir().join(format!("sgclaw-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-first",
|
||||||
|
"baseUrl": "",
|
||||||
|
"model": ""
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let first = DeepSeekSettings::load(Some(config_path.as_path()))
|
||||||
|
.unwrap()
|
||||||
|
.expect("expected config file to produce settings");
|
||||||
|
assert_eq!(first.api_key, "sk-first");
|
||||||
|
assert_eq!(first.base_url, "https://api.deepseek.com");
|
||||||
|
assert_eq!(first.model, "deepseek-chat");
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
r#"{
|
||||||
|
"apiKey": "sk-second",
|
||||||
|
"baseUrl": "https://proxy.example.com/v1",
|
||||||
|
"model": "deepseek-reasoner"
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let second = DeepSeekSettings::load(Some(config_path.as_path()))
|
||||||
|
.unwrap()
|
||||||
|
.expect("expected updated config file to produce settings");
|
||||||
|
assert_eq!(second.api_key, "sk-second");
|
||||||
|
assert_eq!(second.base_url, "https://proxy.example.com/v1");
|
||||||
|
assert_eq!(second.model, "deepseek-reasoner");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -9,8 +10,13 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use common::MockTransport;
|
use common::MockTransport;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use sgclaw::agent::handle_browser_message;
|
use sgclaw::agent::{
|
||||||
|
handle_browser_message,
|
||||||
|
handle_browser_message_with_context,
|
||||||
|
AgentRuntimeContext,
|
||||||
|
};
|
||||||
use sgclaw::compat::runtime::execute_task;
|
use sgclaw::compat::runtime::execute_task;
|
||||||
|
use sgclaw::config::DeepSeekSettings;
|
||||||
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
||||||
use sgclaw::security::MacPolicy;
|
use sgclaw::security::MacPolicy;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -40,6 +46,21 @@ fn temp_workspace_root() -> PathBuf {
|
|||||||
root
|
root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_deepseek_config(root: &PathBuf, api_key: &str, base_url: &str, model: &str) -> PathBuf {
|
||||||
|
let config_path = root.join("sgclaw_config.json");
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
serde_json::to_string_pretty(&json!({
|
||||||
|
"apiKey": api_key,
|
||||||
|
"baseUrl": base_url,
|
||||||
|
"model": model,
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
config_path
|
||||||
|
}
|
||||||
|
|
||||||
fn start_fake_deepseek_server(
|
fn start_fake_deepseek_server(
|
||||||
responses: Vec<Value>,
|
responses: Vec<Value>,
|
||||||
) -> (String, Arc<Mutex<Vec<Value>>>, thread::JoinHandle<()>) {
|
) -> (String, Arc<Mutex<Vec<Value>>>, thread::JoinHandle<()>) {
|
||||||
@@ -177,6 +198,7 @@ fn compat_runtime_uses_zeroclaw_provider_path_and_executes_browser_actions() {
|
|||||||
std::env::set_var("DEEPSEEK_MODEL", "deepseek-chat");
|
std::env::set_var("DEEPSEEK_MODEL", "deepseek-chat");
|
||||||
|
|
||||||
let workspace_root = temp_workspace_root();
|
let workspace_root = temp_workspace_root();
|
||||||
|
let settings = DeepSeekSettings::from_env().unwrap();
|
||||||
let transport = Arc::new(MockTransport::new(vec![
|
let transport = Arc::new(MockTransport::new(vec![
|
||||||
BrowserMessage::Response {
|
BrowserMessage::Response {
|
||||||
seq: 1,
|
seq: 1,
|
||||||
@@ -211,6 +233,7 @@ fn compat_runtime_uses_zeroclaw_provider_path_and_executes_browser_actions() {
|
|||||||
browser_tool,
|
browser_tool,
|
||||||
"打开百度搜索天气",
|
"打开百度搜索天气",
|
||||||
&workspace_root,
|
&workspace_root,
|
||||||
|
&settings,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
server_handle.join().unwrap();
|
server_handle.join().unwrap();
|
||||||
@@ -255,7 +278,151 @@ fn compat_runtime_uses_zeroclaw_provider_path_and_executes_browser_actions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn handle_browser_message_uses_compat_runtime_summary_when_deepseek_env_is_set() {
|
fn handle_browser_message_prefers_compat_runtime_for_supported_instruction_when_deepseek_is_configured() {
|
||||||
|
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||||
|
|
||||||
|
let first_response = json!({
|
||||||
|
"choices": [{
|
||||||
|
"message": {
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call_1",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "browser_action",
|
||||||
|
"arguments": serde_json::to_string(&json!({
|
||||||
|
"action": "navigate",
|
||||||
|
"expected_domain": "www.baidu.com",
|
||||||
|
"url": "https://www.baidu.com"
|
||||||
|
})).unwrap()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "call_2",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "browser_action",
|
||||||
|
"arguments": serde_json::to_string(&json!({
|
||||||
|
"action": "type",
|
||||||
|
"expected_domain": "www.baidu.com",
|
||||||
|
"selector": "#kw",
|
||||||
|
"text": "天气",
|
||||||
|
"clear_first": true
|
||||||
|
})).unwrap()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "call_3",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "browser_action",
|
||||||
|
"arguments": serde_json::to_string(&json!({
|
||||||
|
"action": "click",
|
||||||
|
"expected_domain": "www.baidu.com",
|
||||||
|
"selector": "#su"
|
||||||
|
})).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
let second_response = json!({
|
||||||
|
"choices": [{
|
||||||
|
"message": {
|
||||||
|
"content": "已通过 DeepSeek 执行任务: 打开百度搜索天气"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
let (base_url, requests, server_handle) =
|
||||||
|
start_fake_deepseek_server(vec![first_response, second_response]);
|
||||||
|
|
||||||
|
std::env::remove_var("DEEPSEEK_API_KEY");
|
||||||
|
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||||||
|
std::env::remove_var("DEEPSEEK_MODEL");
|
||||||
|
|
||||||
|
let workspace_root = temp_workspace_root();
|
||||||
|
let config_path = write_deepseek_config(
|
||||||
|
&workspace_root,
|
||||||
|
"deepseek-test-key",
|
||||||
|
&base_url,
|
||||||
|
"deepseek-chat",
|
||||||
|
);
|
||||||
|
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||||
|
|
||||||
|
let transport = Arc::new(MockTransport::new(vec![
|
||||||
|
BrowserMessage::Response {
|
||||||
|
seq: 1,
|
||||||
|
success: true,
|
||||||
|
data: json!({ "navigated": true }),
|
||||||
|
aom_snapshot: vec![],
|
||||||
|
timing: Timing {
|
||||||
|
queue_ms: 1,
|
||||||
|
exec_ms: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
BrowserMessage::Response {
|
||||||
|
seq: 2,
|
||||||
|
success: true,
|
||||||
|
data: json!({ "typed": true }),
|
||||||
|
aom_snapshot: vec![],
|
||||||
|
timing: Timing {
|
||||||
|
queue_ms: 1,
|
||||||
|
exec_ms: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
BrowserMessage::Response {
|
||||||
|
seq: 3,
|
||||||
|
success: true,
|
||||||
|
data: json!({ "clicked": true }),
|
||||||
|
aom_snapshot: vec![],
|
||||||
|
timing: Timing {
|
||||||
|
queue_ms: 1,
|
||||||
|
exec_ms: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]));
|
||||||
|
let browser_tool = BrowserPipeTool::new(
|
||||||
|
transport.clone(),
|
||||||
|
test_policy(),
|
||||||
|
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||||
|
)
|
||||||
|
.with_response_timeout(Duration::from_secs(1));
|
||||||
|
|
||||||
|
handle_browser_message_with_context(
|
||||||
|
transport.as_ref(),
|
||||||
|
&browser_tool,
|
||||||
|
&runtime_context,
|
||||||
|
BrowserMessage::SubmitTask {
|
||||||
|
instruction: "打开百度搜索天气".to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
server_handle.join().unwrap();
|
||||||
|
|
||||||
|
let sent = transport.sent_messages();
|
||||||
|
let request_bodies = requests.lock().unwrap().clone();
|
||||||
|
|
||||||
|
assert!(sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::TaskComplete { success, summary }
|
||||||
|
if *success && summary == "已通过 DeepSeek 执行任务: 打开百度搜索天气"
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
assert!(sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::LogEntry { level, message }
|
||||||
|
if level == "mode" && message == "compat_llm_primary"
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
assert_eq!(request_bodies.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handle_browser_message_falls_back_to_compat_runtime_for_unsupported_instruction() {
|
||||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||||
|
|
||||||
let first_response = json!({
|
let first_response = json!({
|
||||||
@@ -284,7 +451,8 @@ fn handle_browser_message_uses_compat_runtime_summary_when_deepseek_env_is_set()
|
|||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
let (base_url, _, server_handle) = start_fake_deepseek_server(vec![first_response, second_response]);
|
let (base_url, requests, server_handle) =
|
||||||
|
start_fake_deepseek_server(vec![first_response, second_response]);
|
||||||
|
|
||||||
std::env::set_var("DEEPSEEK_API_KEY", "deepseek-test-key");
|
std::env::set_var("DEEPSEEK_API_KEY", "deepseek-test-key");
|
||||||
std::env::set_var("DEEPSEEK_BASE_URL", base_url);
|
std::env::set_var("DEEPSEEK_BASE_URL", base_url);
|
||||||
@@ -315,7 +483,7 @@ fn handle_browser_message_uses_compat_runtime_summary_when_deepseek_env_is_set()
|
|||||||
transport.as_ref(),
|
transport.as_ref(),
|
||||||
&browser_tool,
|
&browser_tool,
|
||||||
BrowserMessage::SubmitTask {
|
BrowserMessage::SubmitTask {
|
||||||
instruction: "打开百度搜索天气".to_string(),
|
instruction: "帮我打开百度首页".to_string(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -323,6 +491,7 @@ fn handle_browser_message_uses_compat_runtime_summary_when_deepseek_env_is_set()
|
|||||||
std::env::set_current_dir(original_dir).unwrap();
|
std::env::set_current_dir(original_dir).unwrap();
|
||||||
|
|
||||||
let sent = transport.sent_messages();
|
let sent = transport.sent_messages();
|
||||||
|
let request_bodies = requests.lock().unwrap().clone();
|
||||||
|
|
||||||
assert!(sent.iter().any(|message| {
|
assert!(sent.iter().any(|message| {
|
||||||
matches!(
|
matches!(
|
||||||
@@ -331,4 +500,12 @@ fn handle_browser_message_uses_compat_runtime_summary_when_deepseek_env_is_set()
|
|||||||
if *success && summary == "来自 ZeroClaw runtime"
|
if *success && summary == "来自 ZeroClaw runtime"
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
|
assert!(sent.iter().any(|message| {
|
||||||
|
matches!(
|
||||||
|
message,
|
||||||
|
AgentMessage::LogEntry { level, message }
|
||||||
|
if level == "mode" && message == "compat_llm_primary"
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
assert_eq!(request_bodies.len(), 2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,39 +74,44 @@ fn submit_task_sends_three_commands_and_finishes_with_task_complete() {
|
|||||||
|
|
||||||
let sent = transport.sent_messages();
|
let sent = transport.sent_messages();
|
||||||
|
|
||||||
assert_eq!(sent.len(), 7);
|
assert_eq!(sent.len(), 8);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
&sent[0],
|
&sent[0],
|
||||||
|
AgentMessage::LogEntry { level, message }
|
||||||
|
if level == "mode" && message == "deterministic_planner"
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
&sent[1],
|
||||||
AgentMessage::LogEntry { level, message }
|
AgentMessage::LogEntry { level, message }
|
||||||
if level == "info" && message == "navigate https://www.baidu.com"
|
if level == "info" && message == "navigate https://www.baidu.com"
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
&sent[1],
|
&sent[2],
|
||||||
AgentMessage::Command { seq, action, .. }
|
AgentMessage::Command { seq, action, .. }
|
||||||
if *seq == 1 && action == &Action::Navigate
|
if *seq == 1 && action == &Action::Navigate
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
&sent[2],
|
&sent[3],
|
||||||
AgentMessage::LogEntry { level, message }
|
AgentMessage::LogEntry { level, message }
|
||||||
if level == "info" && message == "type 天气 into #kw"
|
if level == "info" && message == "type 天气 into #kw"
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
&sent[3],
|
&sent[4],
|
||||||
AgentMessage::Command { seq, action, .. }
|
AgentMessage::Command { seq, action, .. }
|
||||||
if *seq == 2 && action == &Action::Type
|
if *seq == 2 && action == &Action::Type
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
&sent[4],
|
&sent[5],
|
||||||
AgentMessage::LogEntry { level, message }
|
AgentMessage::LogEntry { level, message }
|
||||||
if level == "info" && message == "click #su"
|
if level == "info" && message == "click #su"
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
&sent[5],
|
&sent[6],
|
||||||
AgentMessage::Command { seq, action, .. }
|
AgentMessage::Command { seq, action, .. }
|
||||||
if *seq == 3 && action == &Action::Click
|
if *seq == 3 && action == &Action::Click
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
&sent[6],
|
&sent[7],
|
||||||
AgentMessage::TaskComplete { success, summary }
|
AgentMessage::TaskComplete { success, summary }
|
||||||
if *success && summary == "已在百度搜索天气"
|
if *success && summary == "已在百度搜索天气"
|
||||||
));
|
));
|
||||||
|
|||||||
Reference in New Issue
Block a user