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:
木炎
2026-04-04 23:42:27 +08:00
parent 2ae71fb1c9
commit 3e18350320
33 changed files with 4993 additions and 327 deletions

View File

@@ -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())
);
}
}