313 lines
9.6 KiB
Rust
313 lines
9.6 KiB
Rust
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use async_trait::async_trait;
|
|
use futures_util::{stream, StreamExt};
|
|
use zeroclaw::agent::TurnEvent;
|
|
use zeroclaw::config::Config as ZeroClawConfig;
|
|
use zeroclaw::providers::{
|
|
self, ChatMessage, ChatRequest, ChatResponse, Provider,
|
|
};
|
|
use zeroclaw::providers::traits::{
|
|
ProviderCapabilities, StreamEvent, StreamOptions, StreamResult,
|
|
};
|
|
|
|
use crate::compat::browser_script_skill_tool::build_browser_script_skill_tools;
|
|
use crate::compat::browser_tool_adapter::ZeroClawBrowserTool;
|
|
use crate::compat::config_adapter::{
|
|
build_zeroclaw_config_from_sgclaw_settings,
|
|
resolve_skills_dir_from_sgclaw_settings,
|
|
};
|
|
use crate::compat::openxml_office_tool::OpenXmlOfficeTool;
|
|
use crate::compat::screen_html_export_tool::ScreenHtmlExportTool;
|
|
use crate::config::{DeepSeekSettings, OfficeBackend, SgClawSettings};
|
|
use crate::compat::event_bridge::log_entry_for_turn_event;
|
|
use crate::pipe::{BrowserPipeTool, ConversationMessage, PipeError, Transport};
|
|
use crate::runtime::RuntimeEngine;
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct CompatTaskContext {
|
|
pub conversation_id: Option<String>,
|
|
pub messages: Vec<ConversationMessage>,
|
|
pub page_url: Option<String>,
|
|
pub page_title: Option<String>,
|
|
}
|
|
|
|
pub fn execute_task<T: Transport + 'static>(
|
|
transport: &T,
|
|
browser_tool: BrowserPipeTool<T>,
|
|
instruction: &str,
|
|
task_context: &CompatTaskContext,
|
|
workspace_root: &Path,
|
|
settings: &DeepSeekSettings,
|
|
) -> Result<String, PipeError> {
|
|
let sgclaw_settings = SgClawSettings::from(settings);
|
|
execute_task_with_sgclaw_settings(
|
|
transport,
|
|
browser_tool,
|
|
instruction,
|
|
task_context,
|
|
workspace_root,
|
|
&sgclaw_settings,
|
|
)
|
|
}
|
|
|
|
pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
|
transport: &T,
|
|
browser_tool: BrowserPipeTool<T>,
|
|
instruction: &str,
|
|
task_context: &CompatTaskContext,
|
|
workspace_root: &Path,
|
|
settings: &SgClawSettings,
|
|
) -> Result<String, PipeError> {
|
|
let config = build_zeroclaw_config_from_sgclaw_settings(workspace_root, settings);
|
|
let skills_dir = resolve_skills_dir_from_sgclaw_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}")))?;
|
|
|
|
runtime.block_on(execute_task_with_provider(
|
|
transport,
|
|
browser_tool,
|
|
provider,
|
|
instruction,
|
|
task_context,
|
|
config,
|
|
skills_dir,
|
|
settings.clone(),
|
|
))
|
|
}
|
|
|
|
pub async fn execute_task_with_provider<T: Transport + 'static>(
|
|
transport: &T,
|
|
browser_tool: BrowserPipeTool<T>,
|
|
provider: Box<dyn Provider>,
|
|
instruction: &str,
|
|
task_context: &CompatTaskContext,
|
|
config: ZeroClawConfig,
|
|
skills_dir: PathBuf,
|
|
settings: SgClawSettings,
|
|
) -> Result<String, PipeError> {
|
|
let engine = RuntimeEngine::new(settings.runtime_profile);
|
|
let browser_surface_present = engine.browser_surface_enabled();
|
|
if let Some(preview) = crate::agent::planner::build_execution_preview(
|
|
settings.planner_mode,
|
|
instruction,
|
|
task_context.page_url.as_deref(),
|
|
task_context.page_title.as_deref(),
|
|
) {
|
|
let mut message = preview.summary;
|
|
if !preview.steps.is_empty() {
|
|
message.push('\n');
|
|
message.push_str(&preview.steps.join("\n"));
|
|
}
|
|
transport.send(&crate::pipe::AgentMessage::LogEntry {
|
|
level: "plan".to_string(),
|
|
message,
|
|
})?;
|
|
}
|
|
let loaded_skills = engine.loaded_skills(&config, &skills_dir);
|
|
let loaded_skill_versions = loaded_skills
|
|
.iter()
|
|
.map(|skill| (skill.name.clone(), skill.version.clone()))
|
|
.collect::<HashMap<_, _>>();
|
|
let loaded_skill_labels = loaded_skills
|
|
.iter()
|
|
.map(|skill| format!("{}@{}", skill.name, skill.version))
|
|
.collect::<Vec<_>>();
|
|
if !loaded_skill_labels.is_empty() {
|
|
transport.send(&crate::pipe::AgentMessage::LogEntry {
|
|
level: "info".to_string(),
|
|
message: format!("loaded skills: {}", loaded_skill_labels.join(", ")),
|
|
})?;
|
|
}
|
|
let browser_tool_for_scripts = browser_tool.clone();
|
|
let mut tools: Vec<Box<dyn zeroclaw::tools::Tool>> = if browser_surface_present {
|
|
vec![
|
|
Box::new(ZeroClawBrowserTool::new_superrpa(browser_tool.clone())),
|
|
Box::new(ZeroClawBrowserTool::new(browser_tool)),
|
|
]
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
if browser_surface_present {
|
|
tools.extend(
|
|
build_browser_script_skill_tools(&loaded_skills, browser_tool_for_scripts)
|
|
.map_err(map_anyhow_to_pipe_error)?,
|
|
);
|
|
}
|
|
if matches!(settings.office_backend, OfficeBackend::OpenXml) &&
|
|
engine.should_attach_openxml_office_tool(instruction)
|
|
{
|
|
tools.push(Box::new(OpenXmlOfficeTool::new(config.workspace_dir.clone())));
|
|
}
|
|
if engine.should_attach_screen_html_export_tool(instruction) {
|
|
tools.push(Box::new(ScreenHtmlExportTool::new(config.workspace_dir.clone())));
|
|
}
|
|
let mut agent = engine.build_agent(
|
|
provider,
|
|
&config,
|
|
&skills_dir,
|
|
tools,
|
|
browser_surface_present,
|
|
instruction,
|
|
)?;
|
|
if let Some(conversation_id) = task_context
|
|
.conversation_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
{
|
|
agent.set_memory_session_id(Some(conversation_id.to_string()));
|
|
}
|
|
|
|
let mut seed_messages = Vec::new();
|
|
seed_messages.extend(build_seed_history(task_context));
|
|
if !seed_messages.is_empty() {
|
|
agent.seed_history(&seed_messages);
|
|
}
|
|
|
|
let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<TurnEvent>(32);
|
|
let instruction = engine.build_instruction(
|
|
instruction,
|
|
task_context.page_url.as_deref(),
|
|
task_context.page_title.as_deref(),
|
|
browser_surface_present,
|
|
);
|
|
|
|
let task = tokio::spawn(async move { agent.turn_streamed(&instruction, event_tx).await });
|
|
|
|
while let Some(event) = event_rx.recv().await {
|
|
if let Some(log_entry) = log_entry_for_turn_event(&event, &loaded_skill_versions) {
|
|
transport.send(&log_entry)?;
|
|
}
|
|
}
|
|
|
|
task.await
|
|
.map_err(|err| PipeError::Protocol(format!("zeroclaw task join failed: {err}")))?
|
|
.map_err(|err| PipeError::Protocol(err.to_string()))
|
|
}
|
|
|
|
fn build_provider(config: &ZeroClawConfig) -> Result<Box<dyn Provider>, PipeError> {
|
|
let provider_name = config.default_provider.as_deref().unwrap_or("deepseek");
|
|
let model_name = config
|
|
.default_model
|
|
.as_deref()
|
|
.unwrap_or("deepseek-chat");
|
|
let runtime_options = providers::provider_runtime_options_from_config(config);
|
|
let resolved_provider_name = if provider_name == "deepseek" {
|
|
config
|
|
.api_url
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|url| !url.is_empty())
|
|
.map(|url| format!("custom:{url}"))
|
|
.unwrap_or_else(|| provider_name.to_string())
|
|
} else {
|
|
provider_name.to_string()
|
|
};
|
|
let provider = providers::create_routed_provider_with_options(
|
|
&resolved_provider_name,
|
|
config.api_key.as_deref(),
|
|
config.api_url.as_deref(),
|
|
&config.reliability,
|
|
&config.model_routes,
|
|
model_name,
|
|
&runtime_options,
|
|
)
|
|
.map_err(map_anyhow_to_pipe_error)?;
|
|
|
|
Ok(Box::new(NonStreamingProvider::new(provider)))
|
|
}
|
|
|
|
fn map_anyhow_to_pipe_error(err: anyhow::Error) -> PipeError {
|
|
PipeError::Protocol(err.to_string())
|
|
}
|
|
|
|
struct NonStreamingProvider {
|
|
inner: Box<dyn Provider>,
|
|
}
|
|
|
|
impl NonStreamingProvider {
|
|
fn new(inner: Box<dyn Provider>) -> Self {
|
|
Self { inner }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Provider for NonStreamingProvider {
|
|
fn capabilities(&self) -> ProviderCapabilities {
|
|
self.inner.capabilities()
|
|
}
|
|
|
|
async fn chat_with_system(
|
|
&self,
|
|
system_prompt: Option<&str>,
|
|
message: &str,
|
|
model: &str,
|
|
temperature: f64,
|
|
) -> anyhow::Result<String> {
|
|
self.inner
|
|
.chat_with_system(system_prompt, message, model, temperature)
|
|
.await
|
|
}
|
|
|
|
async fn chat_with_history(
|
|
&self,
|
|
messages: &[ChatMessage],
|
|
model: &str,
|
|
temperature: f64,
|
|
) -> anyhow::Result<String> {
|
|
self.inner.chat_with_history(messages, model, temperature).await
|
|
}
|
|
|
|
async fn chat(
|
|
&self,
|
|
request: ChatRequest<'_>,
|
|
model: &str,
|
|
temperature: f64,
|
|
) -> anyhow::Result<ChatResponse> {
|
|
self.inner.chat(request, model, temperature).await
|
|
}
|
|
|
|
fn supports_streaming(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
fn supports_streaming_tool_events(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
fn stream_chat(
|
|
&self,
|
|
_request: ChatRequest<'_>,
|
|
_model: &str,
|
|
_temperature: f64,
|
|
_options: StreamOptions,
|
|
) -> stream::BoxStream<'static, StreamResult<StreamEvent>> {
|
|
stream::empty().boxed()
|
|
}
|
|
}
|
|
|
|
fn build_seed_history(task_context: &CompatTaskContext) -> Vec<ChatMessage> {
|
|
task_context
|
|
.messages
|
|
.iter()
|
|
.filter_map(to_chat_message)
|
|
.collect()
|
|
}
|
|
|
|
fn to_chat_message(message: &ConversationMessage) -> Option<ChatMessage> {
|
|
let content = message.content.trim();
|
|
if content.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
match message.role.as_str() {
|
|
"user" => Some(ChatMessage::user(content)),
|
|
"assistant" => Some(ChatMessage::assistant(content)),
|
|
"system" => Some(ChatMessage::system(content)),
|
|
_ => None,
|
|
}
|
|
}
|