Add registry-driven scene routing and multi-root skill loading so fault-details and 95598 scene skills can be triggered from natural language while still running through the browser-backed runtime. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
422 lines
14 KiB
Rust
422 lines
14 KiB
Rust
use std::ffi::OsString;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use crate::browser::BrowserBackend;
|
|
use crate::compat::config_adapter::resolve_skills_dir_from_sgclaw_settings;
|
|
use crate::compat::runtime::CompatTaskContext;
|
|
use crate::config::SgClawSettings;
|
|
use crate::pipe::{
|
|
AgentMessage, BrowserPipeTool, ConversationMessage, PipeError, Transport,
|
|
};
|
|
use crate::runtime::RuntimeEngine;
|
|
|
|
#[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(resolve_process_path(PathBuf::from(value)));
|
|
continue;
|
|
}
|
|
|
|
let arg_string = arg.to_string_lossy();
|
|
if let Some(value) = arg_string.strip_prefix("--config-path=") {
|
|
config_path = Some(resolve_process_path(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))
|
|
}
|
|
|
|
pub(crate) 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 resolve_process_path(path: PathBuf) -> PathBuf {
|
|
if path.is_absolute() {
|
|
path
|
|
} else {
|
|
default_workspace_root().join(path)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn from_process_args_resolves_relative_config_path_against_current_dir() {
|
|
let current_dir = std::env::current_dir().unwrap();
|
|
let context = AgentRuntimeContext::from_process_args([
|
|
OsString::from("sg_claw"),
|
|
OsString::from("--config-path"),
|
|
OsString::from("../tmp/sgclaw_config.json"),
|
|
])
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
context.config_path,
|
|
Some(current_dir.join("../tmp/sgclaw_config.json"))
|
|
);
|
|
assert_eq!(context.workspace_root, current_dir.join("../tmp"));
|
|
assert!(context.workspace_root.is_absolute());
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct SubmitTaskRequest {
|
|
pub instruction: String,
|
|
pub conversation_id: Option<String>,
|
|
pub messages: Vec<ConversationMessage>,
|
|
pub page_url: Option<String>,
|
|
pub page_title: Option<String>,
|
|
}
|
|
|
|
pub trait AgentEventSink: Send + Sync {
|
|
fn send(&self, message: &AgentMessage) -> Result<(), PipeError>;
|
|
}
|
|
|
|
impl<T: Transport + ?Sized> AgentEventSink for T {
|
|
fn send(&self, message: &AgentMessage) -> Result<(), PipeError> {
|
|
Transport::send(self, message)
|
|
}
|
|
}
|
|
|
|
pub fn run_submit_task<T: Transport + 'static>(
|
|
transport: &T,
|
|
sink: &dyn AgentEventSink,
|
|
browser_tool: &BrowserPipeTool<T>,
|
|
context: &AgentRuntimeContext,
|
|
request: SubmitTaskRequest,
|
|
) -> Result<(), PipeError> {
|
|
let SubmitTaskRequest {
|
|
instruction,
|
|
conversation_id,
|
|
messages,
|
|
page_url,
|
|
page_title,
|
|
} = request;
|
|
let instruction = instruction.trim().to_string();
|
|
if instruction.is_empty() {
|
|
return sink.send(&AgentMessage::TaskComplete {
|
|
success: false,
|
|
summary: "请输入任务内容。".to_string(),
|
|
});
|
|
}
|
|
|
|
let task_context = CompatTaskContext {
|
|
conversation_id,
|
|
messages,
|
|
page_url,
|
|
page_title,
|
|
};
|
|
let _ = sink.send(&AgentMessage::LogEntry {
|
|
level: "info".to_string(),
|
|
message: runtime_version_log_message(),
|
|
});
|
|
if !task_context.messages.is_empty() {
|
|
let _ = sink.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_dirs =
|
|
resolve_skills_dir_from_sgclaw_settings(&context.workspace_root, &settings);
|
|
let _ = sink.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 _ = sink.send(&AgentMessage::LogEntry {
|
|
level: "info".to_string(),
|
|
message: format!("skills dirs resolved to [{}]", resolved_skills_dirs.iter().map(|d| d.display().to_string()).collect::<Vec<_>>().join(", ")),
|
|
});
|
|
let _ = sink.send(&AgentMessage::LogEntry {
|
|
level: "info".to_string(),
|
|
message: format!(
|
|
"runtime profile={:?} skills_prompt_mode={:?}",
|
|
settings.runtime_profile, settings.skills_prompt_mode
|
|
),
|
|
});
|
|
if RuntimeEngine::new(settings.runtime_profile).browser_surface_enabled()
|
|
&& crate::compat::orchestration::should_use_primary_orchestration(
|
|
&instruction,
|
|
task_context.page_url.as_deref(),
|
|
task_context.page_title.as_deref(),
|
|
)
|
|
{
|
|
let _ = send_mode_log(sink, "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 sink.send(&AgentMessage::TaskComplete {
|
|
success: true,
|
|
summary,
|
|
});
|
|
}
|
|
Err(err) => {
|
|
return sink.send(&AgentMessage::TaskComplete {
|
|
success: false,
|
|
summary: err.to_string(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
let _ = send_mode_log(sink, "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 _ = sink.send(&AgentMessage::LogEntry {
|
|
level: "error".to_string(),
|
|
message: format!("failed to load DeepSeek config: {err}"),
|
|
});
|
|
AgentMessage::TaskComplete {
|
|
success: false,
|
|
summary: err.to_string(),
|
|
}
|
|
}
|
|
};
|
|
|
|
sink.send(&completion)
|
|
}
|
|
|
|
pub fn run_submit_task_with_browser_backend<T: Transport + 'static>(
|
|
_transport: &T,
|
|
sink: &dyn AgentEventSink,
|
|
browser_backend: Arc<dyn BrowserBackend>,
|
|
context: &AgentRuntimeContext,
|
|
request: SubmitTaskRequest,
|
|
) -> Result<(), PipeError> {
|
|
let SubmitTaskRequest {
|
|
instruction,
|
|
conversation_id,
|
|
messages,
|
|
page_url,
|
|
page_title,
|
|
} = request;
|
|
let instruction = instruction.trim().to_string();
|
|
if instruction.is_empty() {
|
|
return sink.send(&AgentMessage::TaskComplete {
|
|
success: false,
|
|
summary: "请输入任务内容。".to_string(),
|
|
});
|
|
}
|
|
|
|
let task_context = CompatTaskContext {
|
|
conversation_id,
|
|
messages,
|
|
page_url,
|
|
page_title,
|
|
};
|
|
let _ = sink.send(&AgentMessage::LogEntry {
|
|
level: "info".to_string(),
|
|
message: runtime_version_log_message(),
|
|
});
|
|
if !task_context.messages.is_empty() {
|
|
let _ = sink.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_dirs =
|
|
resolve_skills_dir_from_sgclaw_settings(&context.workspace_root, &settings);
|
|
let _ = sink.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 _ = sink.send(&AgentMessage::LogEntry {
|
|
level: "info".to_string(),
|
|
message: format!("skills dirs resolved to [{}]", resolved_skills_dirs.iter().map(|d| d.display().to_string()).collect::<Vec<_>>().join(", ")),
|
|
});
|
|
let _ = sink.send(&AgentMessage::LogEntry {
|
|
level: "info".to_string(),
|
|
message: format!(
|
|
"runtime profile={:?} skills_prompt_mode={:?}",
|
|
settings.runtime_profile, settings.skills_prompt_mode
|
|
),
|
|
});
|
|
if RuntimeEngine::new(settings.runtime_profile).browser_surface_enabled()
|
|
&& crate::compat::orchestration::should_use_primary_orchestration(
|
|
&instruction,
|
|
task_context.page_url.as_deref(),
|
|
task_context.page_title.as_deref(),
|
|
)
|
|
{
|
|
let _ = send_mode_log(sink, "zeroclaw_process_message_primary");
|
|
match crate::compat::orchestration::execute_task_with_browser_backend(
|
|
sink,
|
|
browser_backend.clone(),
|
|
&instruction,
|
|
&task_context,
|
|
&context.workspace_root,
|
|
&settings,
|
|
) {
|
|
Ok(summary) => {
|
|
return sink.send(&AgentMessage::TaskComplete {
|
|
success: true,
|
|
summary,
|
|
});
|
|
}
|
|
Err(err) => {
|
|
return sink.send(&AgentMessage::TaskComplete {
|
|
success: false,
|
|
summary: err.to_string(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
let _ = send_mode_log(sink, "compat_llm_primary");
|
|
match crate::compat::runtime::execute_task_with_browser_backend(
|
|
sink,
|
|
browser_backend,
|
|
&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 _ = sink.send(&AgentMessage::LogEntry {
|
|
level: "error".to_string(),
|
|
message: format!("failed to load DeepSeek config: {err}"),
|
|
});
|
|
AgentMessage::TaskComplete {
|
|
success: false,
|
|
summary: err.to_string(),
|
|
}
|
|
}
|
|
};
|
|
|
|
sink.send(&completion)
|
|
}
|
|
|
|
fn send_mode_log(sink: &dyn AgentEventSink, mode: &str) -> Result<(), PipeError> {
|
|
sink.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
|
|
)
|
|
}
|