feat: add websocket browser service runtime
Wire the service/browser runtime onto the websocket-driven execution path and add the new browser/service modules needed for the submit flow and runtime integration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
290
src/agent/mod.rs
290
src/agent/mod.rs
@@ -1,104 +1,17 @@
|
||||
pub mod planner;
|
||||
pub mod runtime;
|
||||
pub mod task_runner;
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::compat::config_adapter::resolve_skills_dir_from_sgclaw_settings;
|
||||
use crate::compat::runtime::CompatTaskContext;
|
||||
use crate::config::SgClawSettings;
|
||||
use crate::browser::ws_backend::WsBrowserBackend;
|
||||
use crate::browser::{BrowserBackend, PipeBrowserBackend};
|
||||
use crate::pipe::{AgentMessage, BrowserMessage, BrowserPipeTool, PipeError, 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_sgclaw_settings(&self) -> Result<Option<SgClawSettings>, PipeError> {
|
||||
SgClawSettings::load(self.config_path.as_deref())
|
||||
.map_err(|err| PipeError::Protocol(err.to_string()))
|
||||
}
|
||||
|
||||
fn settings_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 missing_llm_configuration_summary() -> String {
|
||||
"未配置大语言模型。请先在 sgclaw_config.json 或环境变量中配置 apiKey、baseUrl 与 model。"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn runtime_version_log_message() -> String {
|
||||
format!(
|
||||
"sgclaw runtime version={} protocol={}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
crate::pipe::protocol::PROTOCOL_VERSION
|
||||
)
|
||||
}
|
||||
pub use task_runner::{
|
||||
run_submit_task, run_submit_task_with_browser_backend, AgentEventSink, AgentRuntimeContext,
|
||||
SubmitTaskRequest,
|
||||
};
|
||||
|
||||
fn execute_plan<T: Transport>(
|
||||
transport: &T,
|
||||
@@ -127,6 +40,53 @@ fn execute_plan<T: Transport>(
|
||||
Ok(plan.summary.clone())
|
||||
}
|
||||
|
||||
fn normalize_optional_submit_field(value: String) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}
|
||||
|
||||
fn browser_backend_for_submit<T: Transport + 'static>(
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
context: &AgentRuntimeContext,
|
||||
request: &SubmitTaskRequest,
|
||||
) -> Result<Arc<dyn BrowserBackend>, PipeError> {
|
||||
if let Some(browser_ws_url) = configured_browser_ws_url(context) {
|
||||
return Ok(Arc::new(
|
||||
WsBrowserBackend::new(
|
||||
Arc::new(crate::service::browser_ws_client::ServiceWsClient::connect(
|
||||
&browser_ws_url,
|
||||
)?),
|
||||
browser_tool.mac_policy().clone(),
|
||||
crate::service::browser_ws_client::initial_request_url_for_submit_task(request),
|
||||
)
|
||||
.with_response_timeout(browser_tool.response_timeout()),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Arc::new(PipeBrowserBackend::from_inner(browser_tool.clone())))
|
||||
}
|
||||
|
||||
fn configured_browser_ws_url(context: &AgentRuntimeContext) -> Option<String> {
|
||||
std::env::var("SGCLAW_BROWSER_WS_URL")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| {
|
||||
context
|
||||
.load_sgclaw_settings()
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|settings| settings.browser_ws_url)
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn send_status_changed<T: Transport>(transport: &T, state: &str) -> Result<(), PipeError> {
|
||||
transport.send(&AgentMessage::StatusChanged {
|
||||
state: state.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn execute_task<T: Transport>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
@@ -157,6 +117,9 @@ pub fn handle_browser_message_with_context<T: Transport + 'static>(
|
||||
message: BrowserMessage,
|
||||
) -> Result<(), PipeError> {
|
||||
match message {
|
||||
BrowserMessage::Connect => send_status_changed(transport, "connected"),
|
||||
BrowserMessage::Start => send_status_changed(transport, "started"),
|
||||
BrowserMessage::Stop => send_status_changed(transport, "stopped"),
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction,
|
||||
conversation_id,
|
||||
@@ -164,124 +127,15 @@ pub fn handle_browser_message_with_context<T: Transport + 'static>(
|
||||
page_url,
|
||||
page_title,
|
||||
} => {
|
||||
let instruction = instruction.trim().to_string();
|
||||
if instruction.is_empty() {
|
||||
return transport.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: "请输入任务内容。".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let task_context = CompatTaskContext {
|
||||
conversation_id: (!conversation_id.trim().is_empty())
|
||||
.then_some(conversation_id.clone()),
|
||||
let request = SubmitTaskRequest {
|
||||
instruction,
|
||||
conversation_id: normalize_optional_submit_field(conversation_id),
|
||||
messages,
|
||||
page_url: (!page_url.trim().is_empty()).then_some(page_url),
|
||||
page_title: (!page_title.trim().is_empty()).then_some(page_title),
|
||||
page_url: normalize_optional_submit_field(page_url),
|
||||
page_title: normalize_optional_submit_field(page_title),
|
||||
};
|
||||
let _ = transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: runtime_version_log_message(),
|
||||
});
|
||||
if !task_context.messages.is_empty() {
|
||||
let _ = transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"continuing conversation with {} prior turns",
|
||||
task_context.messages.len()
|
||||
),
|
||||
});
|
||||
}
|
||||
let completion = match context.load_sgclaw_settings() {
|
||||
Ok(Some(settings)) => {
|
||||
let resolved_skills_dir =
|
||||
resolve_skills_dir_from_sgclaw_settings(&context.workspace_root, &settings);
|
||||
let _ = transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"DeepSeek config loaded from {} model={} base_url={}",
|
||||
context.settings_source_label(),
|
||||
settings.provider_model,
|
||||
settings.provider_base_url
|
||||
),
|
||||
});
|
||||
let _ = transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"skills dir resolved to {}",
|
||||
resolved_skills_dir.display()
|
||||
),
|
||||
});
|
||||
let _ = transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"runtime profile={:?} skills_prompt_mode={:?}",
|
||||
settings.runtime_profile, settings.skills_prompt_mode
|
||||
),
|
||||
});
|
||||
if crate::compat::orchestration::should_use_primary_orchestration(
|
||||
&instruction,
|
||||
task_context.page_url.as_deref(),
|
||||
task_context.page_title.as_deref(),
|
||||
) {
|
||||
let _ = send_mode_log(transport, "zeroclaw_process_message_primary");
|
||||
match crate::compat::orchestration::execute_task_with_sgclaw_settings(
|
||||
transport,
|
||||
browser_tool.clone(),
|
||||
&instruction,
|
||||
&task_context,
|
||||
&context.workspace_root,
|
||||
&settings,
|
||||
) {
|
||||
Ok(summary) => {
|
||||
return transport.send(&AgentMessage::TaskComplete {
|
||||
success: true,
|
||||
summary,
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
return transport.send(&AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = send_mode_log(transport, "compat_llm_primary");
|
||||
match crate::compat::runtime::execute_task_with_sgclaw_settings(
|
||||
transport,
|
||||
browser_tool.clone(),
|
||||
&instruction,
|
||||
&task_context,
|
||||
&context.workspace_root,
|
||||
&settings,
|
||||
) {
|
||||
Ok(summary) => AgentMessage::TaskComplete {
|
||||
success: true,
|
||||
summary,
|
||||
},
|
||||
Err(err) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: err.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(None) => AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: missing_llm_configuration_summary(),
|
||||
},
|
||||
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)
|
||||
let browser_backend = browser_backend_for_submit(browser_tool, context, &request)?;
|
||||
run_submit_task_with_browser_backend(transport, transport, browser_backend, context, request)
|
||||
}
|
||||
BrowserMessage::Init { .. } => {
|
||||
eprintln!("ignoring duplicate init after handshake");
|
||||
@@ -293,3 +147,17 @@ pub fn handle_browser_message_with_context<T: Transport + 'static>(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_optional_submit_field;
|
||||
|
||||
#[test]
|
||||
fn normalize_optional_submit_field_trims_and_drops_blank_values() {
|
||||
assert_eq!(normalize_optional_submit_field(" \n\t ".to_string()), None);
|
||||
assert_eq!(
|
||||
normalize_optional_submit_field(" https://example.com/page ".to_string()),
|
||||
Some("https://example.com/page".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user