sgclaw: snapshot today's runtime and skill updates
This commit is contained in:
@@ -7,9 +7,7 @@ use std::path::PathBuf;
|
||||
use crate::compat::config_adapter::resolve_skills_dir_from_sgclaw_settings;
|
||||
use crate::compat::runtime::CompatTaskContext;
|
||||
use crate::config::SgClawSettings;
|
||||
use crate::pipe::{
|
||||
AgentMessage, BrowserMessage, BrowserPipeTool, PipeError, Transport,
|
||||
};
|
||||
use crate::pipe::{AgentMessage, BrowserMessage, BrowserPipeTool, PipeError, Transport};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AgentRuntimeContext {
|
||||
@@ -218,8 +216,7 @@ pub fn handle_browser_message_with_context<T: Transport + 'static>(
|
||||
level: "info".to_string(),
|
||||
message: format!(
|
||||
"runtime profile={:?} skills_prompt_mode={:?}",
|
||||
settings.runtime_profile,
|
||||
settings.skills_prompt_mode
|
||||
settings.runtime_profile, settings.skills_prompt_mode
|
||||
),
|
||||
});
|
||||
if crate::compat::orchestration::should_use_primary_orchestration(
|
||||
|
||||
@@ -189,7 +189,10 @@ fn plan_zhihu_search(query: &str) -> TaskPlan {
|
||||
|
||||
fn build_zhihu_hotlist_preview(instruction: &str) -> ExecutionPreview {
|
||||
let normalized = instruction.to_ascii_lowercase();
|
||||
if normalized.contains("dashboard") || instruction.contains("大屏") || instruction.contains("新标签页") {
|
||||
if normalized.contains("dashboard")
|
||||
|| instruction.contains("大屏")
|
||||
|| instruction.contains("新标签页")
|
||||
{
|
||||
return ExecutionPreview {
|
||||
summary: "先规划再执行知乎热榜大屏生成".to_string(),
|
||||
steps: vec![
|
||||
|
||||
@@ -3,6 +3,10 @@ use serde_json::{json, Map, Value};
|
||||
use crate::llm::{ChatMessage, LlmError, LlmProvider, ToolDefinition, ToolFunctionCall};
|
||||
use crate::pipe::{Action, AgentMessage, BrowserPipeTool, PipeError, Transport};
|
||||
|
||||
/// Legacy browser-only runtime kept for dev-only validation and narrow regression coverage.
|
||||
/// Production browser submit flow uses `compat::runtime` plus `runtime::engine`.
|
||||
pub const LEGACY_DEV_ONLY: bool = true;
|
||||
|
||||
const BROWSER_ACTION_TOOL_NAME: &str = "browser_action";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -21,8 +25,7 @@ pub fn execute_task_with_provider<P: LlmProvider, T: Transport>(
|
||||
let messages = vec![
|
||||
ChatMessage {
|
||||
role: "system".to_string(),
|
||||
content: "You are sgClaw. Use browser_action to complete the browser task."
|
||||
.to_string(),
|
||||
content: "You are sgClaw. Use browser_action to complete the browser task.".to_string(),
|
||||
},
|
||||
ChatMessage {
|
||||
role: "user".to_string(),
|
||||
@@ -35,8 +38,8 @@ pub fn execute_task_with_provider<P: LlmProvider, T: Transport>(
|
||||
.map_err(map_llm_error_to_pipe_error)?;
|
||||
|
||||
for call in calls {
|
||||
let browser_call = parse_browser_action_call(call)
|
||||
.map_err(|err| PipeError::Protocol(err.to_string()))?;
|
||||
let browser_call =
|
||||
parse_browser_action_call(call).map_err(|err| PipeError::Protocol(err.to_string()))?;
|
||||
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
|
||||
@@ -26,10 +26,15 @@ impl<T: Transport> BrowserScriptSkillTool<T> {
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let script_path = skill_root.join(&tool.command);
|
||||
let canonical_skill_root = skill_root.canonicalize().unwrap_or_else(|_| skill_root.to_path_buf());
|
||||
let canonical_script_path = script_path
|
||||
let canonical_skill_root = skill_root
|
||||
.canonicalize()
|
||||
.map_err(|err| anyhow::anyhow!("failed to resolve browser script {}: {err}", script_path.display()))?;
|
||||
.unwrap_or_else(|_| skill_root.to_path_buf());
|
||||
let canonical_script_path = script_path.canonicalize().map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"failed to resolve browser script {}: {err}",
|
||||
script_path.display()
|
||||
)
|
||||
})?;
|
||||
if !canonical_script_path.starts_with(&canonical_skill_root) {
|
||||
anyhow::bail!(
|
||||
"browser script path escapes skill root: {}",
|
||||
@@ -108,7 +113,11 @@ impl<T: Transport + 'static> Tool for BrowserScriptSkillTool<T> {
|
||||
"expected_domain must be a non-empty string, got {other}"
|
||||
)))
|
||||
}
|
||||
None => return Ok(failed_tool_result("missing required field expected_domain".to_string())),
|
||||
None => {
|
||||
return Ok(failed_tool_result(
|
||||
"missing required field expected_domain".to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
let expected_domain = match normalize_domain_like(&raw_expected_domain) {
|
||||
Some(value) => value,
|
||||
@@ -148,7 +157,9 @@ impl<T: Transport + 'static> Tool for BrowserScriptSkillTool<T> {
|
||||
};
|
||||
|
||||
if !result.success {
|
||||
return Ok(failed_tool_result(format_browser_script_error(&result.data)));
|
||||
return Ok(failed_tool_result(format_browser_script_error(
|
||||
&result.data,
|
||||
)));
|
||||
}
|
||||
|
||||
let payload = result
|
||||
|
||||
@@ -101,14 +101,14 @@ impl<T: Transport + 'static> Tool for ZeroClawBrowserTool<T> {
|
||||
Err(err) => return Ok(failed_tool_result(err.to_string())),
|
||||
};
|
||||
|
||||
let result = match self.browser_tool.invoke(
|
||||
request.action,
|
||||
request.params,
|
||||
&request.expected_domain,
|
||||
) {
|
||||
Ok(result) => result,
|
||||
Err(err) => return Ok(failed_tool_result(err.to_string())),
|
||||
};
|
||||
let result =
|
||||
match self
|
||||
.browser_tool
|
||||
.invoke(request.action, request.params, &request.expected_domain)
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(err) => return Ok(failed_tool_result(err.to_string())),
|
||||
};
|
||||
|
||||
let output = serde_json::to_string(&json!({
|
||||
"seq": result.seq,
|
||||
@@ -122,8 +122,7 @@ impl<T: Transport + 'static> Tool for ZeroClawBrowserTool<T> {
|
||||
Ok(ToolResult {
|
||||
success: result.success,
|
||||
output,
|
||||
error: (!result.success)
|
||||
.then(|| format_browser_action_error(&result.data)),
|
||||
error: (!result.success).then(|| format_browser_action_error(&result.data)),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -134,7 +133,9 @@ struct BrowserActionRequest {
|
||||
params: Value,
|
||||
}
|
||||
|
||||
fn parse_browser_action_request(args: Value) -> Result<BrowserActionRequest, BrowserActionAdapterError> {
|
||||
fn parse_browser_action_request(
|
||||
args: Value,
|
||||
) -> Result<BrowserActionRequest, BrowserActionAdapterError> {
|
||||
let mut args = match args {
|
||||
Value::Object(args) => args,
|
||||
other => {
|
||||
|
||||
@@ -2,8 +2,8 @@ use std::collections::HashMap;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use zeroclaw::Config as ZeroClawConfig;
|
||||
use zeroclaw::config::schema::ModelProviderConfig;
|
||||
use zeroclaw::Config as ZeroClawConfig;
|
||||
|
||||
use crate::compat::cron_adapter::configure_embedded_cron;
|
||||
use crate::compat::memory_adapter::configure_embedded_memory;
|
||||
@@ -13,7 +13,9 @@ use crate::runtime::RuntimeProfile;
|
||||
const SGCLAW_ZEROCLAW_WORKSPACE_DIR: &str = ".sgclaw-zeroclaw-workspace";
|
||||
const SKILLS_DIR_NAME: &str = "skills";
|
||||
|
||||
pub fn build_zeroclaw_config(workspace_root: &Path) -> Result<ZeroClawConfig, crate::config::ConfigError> {
|
||||
pub fn build_zeroclaw_config(
|
||||
workspace_root: &Path,
|
||||
) -> Result<ZeroClawConfig, crate::config::ConfigError> {
|
||||
let settings = SgClawSettings::from_env()?;
|
||||
Ok(build_zeroclaw_config_from_sgclaw_settings(
|
||||
workspace_root,
|
||||
|
||||
@@ -65,7 +65,10 @@ where
|
||||
|
||||
for job in jobs {
|
||||
if !matches!(job.job_type, JobType::Agent) {
|
||||
anyhow::bail!("unsupported cron job type in sgclaw compat: {:?}", job.job_type);
|
||||
anyhow::bail!(
|
||||
"unsupported cron job type in sgclaw compat: {:?}",
|
||||
job.job_type
|
||||
);
|
||||
}
|
||||
|
||||
let started_at = Utc::now();
|
||||
|
||||
@@ -14,19 +14,17 @@ pub fn log_entry_for_turn_event(
|
||||
level: "info".to_string(),
|
||||
message: format_tool_call(name, args, skill_versions),
|
||||
}),
|
||||
TurnEvent::ToolResult { output, .. } if is_tool_error(output) => Some(AgentMessage::LogEntry {
|
||||
level: "error".to_string(),
|
||||
message: output.trim_start_matches("Error: ").to_string(),
|
||||
}),
|
||||
TurnEvent::ToolResult { output, .. } if is_tool_error(output) => {
|
||||
Some(AgentMessage::LogEntry {
|
||||
level: "error".to_string(),
|
||||
message: output.trim_start_matches("Error: ").to_string(),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_tool_call(
|
||||
name: &str,
|
||||
args: &Value,
|
||||
skill_versions: &HashMap<String, String>,
|
||||
) -> String {
|
||||
fn format_tool_call(name: &str, args: &Value, skill_versions: &HashMap<String, String>) -> String {
|
||||
if name == "read_skill" {
|
||||
let skill_name = args
|
||||
.get("name")
|
||||
@@ -49,7 +47,10 @@ fn format_tool_call(
|
||||
|
||||
match action {
|
||||
"navigate" => {
|
||||
let url = args.get("url").and_then(Value::as_str).unwrap_or("<missing-url>");
|
||||
let url = args
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("<missing-url>");
|
||||
format!("navigate {url}")
|
||||
}
|
||||
"type" => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
@@ -93,7 +93,11 @@ impl Tool for OpenXmlOfficeTool {
|
||||
return Ok(failed_tool_result("rows must not be empty".to_string()));
|
||||
}
|
||||
|
||||
if parsed.rows.iter().any(|row| row.len() != parsed.columns.len()) {
|
||||
if parsed
|
||||
.rows
|
||||
.iter()
|
||||
.any(|row| row.len() != parsed.columns.len())
|
||||
{
|
||||
return Ok(failed_tool_result(
|
||||
"each row must match the declared columns length".to_string(),
|
||||
));
|
||||
@@ -153,10 +157,10 @@ fn failed_tool_result(error: String) -> ToolResult {
|
||||
}
|
||||
|
||||
fn create_job_root(workspace_root: &Path) -> anyhow::Result<PathBuf> {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)?
|
||||
.as_nanos();
|
||||
let path = workspace_root.join(".sgclaw-openxml").join(format!("{nanos}"));
|
||||
let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
|
||||
let path = workspace_root
|
||||
.join(".sgclaw-openxml")
|
||||
.join(format!("{nanos}"));
|
||||
fs::create_dir_all(&path)?;
|
||||
Ok(path)
|
||||
}
|
||||
@@ -188,10 +192,7 @@ fn resolve_column_order(
|
||||
.iter()
|
||||
.map(|value| value.to_string())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let expected_set = expected_columns
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
let expected_set = expected_columns.iter().cloned().collect::<BTreeSet<_>>();
|
||||
|
||||
if provided_set != expected_set {
|
||||
return None;
|
||||
|
||||
@@ -9,6 +9,12 @@ pub fn should_use_primary_orchestration(
|
||||
page_url: Option<&str>,
|
||||
page_title: Option<&str>,
|
||||
) -> bool {
|
||||
if crate::compat::workflow_executor::detect_route(instruction, page_url, page_title)
|
||||
.is_some_and(|route| crate::compat::workflow_executor::prefers_direct_execution(&route))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
let normalized = instruction.to_ascii_lowercase();
|
||||
let needs_export = normalized.contains("excel")
|
||||
|| normalized.contains("xlsx")
|
||||
@@ -33,6 +39,18 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
||||
task_context.page_url.as_deref(),
|
||||
task_context.page_title.as_deref(),
|
||||
);
|
||||
if let Some(route) = route.clone() {
|
||||
if crate::compat::workflow_executor::prefers_direct_execution(&route) {
|
||||
return crate::compat::workflow_executor::execute_route(
|
||||
transport,
|
||||
&browser_tool,
|
||||
workspace_root,
|
||||
instruction,
|
||||
task_context,
|
||||
route,
|
||||
);
|
||||
}
|
||||
}
|
||||
let primary_result = crate::compat::runtime::execute_task_with_sgclaw_settings(
|
||||
transport,
|
||||
browser_tool.clone(),
|
||||
@@ -44,13 +62,16 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
||||
|
||||
match (route, primary_result) {
|
||||
(Some(route), Ok(summary))
|
||||
if crate::compat::workflow_executor::should_fallback_after_summary(&summary, &route) =>
|
||||
if crate::compat::workflow_executor::should_fallback_after_summary(
|
||||
&summary, &route,
|
||||
) =>
|
||||
{
|
||||
crate::compat::workflow_executor::execute_route(
|
||||
transport,
|
||||
&browser_tool,
|
||||
workspace_root,
|
||||
instruction,
|
||||
task_context,
|
||||
route,
|
||||
)
|
||||
}
|
||||
@@ -60,6 +81,7 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
||||
&browser_tool,
|
||||
workspace_root,
|
||||
instruction,
|
||||
task_context,
|
||||
route,
|
||||
),
|
||||
(None, Err(err)) => Err(err),
|
||||
|
||||
@@ -5,23 +5,18 @@ 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 zeroclaw::providers::traits::{ProviderCapabilities, StreamEvent, StreamOptions, StreamResult};
|
||||
use zeroclaw::providers::{self, ChatMessage, ChatRequest, ChatResponse, Provider};
|
||||
|
||||
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,
|
||||
build_zeroclaw_config_from_sgclaw_settings, resolve_skills_dir_from_sgclaw_settings,
|
||||
};
|
||||
use crate::compat::event_bridge::log_entry_for_turn_event;
|
||||
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;
|
||||
|
||||
@@ -136,13 +131,17 @@ pub async fn execute_task_with_provider<T: Transport + 'static>(
|
||||
.map_err(map_anyhow_to_pipe_error)?,
|
||||
);
|
||||
}
|
||||
if matches!(settings.office_backend, OfficeBackend::OpenXml) &&
|
||||
engine.should_attach_openxml_office_tool(instruction)
|
||||
if matches!(settings.office_backend, OfficeBackend::OpenXml)
|
||||
&& engine.should_attach_openxml_office_tool(instruction)
|
||||
{
|
||||
tools.push(Box::new(OpenXmlOfficeTool::new(config.workspace_dir.clone())));
|
||||
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())));
|
||||
tools.push(Box::new(ScreenHtmlExportTool::new(
|
||||
config.workspace_dir.clone(),
|
||||
)));
|
||||
}
|
||||
let mut agent = engine.build_agent(
|
||||
provider,
|
||||
@@ -190,10 +189,7 @@ pub async fn execute_task_with_provider<T: Transport + 'static>(
|
||||
|
||||
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 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
|
||||
@@ -258,7 +254,9 @@ impl Provider for NonStreamingProvider {
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
self.inner.chat_with_history(messages, model, temperature).await
|
||||
self.inner
|
||||
.chat_with_history(messages, model, temperature)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
|
||||
@@ -238,29 +238,40 @@ fn derive_categories(table: &[ScreenTableRow]) -> Vec<ScreenCategory> {
|
||||
|
||||
grouped
|
||||
.into_iter()
|
||||
.map(|((category_code, category_label), (item_count, total_heat))| ScreenCategory {
|
||||
category_code,
|
||||
category_label,
|
||||
item_count,
|
||||
total_heat,
|
||||
avg_heat: if item_count == 0 {
|
||||
0
|
||||
} else {
|
||||
total_heat / item_count
|
||||
.map(
|
||||
|((category_code, category_label), (item_count, total_heat))| ScreenCategory {
|
||||
category_code,
|
||||
category_label,
|
||||
item_count,
|
||||
total_heat,
|
||||
avg_heat: if item_count == 0 {
|
||||
0
|
||||
} else {
|
||||
total_heat / item_count
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn classify_title(title: &str) -> (&'static str, &'static str) {
|
||||
let normalized = title.to_ascii_lowercase();
|
||||
if contains_any(&normalized, &["ai", "芯片", "科技", "算法", "机器人", "无人机"]) {
|
||||
if contains_any(
|
||||
&normalized,
|
||||
&["ai", "芯片", "科技", "算法", "机器人", "无人机"],
|
||||
) {
|
||||
return ("technology", "科技");
|
||||
}
|
||||
if contains_any(&normalized, &["电影", "综艺", "明星", "周杰伦", "短剧", "娱乐"]) {
|
||||
if contains_any(
|
||||
&normalized,
|
||||
&["电影", "综艺", "明星", "周杰伦", "短剧", "娱乐"],
|
||||
) {
|
||||
return ("entertainment", "娱乐");
|
||||
}
|
||||
if contains_any(&normalized, &["足球", "比赛", "联赛", "国足", "体育", "冠军"]) {
|
||||
if contains_any(
|
||||
&normalized,
|
||||
&["足球", "比赛", "联赛", "国足", "体育", "冠军"],
|
||||
) {
|
||||
return ("sports", "体育");
|
||||
}
|
||||
if contains_any(&normalized, &["航母", "作战", "军", "军事", "演训"]) {
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use regex::Regex;
|
||||
use serde_json::{json, Value};
|
||||
use zeroclaw::tools::Tool;
|
||||
|
||||
use crate::compat::runtime::CompatTaskContext;
|
||||
use crate::compat::openxml_office_tool::OpenXmlOfficeTool;
|
||||
use crate::compat::runtime::CompatTaskContext;
|
||||
use crate::compat::screen_html_export_tool::ScreenHtmlExportTool;
|
||||
use crate::pipe::{
|
||||
Action,
|
||||
AgentMessage,
|
||||
BrowserPipeTool,
|
||||
ConversationMessage,
|
||||
PipeError,
|
||||
Transport,
|
||||
Action, AgentMessage, BrowserPipeTool, ConversationMessage, PipeError, Transport,
|
||||
};
|
||||
|
||||
const ZHIHU_DOMAIN: &str = "www.zhihu.com";
|
||||
@@ -22,6 +19,10 @@ const ZHIHU_EDITOR_DOMAIN: &str = "zhuanlan.zhihu.com";
|
||||
const ZHIHU_HOT_URL: &str = "https://www.zhihu.com/hot";
|
||||
const ZHIHU_CREATOR_URL: &str = "https://www.zhihu.com/creator";
|
||||
const ZHIHU_EDITOR_URL: &str = "https://zhuanlan.zhihu.com/write";
|
||||
const HOTLIST_READY_POLL_ATTEMPTS: usize = 10;
|
||||
const HOTLIST_READY_POLL_INTERVAL: Duration = Duration::from_millis(500);
|
||||
const HOTLIST_TEXT_READY_PATTERN: &str =
|
||||
r"(?:^|\n)\s*1(?:[.、]|\s)+.+\d+(?:\.\d+)?\s*(?:万|亿|k|K|m|M)(?:热度)?";
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WorkflowRoute {
|
||||
ZhihuHotlistExportXlsx,
|
||||
@@ -51,10 +52,16 @@ pub fn detect_route(
|
||||
) -> Option<WorkflowRoute> {
|
||||
if crate::runtime::is_zhihu_hotlist_task(instruction, page_url, page_title) {
|
||||
let normalized = instruction.to_ascii_lowercase();
|
||||
if normalized.contains("dashboard") || instruction.contains("大屏") || instruction.contains("新标签页") {
|
||||
if normalized.contains("dashboard")
|
||||
|| instruction.contains("大屏")
|
||||
|| instruction.contains("新标签页")
|
||||
{
|
||||
return Some(WorkflowRoute::ZhihuHotlistScreen);
|
||||
}
|
||||
if normalized.contains("excel") || normalized.contains("xlsx") || instruction.contains("导出") {
|
||||
if normalized.contains("excel")
|
||||
|| normalized.contains("xlsx")
|
||||
|| instruction.contains("导出")
|
||||
{
|
||||
return Some(WorkflowRoute::ZhihuHotlistExportXlsx);
|
||||
}
|
||||
}
|
||||
@@ -73,9 +80,11 @@ pub fn detect_route(
|
||||
pub fn prefers_direct_execution(route: &WorkflowRoute) -> bool {
|
||||
matches!(
|
||||
route,
|
||||
WorkflowRoute::ZhihuArticleEntry |
|
||||
WorkflowRoute::ZhihuArticleDraft |
|
||||
WorkflowRoute::ZhihuArticlePublish
|
||||
WorkflowRoute::ZhihuHotlistExportXlsx
|
||||
| WorkflowRoute::ZhihuHotlistScreen
|
||||
| WorkflowRoute::ZhihuArticleEntry
|
||||
| WorkflowRoute::ZhihuArticleDraft
|
||||
| WorkflowRoute::ZhihuArticlePublish
|
||||
)
|
||||
}
|
||||
|
||||
@@ -85,22 +94,23 @@ pub fn should_fallback_after_summary(summary: &str, route: &WorkflowRoute) -> bo
|
||||
return false;
|
||||
}
|
||||
|
||||
let looks_like_denial = summary.contains("拒绝") ||
|
||||
normalized.contains("denied") ||
|
||||
normalized.contains("failed") ||
|
||||
normalized.contains("protocol error") ||
|
||||
normalized.contains("maximum tool iterations") ||
|
||||
summary.contains("失败") ||
|
||||
summary.contains("无法");
|
||||
let looks_like_denial = summary.contains("拒绝")
|
||||
|| normalized.contains("denied")
|
||||
|| normalized.contains("failed")
|
||||
|| normalized.contains("protocol error")
|
||||
|| normalized.contains("maximum tool iterations")
|
||||
|| summary.contains("失败")
|
||||
|| summary.contains("无法");
|
||||
|
||||
looks_like_denial || matches!(
|
||||
route,
|
||||
WorkflowRoute::ZhihuHotlistExportXlsx |
|
||||
WorkflowRoute::ZhihuHotlistScreen |
|
||||
WorkflowRoute::ZhihuArticleEntry |
|
||||
WorkflowRoute::ZhihuArticleDraft |
|
||||
WorkflowRoute::ZhihuArticlePublish
|
||||
)
|
||||
looks_like_denial
|
||||
|| matches!(
|
||||
route,
|
||||
WorkflowRoute::ZhihuHotlistExportXlsx
|
||||
| WorkflowRoute::ZhihuHotlistScreen
|
||||
| WorkflowRoute::ZhihuArticleEntry
|
||||
| WorkflowRoute::ZhihuArticleDraft
|
||||
| WorkflowRoute::ZhihuArticlePublish
|
||||
)
|
||||
}
|
||||
|
||||
pub fn execute_route<T: Transport + 'static>(
|
||||
@@ -114,15 +124,19 @@ pub fn execute_route<T: Transport + 'static>(
|
||||
match route {
|
||||
WorkflowRoute::ZhihuHotlistExportXlsx | WorkflowRoute::ZhihuHotlistScreen => {
|
||||
let top_n = extract_top_n(instruction);
|
||||
let items = collect_hotlist_items(transport, browser_tool, top_n)?;
|
||||
let items = collect_hotlist_items(transport, browser_tool, top_n, task_context)?;
|
||||
if items.is_empty() {
|
||||
return Err(PipeError::Protocol(
|
||||
"知乎热榜采集失败:未能从页面文本中解析到热榜条目".to_string(),
|
||||
));
|
||||
}
|
||||
match route {
|
||||
WorkflowRoute::ZhihuHotlistExportXlsx => export_xlsx(transport, workspace_root, &items),
|
||||
WorkflowRoute::ZhihuHotlistScreen => export_screen(transport, workspace_root, &items),
|
||||
WorkflowRoute::ZhihuHotlistExportXlsx => {
|
||||
export_xlsx(transport, workspace_root, &items)
|
||||
}
|
||||
WorkflowRoute::ZhihuHotlistScreen => {
|
||||
export_screen(transport, workspace_root, &items)
|
||||
}
|
||||
_ => unreachable!("handled by outer match"),
|
||||
}
|
||||
}
|
||||
@@ -142,8 +156,9 @@ fn collect_hotlist_items<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
top_n: usize,
|
||||
task_context: &CompatTaskContext,
|
||||
) -> Result<Vec<HotlistItem>, PipeError> {
|
||||
navigate_hotlist_with_retry(transport, browser_tool)?;
|
||||
ensure_hotlist_page_ready(transport, browser_tool, task_context)?;
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: "call zhihu-hotlist.extract_hotlist".to_string(),
|
||||
@@ -168,35 +183,87 @@ fn collect_hotlist_items<T: Transport + 'static>(
|
||||
parse_hotlist_items_payload(response.data.get("text").unwrap_or(&response.data))
|
||||
}
|
||||
|
||||
fn navigate_hotlist_with_retry<T: Transport + 'static>(
|
||||
fn ensure_hotlist_page_ready<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
task_context: &CompatTaskContext,
|
||||
) -> Result<(), PipeError> {
|
||||
let starts_on_hotlist = task_context
|
||||
.page_url
|
||||
.as_deref()
|
||||
.is_some_and(|url| url.starts_with(ZHIHU_HOT_URL))
|
||||
|| task_context
|
||||
.page_title
|
||||
.as_deref()
|
||||
.is_some_and(|title| title.contains("热榜"));
|
||||
|
||||
if starts_on_hotlist && poll_for_hotlist_readiness(browser_tool)? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut last_error = None;
|
||||
for attempt in 0..2 {
|
||||
navigate_hotlist_page(transport, browser_tool)?;
|
||||
if poll_for_hotlist_readiness(browser_tool)? {
|
||||
return Ok(());
|
||||
}
|
||||
last_error = Some(PipeError::Protocol(format!(
|
||||
"知乎热榜页面已打开,但在短轮询窗口内仍未出现可读热榜内容(attempt={})",
|
||||
attempt + 1
|
||||
)));
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| PipeError::Protocol("知乎热榜页面未就绪".to_string())))
|
||||
}
|
||||
|
||||
fn navigate_hotlist_page<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
) -> Result<(), PipeError> {
|
||||
let mut last_error = None;
|
||||
for _ in 0..2 {
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!("navigate {ZHIHU_HOT_URL}"),
|
||||
})?;
|
||||
match browser_tool.invoke(
|
||||
Action::Navigate,
|
||||
json!({ "url": ZHIHU_HOT_URL }),
|
||||
ZHIHU_DOMAIN,
|
||||
) {
|
||||
Ok(response) if response.success => return Ok(()),
|
||||
Ok(response) => {
|
||||
last_error = Some(PipeError::Protocol(format!(
|
||||
"navigate failed: {}",
|
||||
response.data
|
||||
)));
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: format!("navigate {ZHIHU_HOT_URL}"),
|
||||
})?;
|
||||
let response = browser_tool.invoke(
|
||||
Action::Navigate,
|
||||
json!({ "url": ZHIHU_HOT_URL }),
|
||||
ZHIHU_DOMAIN,
|
||||
)?;
|
||||
if response.success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(PipeError::Protocol(format!(
|
||||
"navigate failed: {}",
|
||||
response.data
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_for_hotlist_readiness<T: Transport + 'static>(
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
) -> Result<bool, PipeError> {
|
||||
let ready_pattern =
|
||||
Regex::new(HOTLIST_TEXT_READY_PATTERN).expect("hotlist readiness regex must compile");
|
||||
for attempt in 0..HOTLIST_READY_POLL_ATTEMPTS {
|
||||
let response =
|
||||
browser_tool.invoke(Action::GetText, json!({ "selector": "body" }), ZHIHU_DOMAIN)?;
|
||||
if response.success {
|
||||
let payload = response.data.get("text").unwrap_or(&response.data);
|
||||
if hotlist_text_looks_ready(payload, &ready_pattern) {
|
||||
return Ok(true);
|
||||
}
|
||||
Err(err) => last_error = Some(err),
|
||||
}
|
||||
|
||||
if attempt + 1 < HOTLIST_READY_POLL_ATTEMPTS {
|
||||
thread::sleep(HOTLIST_READY_POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| {
|
||||
PipeError::Protocol("navigate failed without detailed error".to_string())
|
||||
}))
|
||||
fn hotlist_text_looks_ready(payload: &Value, ready_pattern: &Regex) -> bool {
|
||||
let text = payload.as_str().unwrap_or_default();
|
||||
text.contains("热榜") && ready_pattern.is_match(text)
|
||||
}
|
||||
|
||||
fn export_xlsx<T: Transport>(
|
||||
@@ -224,15 +291,17 @@ fn export_xlsx<T: Transport>(
|
||||
.map_err(|err| PipeError::Protocol(err.to_string()))?;
|
||||
if !result.success {
|
||||
return Err(PipeError::Protocol(
|
||||
result.error.unwrap_or_else(|| "openxml_office failed".to_string()),
|
||||
result
|
||||
.error
|
||||
.unwrap_or_else(|| "openxml_office failed".to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
let payload: Value = serde_json::from_str(&result.output)
|
||||
.map_err(|err| PipeError::Protocol(format!("invalid openxml_office output: {err}")))?;
|
||||
let output_path = payload["output_path"]
|
||||
.as_str()
|
||||
.ok_or_else(|| PipeError::Protocol("openxml_office did not return output_path".to_string()))?;
|
||||
let output_path = payload["output_path"].as_str().ok_or_else(|| {
|
||||
PipeError::Protocol("openxml_office did not return output_path".to_string())
|
||||
})?;
|
||||
Ok(format!("已导出知乎热榜 Excel {output_path}"))
|
||||
}
|
||||
|
||||
@@ -257,15 +326,17 @@ fn export_screen<T: Transport>(
|
||||
.map_err(|err| PipeError::Protocol(err.to_string()))?;
|
||||
if !result.success {
|
||||
return Err(PipeError::Protocol(
|
||||
result.error.unwrap_or_else(|| "screen_html_export failed".to_string()),
|
||||
result
|
||||
.error
|
||||
.unwrap_or_else(|| "screen_html_export failed".to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
let payload: Value = serde_json::from_str(&result.output)
|
||||
.map_err(|err| PipeError::Protocol(format!("invalid screen_html_export output: {err}")))?;
|
||||
let output_path = payload["output_path"]
|
||||
.as_str()
|
||||
.ok_or_else(|| PipeError::Protocol("screen_html_export did not return output_path".to_string()))?;
|
||||
let output_path = payload["output_path"].as_str().ok_or_else(|| {
|
||||
PipeError::Protocol("screen_html_export did not return output_path".to_string())
|
||||
})?;
|
||||
Ok(format!("已生成知乎热榜大屏 {output_path}"))
|
||||
}
|
||||
|
||||
@@ -300,7 +371,9 @@ fn execute_zhihu_article_route<T: Transport + 'static>(
|
||||
ZHIHU_DOMAIN,
|
||||
)?;
|
||||
if is_login_required_payload(&creator_state) {
|
||||
return Ok(build_login_block_message(payload_current_url(&creator_state)));
|
||||
return Ok(build_login_block_message(payload_current_url(
|
||||
&creator_state,
|
||||
)));
|
||||
}
|
||||
if payload_status(&creator_state) == Some("creator_home") {
|
||||
return Ok(build_creator_entry_missing_message(payload_current_url(
|
||||
@@ -321,10 +394,14 @@ fn execute_zhihu_article_route<T: Transport + 'static>(
|
||||
ZHIHU_EDITOR_DOMAIN,
|
||||
)?;
|
||||
if is_login_required_payload(&editor_state) {
|
||||
return Ok(build_login_block_message(payload_current_url(&editor_state)));
|
||||
return Ok(build_login_block_message(payload_current_url(
|
||||
&editor_state,
|
||||
)));
|
||||
}
|
||||
if payload_status(&editor_state) != Some("editor_ready") {
|
||||
return Ok(build_editor_unavailable_message(payload_current_url(&editor_state)));
|
||||
return Ok(build_editor_unavailable_message(payload_current_url(
|
||||
&editor_state,
|
||||
)));
|
||||
}
|
||||
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
@@ -347,7 +424,10 @@ fn execute_zhihu_article_route<T: Transport + 'static>(
|
||||
}
|
||||
|
||||
match payload_status(&fill_result) {
|
||||
Some("draft_ready") => Ok(format!("已进入知乎文章编辑器并写入草稿《{}》", article.title)),
|
||||
Some("draft_ready") => Ok(format!(
|
||||
"已进入知乎文章编辑器并写入草稿《{}》",
|
||||
article.title
|
||||
)),
|
||||
Some("publish_clicked") | Some("publish_submitted") => {
|
||||
Ok(format!("已提交知乎文章发布流程《{}》", article.title))
|
||||
}
|
||||
@@ -380,7 +460,9 @@ fn execute_zhihu_article_entry_route<T: Transport + 'static>(
|
||||
ZHIHU_DOMAIN,
|
||||
)?;
|
||||
if is_login_required_payload(&creator_state) {
|
||||
return Ok(build_login_block_message(payload_current_url(&creator_state)));
|
||||
return Ok(build_login_block_message(payload_current_url(
|
||||
&creator_state,
|
||||
)));
|
||||
}
|
||||
if payload_status(&creator_state) == Some("creator_home") {
|
||||
return Ok(build_creator_entry_missing_message(payload_current_url(
|
||||
@@ -401,13 +483,17 @@ fn execute_zhihu_article_entry_route<T: Transport + 'static>(
|
||||
ZHIHU_EDITOR_DOMAIN,
|
||||
)?;
|
||||
if is_login_required_payload(&editor_state) {
|
||||
return Ok(build_login_block_message(payload_current_url(&editor_state)));
|
||||
return Ok(build_login_block_message(payload_current_url(
|
||||
&editor_state,
|
||||
)));
|
||||
}
|
||||
if payload_status(&editor_state) == Some("editor_ready") {
|
||||
return Ok("已进入知乎文章编辑器。".to_string());
|
||||
}
|
||||
|
||||
Ok(build_editor_unavailable_message(payload_current_url(&editor_state)))
|
||||
Ok(build_editor_unavailable_message(payload_current_url(
|
||||
&editor_state,
|
||||
)))
|
||||
}
|
||||
|
||||
fn load_hotlist_extractor_script(top_n: usize) -> Result<String, PipeError> {
|
||||
@@ -443,7 +529,11 @@ fn parse_hotlist_items_payload(payload: &Value) -> Result<Vec<HotlistItem>, Pipe
|
||||
|
||||
let rank = cells[0]
|
||||
.as_u64()
|
||||
.or_else(|| cells[0].as_str().and_then(|value| value.parse::<u64>().ok()))
|
||||
.or_else(|| {
|
||||
cells[0]
|
||||
.as_str()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
})
|
||||
.unwrap_or((items.len() + 1) as u64);
|
||||
let title = cells[1].as_str().unwrap_or_default().trim().to_string();
|
||||
let heat = cells[2].as_str().unwrap_or_default().trim().to_string();
|
||||
@@ -483,7 +573,10 @@ fn navigate_zhihu_page<T: Transport + 'static>(
|
||||
if response.success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(PipeError::Protocol(format!("navigate failed: {}", response.data)))
|
||||
Err(PipeError::Protocol(format!(
|
||||
"navigate failed: {}",
|
||||
response.data
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,7 +600,9 @@ fn execute_browser_skill_script<T: Transport + 'static>(
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(normalize_payload(response.data.get("text").unwrap_or(&response.data)))
|
||||
Ok(normalize_payload(
|
||||
response.data.get("text").unwrap_or(&response.data),
|
||||
))
|
||||
}
|
||||
|
||||
fn navigate_to_editor_after_creator_entry<T: Transport + 'static>(
|
||||
@@ -542,6 +637,239 @@ fn navigate_to_editor_after_creator_entry<T: Transport + 'static>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::pipe::{BrowserMessage, Timing};
|
||||
use crate::security::MacPolicy;
|
||||
|
||||
struct MockWorkflowTransport {
|
||||
sent: Mutex<Vec<AgentMessage>>,
|
||||
responses: Mutex<VecDeque<BrowserMessage>>,
|
||||
}
|
||||
|
||||
impl MockWorkflowTransport {
|
||||
fn new(responses: Vec<BrowserMessage>) -> Self {
|
||||
Self {
|
||||
sent: Mutex::new(Vec::new()),
|
||||
responses: Mutex::new(VecDeque::from(responses)),
|
||||
}
|
||||
}
|
||||
|
||||
fn sent_messages(&self) -> Vec<AgentMessage> {
|
||||
self.sent.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport for MockWorkflowTransport {
|
||||
fn send(&self, message: &AgentMessage) -> Result<(), PipeError> {
|
||||
self.sent.lock().unwrap().push(message.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn recv_timeout(&self, _timeout: Duration) -> Result<BrowserMessage, PipeError> {
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.ok_or(PipeError::Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
fn zhihu_test_policy() -> MacPolicy {
|
||||
MacPolicy::from_json_str(
|
||||
&json!({
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": ["www.zhihu.com"] },
|
||||
"pipe_actions": {
|
||||
"allowed": ["navigate", "getText", "eval"],
|
||||
"blocked": []
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn success_browser_response(seq: u64, data: Value) -> BrowserMessage {
|
||||
BrowserMessage::Response {
|
||||
seq,
|
||||
success: true,
|
||||
data,
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_hotlist_items_skips_navigation_when_hot_page_is_already_readable() {
|
||||
let transport = Arc::new(MockWorkflowTransport::new(vec![
|
||||
success_browser_response(
|
||||
1,
|
||||
json!({ "text": "知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度" }),
|
||||
),
|
||||
success_browser_response(
|
||||
2,
|
||||
json!({
|
||||
"text": {
|
||||
"source": "https://www.zhihu.com/hot",
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [[1, "问题一", "344万"], [2, "问题二", "266万"]]
|
||||
}
|
||||
}),
|
||||
),
|
||||
]));
|
||||
let browser_tool =
|
||||
BrowserPipeTool::new(transport.clone(), zhihu_test_policy(), vec![1, 2, 3, 4])
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
let task_context = CompatTaskContext {
|
||||
page_url: Some("https://www.zhihu.com/hot".to_string()),
|
||||
page_title: Some("知乎热榜".to_string()),
|
||||
..CompatTaskContext::default()
|
||||
};
|
||||
|
||||
let items = collect_hotlist_items(transport.as_ref(), &browser_tool, 10, &task_context)
|
||||
.expect("hotlist collection should succeed");
|
||||
|
||||
assert_eq!(items.len(), 2);
|
||||
let sent = transport.sent_messages();
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, .. } if action == &Action::GetText
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, .. } if action == &Action::Eval
|
||||
)
|
||||
}));
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, .. } if action == &Action::Navigate
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_hotlist_items_polls_after_navigation_before_retrying_navigation() {
|
||||
let transport = Arc::new(MockWorkflowTransport::new(vec![
|
||||
success_browser_response(1, json!({ "navigated": true })),
|
||||
success_browser_response(2, json!({ "text": "" })),
|
||||
success_browser_response(3, json!({ "text": "" })),
|
||||
success_browser_response(4, json!({ "text": "知乎热榜\n1 问题一 344万热度" })),
|
||||
success_browser_response(
|
||||
5,
|
||||
json!({
|
||||
"text": {
|
||||
"source": "https://www.zhihu.com/hot",
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [[1, "问题一", "344万"]]
|
||||
}
|
||||
}),
|
||||
),
|
||||
]));
|
||||
let browser_tool =
|
||||
BrowserPipeTool::new(transport.clone(), zhihu_test_policy(), vec![1, 2, 3, 4, 5])
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
let task_context = CompatTaskContext {
|
||||
page_url: Some("https://www.zhihu.com/".to_string()),
|
||||
page_title: Some("知乎".to_string()),
|
||||
..CompatTaskContext::default()
|
||||
};
|
||||
|
||||
let items = collect_hotlist_items(transport.as_ref(), &browser_tool, 10, &task_context)
|
||||
.expect("hotlist collection should succeed after readiness polling");
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
let sent = transport.sent_messages();
|
||||
let actions = sent
|
||||
.iter()
|
||||
.filter_map(|message| match message {
|
||||
AgentMessage::Command { action, .. } => Some(action.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![
|
||||
Action::Navigate,
|
||||
Action::GetText,
|
||||
Action::GetText,
|
||||
Action::GetText,
|
||||
Action::Eval
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_hotlist_items_retries_navigation_after_short_readiness_budget_expires() {
|
||||
let transport = Arc::new(MockWorkflowTransport::new(vec![
|
||||
success_browser_response(1, json!({ "navigated": true })),
|
||||
success_browser_response(2, json!({ "text": "" })),
|
||||
success_browser_response(3, json!({ "text": "" })),
|
||||
success_browser_response(4, json!({ "text": "" })),
|
||||
success_browser_response(5, json!({ "text": "" })),
|
||||
success_browser_response(6, json!({ "text": "" })),
|
||||
success_browser_response(7, json!({ "text": "" })),
|
||||
success_browser_response(8, json!({ "text": "" })),
|
||||
success_browser_response(9, json!({ "text": "" })),
|
||||
success_browser_response(10, json!({ "text": "" })),
|
||||
success_browser_response(11, json!({ "text": "" })),
|
||||
success_browser_response(12, json!({ "navigated": true })),
|
||||
success_browser_response(13, json!({ "text": "知乎热榜\n1 问题一 344万热度" })),
|
||||
success_browser_response(
|
||||
14,
|
||||
json!({
|
||||
"text": {
|
||||
"source": "https://www.zhihu.com/hot",
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [[1, "问题一", "344万"]]
|
||||
}
|
||||
}),
|
||||
),
|
||||
]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
zhihu_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
let task_context = CompatTaskContext {
|
||||
page_url: Some("https://www.zhihu.com/".to_string()),
|
||||
page_title: Some("知乎".to_string()),
|
||||
..CompatTaskContext::default()
|
||||
};
|
||||
|
||||
let items = collect_hotlist_items(transport.as_ref(), &browser_tool, 10, &task_context)
|
||||
.expect("hotlist collection should succeed after one navigation retry");
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
let sent = transport.sent_messages();
|
||||
let navigate_count = sent
|
||||
.iter()
|
||||
.filter(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, .. } if action == &Action::Navigate
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(navigate_count, 2);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_browser_skill_script(
|
||||
skill_name: &str,
|
||||
script_name: &str,
|
||||
@@ -563,8 +891,7 @@ fn load_browser_skill_script(
|
||||
})?;
|
||||
Ok(format!(
|
||||
"(function() {{\nconst args = {};\n{}\n}})()",
|
||||
args,
|
||||
script
|
||||
args, script
|
||||
))
|
||||
}
|
||||
|
||||
@@ -632,11 +959,11 @@ fn build_publish_confirmation_message(article: &ArticleDraft) -> String {
|
||||
|
||||
fn has_explicit_publish_confirmation(instruction: &str) -> bool {
|
||||
let trimmed = instruction.trim();
|
||||
trimmed.contains("确认发布") ||
|
||||
trimmed.contains("确认发表") ||
|
||||
trimmed.contains("现在发布") ||
|
||||
trimmed.contains("立即发布") ||
|
||||
trimmed.contains("可以发布")
|
||||
trimmed.contains("确认发布")
|
||||
|| trimmed.contains("确认发表")
|
||||
|| trimmed.contains("现在发布")
|
||||
|| trimmed.contains("立即发布")
|
||||
|| trimmed.contains("可以发布")
|
||||
}
|
||||
|
||||
fn task_requests_zhihu_article_entry(
|
||||
@@ -649,17 +976,17 @@ fn task_requests_zhihu_article_entry(
|
||||
}
|
||||
|
||||
let normalized = instruction.to_ascii_lowercase();
|
||||
let asks_to_open = normalized.contains("open") ||
|
||||
normalized.contains("goto") ||
|
||||
normalized.contains("go to") ||
|
||||
instruction.contains("打开") ||
|
||||
instruction.contains("进入") ||
|
||||
instruction.contains("去");
|
||||
let mentions_entry = instruction.contains("页面") ||
|
||||
instruction.contains("入口") ||
|
||||
instruction.contains("创作中心") ||
|
||||
instruction.contains("写文章") ||
|
||||
instruction.contains("发文章");
|
||||
let asks_to_open = normalized.contains("open")
|
||||
|| normalized.contains("goto")
|
||||
|| normalized.contains("go to")
|
||||
|| instruction.contains("打开")
|
||||
|| instruction.contains("进入")
|
||||
|| instruction.contains("去");
|
||||
let mentions_entry = instruction.contains("页面")
|
||||
|| instruction.contains("入口")
|
||||
|| instruction.contains("创作中心")
|
||||
|| instruction.contains("写文章")
|
||||
|| instruction.contains("发文章");
|
||||
let has_article_inputs = parse_article_draft(instruction).is_some();
|
||||
|
||||
asks_to_open && mentions_entry && !has_article_inputs
|
||||
@@ -681,12 +1008,11 @@ fn extract_article_draft(
|
||||
fn parse_article_draft(text: &str) -> Option<ArticleDraft> {
|
||||
let normalized = normalize_article_draft_input(text);
|
||||
let title_re = Regex::new(r"(?m)^标题[::]\s*(.+?)\s*$").expect("valid zhihu title regex");
|
||||
let body_re =
|
||||
Regex::new(r"(?s)正文[::]\s*(.+)$").expect("valid zhihu body regex");
|
||||
let inline_title_re = Regex::new(r"标题(?:是|为)\s*([^,,\n]+)")
|
||||
.expect("valid inline zhihu title regex");
|
||||
let inline_body_re = Regex::new(r"(?s)正文(?:是|为)\s*(.+)$")
|
||||
.expect("valid inline zhihu body regex");
|
||||
let body_re = Regex::new(r"(?s)正文[::]\s*(.+)$").expect("valid zhihu body regex");
|
||||
let inline_title_re =
|
||||
Regex::new(r"标题(?:是|为)\s*([^,,\n]+)").expect("valid inline zhihu title regex");
|
||||
let inline_body_re =
|
||||
Regex::new(r"(?s)正文(?:是|为)\s*(.+)$").expect("valid inline zhihu body regex");
|
||||
|
||||
let title = title_re
|
||||
.captures(&normalized)
|
||||
@@ -718,9 +1044,9 @@ fn parse_article_draft(text: &str) -> Option<ArticleDraft> {
|
||||
|
||||
fn normalize_article_draft_input(text: &str) -> String {
|
||||
let trimmed = text.trim();
|
||||
let unquoted = if trimmed.len() >= 2 &&
|
||||
((trimmed.starts_with('"') && trimmed.ends_with('"')) ||
|
||||
(trimmed.starts_with('\'') && trimmed.ends_with('\'')))
|
||||
let unquoted = if trimmed.len() >= 2
|
||||
&& ((trimmed.starts_with('"') && trimmed.ends_with('"'))
|
||||
|| (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
|
||||
{
|
||||
&trimmed[1..trimmed.len() - 1]
|
||||
} else {
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
mod settings;
|
||||
|
||||
pub use settings::{
|
||||
BrowserBackend,
|
||||
ConfigError,
|
||||
DeepSeekSettings,
|
||||
OfficeBackend,
|
||||
PlannerMode,
|
||||
ProviderSettings,
|
||||
SgClawSettings,
|
||||
SkillsPromptMode,
|
||||
BrowserBackend, ConfigError, DeepSeekSettings, OfficeBackend, PlannerMode, ProviderSettings,
|
||||
SgClawSettings, SkillsPromptMode,
|
||||
};
|
||||
|
||||
@@ -114,7 +114,8 @@ impl DeepSeekSettings {
|
||||
}
|
||||
|
||||
pub fn load(config_path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
|
||||
SgClawSettings::load(config_path).map(|settings| settings.map(|settings| Self::from(&settings)))
|
||||
SgClawSettings::load(config_path)
|
||||
.map(|settings| settings.map(|settings| Self::from(&settings)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +217,10 @@ impl SgClawSettings {
|
||||
.map(parse_runtime_profile)
|
||||
.transpose()
|
||||
.map_err(|value| {
|
||||
ConfigError::ConfigParse(path.to_path_buf(), format!("invalid runtimeProfile: {value}"))
|
||||
ConfigError::ConfigParse(
|
||||
path.to_path_buf(),
|
||||
format!("invalid runtimeProfile: {value}"),
|
||||
)
|
||||
})?;
|
||||
let skills_prompt_mode = config
|
||||
.skills_prompt_mode
|
||||
@@ -235,7 +239,10 @@ impl SgClawSettings {
|
||||
.map(parse_planner_mode)
|
||||
.transpose()
|
||||
.map_err(|value| {
|
||||
ConfigError::ConfigParse(path.to_path_buf(), format!("invalid plannerMode: {value}"))
|
||||
ConfigError::ConfigParse(
|
||||
path.to_path_buf(),
|
||||
format!("invalid plannerMode: {value}"),
|
||||
)
|
||||
})?;
|
||||
let browser_backend = config
|
||||
.browser_backend
|
||||
@@ -243,7 +250,10 @@ impl SgClawSettings {
|
||||
.map(parse_browser_backend)
|
||||
.transpose()
|
||||
.map_err(|value| {
|
||||
ConfigError::ConfigParse(path.to_path_buf(), format!("invalid browserBackend: {value}"))
|
||||
ConfigError::ConfigParse(
|
||||
path.to_path_buf(),
|
||||
format!("invalid browserBackend: {value}"),
|
||||
)
|
||||
})?;
|
||||
let office_backend = config
|
||||
.office_backend
|
||||
@@ -251,7 +261,10 @@ impl SgClawSettings {
|
||||
.map(parse_office_backend)
|
||||
.transpose()
|
||||
.map_err(|value| {
|
||||
ConfigError::ConfigParse(path.to_path_buf(), format!("invalid officeBackend: {value}"))
|
||||
ConfigError::ConfigParse(
|
||||
path.to_path_buf(),
|
||||
format!("invalid officeBackend: {value}"),
|
||||
)
|
||||
})?;
|
||||
let providers = config
|
||||
.providers
|
||||
@@ -290,12 +303,14 @@ impl SgClawSettings {
|
||||
office_backend: Option<OfficeBackend>,
|
||||
) -> Result<Self, ConfigError> {
|
||||
let providers = if providers.is_empty() {
|
||||
vec![ProviderSettings::from_legacy_deepseek(api_key, base_url, model)?]
|
||||
vec![ProviderSettings::from_legacy_deepseek(
|
||||
api_key, base_url, model,
|
||||
)?]
|
||||
} else {
|
||||
providers
|
||||
};
|
||||
let active_provider = normalize_optional_value(active_provider)
|
||||
.unwrap_or_else(|| providers[0].id.clone());
|
||||
let active_provider =
|
||||
normalize_optional_value(active_provider).unwrap_or_else(|| providers[0].id.clone());
|
||||
let active_provider_settings = providers
|
||||
.iter()
|
||||
.find(|provider| provider.id == active_provider)
|
||||
@@ -308,7 +323,10 @@ impl SgClawSettings {
|
||||
|
||||
Ok(Self {
|
||||
provider_api_key: active_provider_settings.api_key.clone(),
|
||||
provider_base_url: active_provider_settings.base_url.clone().unwrap_or_default(),
|
||||
provider_base_url: active_provider_settings
|
||||
.base_url
|
||||
.clone()
|
||||
.unwrap_or_default(),
|
||||
provider_model: active_provider_settings.model.clone(),
|
||||
skills_dir,
|
||||
skills_prompt_mode: skills_prompt_mode.unwrap_or(SkillsPromptMode::Compact),
|
||||
@@ -497,11 +515,7 @@ struct RawProviderSettings {
|
||||
api_path: Option<String>,
|
||||
#[serde(rename = "wireApi", alias = "wire_api", default)]
|
||||
wire_api: Option<String>,
|
||||
#[serde(
|
||||
rename = "requiresOpenaiAuth",
|
||||
alias = "requires_openai_auth",
|
||||
default
|
||||
)]
|
||||
#[serde(rename = "requiresOpenaiAuth", alias = "requires_openai_auth", default)]
|
||||
requires_openai_auth: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod compat;
|
||||
pub mod config;
|
||||
pub mod llm;
|
||||
pub mod pipe;
|
||||
pub mod runtime;
|
||||
pub mod security;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -21,9 +21,7 @@ impl HandshakeResult {
|
||||
.iter()
|
||||
.any(|capability| capability == "browser_action")
|
||||
.then(|| {
|
||||
ExecutionSurfaceMetadata::privileged_browser_pipe(
|
||||
"browser_host_and_mac_policy",
|
||||
)
|
||||
ExecutionSurfaceMetadata::privileged_browser_pipe("browser_host_and_mac_policy")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ pub mod protocol;
|
||||
pub use browser_tool::{BrowserPipeTool, CommandOutput};
|
||||
pub use handshake::{perform_handshake, HandshakeResult};
|
||||
pub use protocol::{
|
||||
supported_actions, Action, AgentMessage, BrowserContext, BrowserMessage,
|
||||
ConversationMessage, ExecutionSurfaceKind, ExecutionSurfaceMetadata, SecurityFields, Timing,
|
||||
supported_actions, Action, AgentMessage, BrowserContext, BrowserMessage, ConversationMessage,
|
||||
ExecutionSurfaceKind, ExecutionSurfaceMetadata, SecurityFields, Timing,
|
||||
};
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
|
||||
@@ -24,6 +24,7 @@ const BROWSER_TOOL_CONTRACT_PROMPT: &str = "SuperRPA browser interface contract:
|
||||
const ZHIHU_HOTLIST_EXECUTION_PROMPT: &str = "Zhihu hotlist execution contract:\n- Treat Zhihu hotlist export/presentation requests as a real browser workflow, not as a text-only summarization task.\n- You must attempt the browser workflow before concluding failure; a prose-only answer is invalid for this workflow.\n- If the current page is not already `https://www.zhihu.com/hot`, navigate there first.\n- If the `zhihu-hotlist.extract_hotlist` skill tool is available, call it before any generic browser probing.\n- Use generic `getText` only as a last-resort fallback when the packaged extractor fails.\n- Extract ordered rows containing `rank`, `title`, and `heat` as structured data.\n- Do not use shell, web_fetch, web_search_tool, or fabricated sample data for this workflow.\n- Do not repeat the same sentence or section in your final answer.";
|
||||
const OFFICE_EXPORT_COMPLETION_PROMPT: &str = "Export completion contract:\n- This task requires a real Excel export.\n- After the Zhihu rows are available, you must call openxml_office before finishing.\n- Never fabricate, simulate, or invent substitute hotlist data when a live collection/export task fails.\n- If live collection fails, report the failure concisely instead of producing fake rows.\n- Do not stop after describing how you will parse or export the data.\n- Do not repeat the same sentence or section in your final answer.\n- Your final answer must include the generated local .xlsx path.";
|
||||
const SCREEN_EXPORT_COMPLETION_PROMPT: &str = "Presentation completion contract:\n- This task requires a real dashboard artifact.\n- After the Zhihu rows are available, you must call screen_html_export before finishing.\n- Do not stop after describing how you will render or present the data.\n- Do not repeat the same sentence or section in your final answer.\n- Your final answer must include the local .html path and the presentation object.";
|
||||
const ZHIHU_WRITE_PUBLISH_PROMPT: &str = "Zhihu article publish contract:\n- This task may publish a Zhihu article.\n- You must not click publish without explicit human confirmation in the current session.\n- If the user asked to publish but no explicit confirmation phrase is present yet, ask for confirmation concisely and stop after the confirmation request.\n- Do not keep exploring tools after you have determined that publish confirmation is missing.\n- If the user only asked to write or draft, stay in draft mode and do not treat it as publish mode.\n- Do not repeat the same sentence or section in your final answer.";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeEngine {
|
||||
@@ -51,9 +52,7 @@ impl RuntimeEngine {
|
||||
self.tool_policy
|
||||
.allowed_tools
|
||||
.iter()
|
||||
.any(|tool| {
|
||||
tool == BROWSER_ACTION_TOOL_NAME || tool == SUPERRPA_BROWSER_TOOL_NAME
|
||||
})
|
||||
.any(|tool| tool == BROWSER_ACTION_TOOL_NAME || tool == SUPERRPA_BROWSER_TOOL_NAME)
|
||||
}
|
||||
|
||||
pub fn build_agent(
|
||||
@@ -155,6 +154,9 @@ impl RuntimeEngine {
|
||||
if task_needs_screen_export(trimmed_instruction) {
|
||||
sections.push(SCREEN_EXPORT_COMPLETION_PROMPT.to_string());
|
||||
}
|
||||
if task_requests_zhihu_article_publish(trimmed_instruction, page_url, page_title) {
|
||||
sections.push(ZHIHU_WRITE_PUBLISH_PROMPT.to_string());
|
||||
}
|
||||
if let Some(page_context) = build_page_context_message(page_url, page_title) {
|
||||
sections.push(page_context);
|
||||
}
|
||||
@@ -173,17 +175,11 @@ impl RuntimeEngine {
|
||||
.cmp(&right.name)
|
||||
.then(left.version.cmp(&right.version))
|
||||
});
|
||||
skills.dedup_by(|left, right| {
|
||||
left.name == right.name && left.version == right.version
|
||||
});
|
||||
skills.dedup_by(|left, right| left.name == right.name && left.version == right.version);
|
||||
skills
|
||||
}
|
||||
|
||||
pub fn loaded_skill_names(
|
||||
&self,
|
||||
config: &ZeroClawConfig,
|
||||
skills_dir: &Path,
|
||||
) -> Vec<String> {
|
||||
pub fn loaded_skill_names(&self, config: &ZeroClawConfig, skills_dir: &Path) -> Vec<String> {
|
||||
let mut names = self
|
||||
.loaded_skills(config, skills_dir)
|
||||
.into_iter()
|
||||
@@ -237,8 +233,8 @@ impl RuntimeEngine {
|
||||
}
|
||||
allowed_tools.dedup();
|
||||
|
||||
if matches!(self.profile, RuntimeProfile::GeneralAssistant) &&
|
||||
self.tool_policy.may_use_non_browser_tools
|
||||
if matches!(self.profile, RuntimeProfile::GeneralAssistant)
|
||||
&& self.tool_policy.may_use_non_browser_tools
|
||||
{
|
||||
None
|
||||
} else {
|
||||
@@ -263,9 +259,7 @@ fn browser_script_tool_names(skills: &[zeroclaw::skills::Skill]) -> Vec<String>
|
||||
|
||||
fn task_needs_local_file_read(instruction: &str) -> bool {
|
||||
let normalized = instruction.trim();
|
||||
normalized.contains("/home/") ||
|
||||
normalized.contains("./") ||
|
||||
normalized.contains("../")
|
||||
normalized.contains("/home/") || normalized.contains("./") || normalized.contains("../")
|
||||
}
|
||||
|
||||
pub fn is_zhihu_hotlist_task(
|
||||
@@ -277,16 +271,16 @@ pub fn is_zhihu_hotlist_task(
|
||||
let normalized_url = page_url.unwrap_or_default().to_ascii_lowercase();
|
||||
let normalized_title = page_title.unwrap_or_default().to_ascii_lowercase();
|
||||
|
||||
let is_zhihu = normalized_instruction.contains("zhihu") ||
|
||||
instruction.contains("知乎") ||
|
||||
normalized_url.contains("zhihu.com") ||
|
||||
normalized_title.contains("zhihu") ||
|
||||
page_title.unwrap_or_default().contains("知乎");
|
||||
let is_hotlist = normalized_instruction.contains("hotlist") ||
|
||||
instruction.contains("热榜") ||
|
||||
normalized_url.contains("/hot") ||
|
||||
normalized_title.contains("hotlist") ||
|
||||
page_title.unwrap_or_default().contains("热榜");
|
||||
let is_zhihu = normalized_instruction.contains("zhihu")
|
||||
|| instruction.contains("知乎")
|
||||
|| normalized_url.contains("zhihu.com")
|
||||
|| normalized_title.contains("zhihu")
|
||||
|| page_title.unwrap_or_default().contains("知乎");
|
||||
let is_hotlist = normalized_instruction.contains("hotlist")
|
||||
|| instruction.contains("热榜")
|
||||
|| normalized_url.contains("/hot")
|
||||
|| normalized_title.contains("hotlist")
|
||||
|| page_title.unwrap_or_default().contains("热榜");
|
||||
|
||||
is_zhihu && is_hotlist
|
||||
}
|
||||
@@ -310,6 +304,48 @@ fn task_needs_screen_export(instruction: &str) -> bool {
|
||||
|| normalized.contains("汇报")
|
||||
}
|
||||
|
||||
pub fn task_requests_zhihu_article_publish(
|
||||
instruction: &str,
|
||||
page_url: Option<&str>,
|
||||
page_title: Option<&str>,
|
||||
) -> bool {
|
||||
if !is_zhihu_write_task(instruction, page_url, page_title) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let normalized = instruction.to_ascii_lowercase();
|
||||
normalized.contains("publish") || instruction.contains("发布") || instruction.contains("发表")
|
||||
}
|
||||
|
||||
pub fn is_zhihu_write_task(
|
||||
instruction: &str,
|
||||
page_url: Option<&str>,
|
||||
page_title: Option<&str>,
|
||||
) -> bool {
|
||||
let normalized_instruction = instruction.to_ascii_lowercase();
|
||||
let normalized_url = page_url.unwrap_or_default().to_ascii_lowercase();
|
||||
let normalized_title = page_title.unwrap_or_default().to_ascii_lowercase();
|
||||
|
||||
let is_zhihu = normalized_instruction.contains("zhihu")
|
||||
|| instruction.contains("知乎")
|
||||
|| normalized_url.contains("zhihu.com")
|
||||
|| normalized_title.contains("zhihu")
|
||||
|| page_title.unwrap_or_default().contains("知乎");
|
||||
let is_write = normalized_instruction.contains("article")
|
||||
|| normalized_instruction.contains("write")
|
||||
|| normalized_instruction.contains("publish")
|
||||
|| instruction.contains("文章")
|
||||
|| instruction.contains("写")
|
||||
|| instruction.contains("发布")
|
||||
|| instruction.contains("发表")
|
||||
|| normalized_url.contains("creator")
|
||||
|| normalized_url.contains("write")
|
||||
|| page_title.unwrap_or_default().contains("创作")
|
||||
|| page_title.unwrap_or_default().contains("写文章");
|
||||
|
||||
is_zhihu && is_write
|
||||
}
|
||||
|
||||
fn load_runtime_skills(config: &ZeroClawConfig, skills_dir: &Path) -> Vec<zeroclaw::skills::Skill> {
|
||||
let default_skills_dir = config.workspace_dir.join("skills");
|
||||
if skills_dir == default_skills_dir {
|
||||
@@ -344,10 +380,7 @@ fn build_page_context_message(page_url: Option<&str>, page_title: Option<&str>)
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"Current browser context:\n{}",
|
||||
parts.join("\n")
|
||||
))
|
||||
Some(format!("Current browser context:\n{}", parts.join("\n")))
|
||||
}
|
||||
|
||||
fn map_anyhow_to_pipe_error(err: anyhow::Error) -> PipeError {
|
||||
|
||||
@@ -2,6 +2,8 @@ mod engine;
|
||||
mod profile;
|
||||
mod tool_policy;
|
||||
|
||||
pub use engine::{is_zhihu_hotlist_task, RuntimeEngine};
|
||||
pub use engine::{
|
||||
is_zhihu_hotlist_task, is_zhihu_write_task, task_requests_zhihu_article_publish, RuntimeEngine,
|
||||
};
|
||||
pub use profile::RuntimeProfile;
|
||||
pub use tool_policy::ToolPolicy;
|
||||
|
||||
6
src/runtime/profile.rs
Normal file
6
src/runtime/profile.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RuntimeProfile {
|
||||
BrowserAttached,
|
||||
BrowserHeavy,
|
||||
GeneralAssistant,
|
||||
}
|
||||
@@ -13,18 +13,12 @@ impl ToolPolicy {
|
||||
RuntimeProfile::BrowserAttached => Self {
|
||||
requires_browser_surface: false,
|
||||
may_use_non_browser_tools: true,
|
||||
allowed_tools: vec![
|
||||
"superrpa_browser".to_string(),
|
||||
"browser_action".to_string(),
|
||||
],
|
||||
allowed_tools: vec!["superrpa_browser".to_string(), "browser_action".to_string()],
|
||||
},
|
||||
RuntimeProfile::BrowserHeavy => Self {
|
||||
requires_browser_surface: true,
|
||||
may_use_non_browser_tools: true,
|
||||
allowed_tools: vec![
|
||||
"superrpa_browser".to_string(),
|
||||
"browser_action".to_string(),
|
||||
],
|
||||
allowed_tools: vec!["superrpa_browser".to_string(), "browser_action".to_string()],
|
||||
},
|
||||
RuntimeProfile::GeneralAssistant => Self {
|
||||
requires_browser_surface: false,
|
||||
|
||||
Reference in New Issue
Block a user