wip: checkpoint 2026-03-29 runtime work
This commit is contained in:
316
src/runtime/engine.rs
Normal file
316
src/runtime/engine.rs
Normal file
@@ -0,0 +1,316 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use zeroclaw::agent::dispatcher::NativeToolDispatcher;
|
||||
use zeroclaw::agent::Agent;
|
||||
use zeroclaw::config::{Config as ZeroClawConfig, SkillsPromptInjectionMode};
|
||||
use zeroclaw::memory::Memory;
|
||||
use zeroclaw::observability::{NoopObserver, Observer};
|
||||
use zeroclaw::providers::Provider;
|
||||
use zeroclaw::runtime::NativeRuntime;
|
||||
use zeroclaw::tools::{self, ReadSkillTool};
|
||||
use zeroclaw::SecurityPolicy;
|
||||
|
||||
use crate::compat::memory_adapter::build_memory;
|
||||
use crate::pipe::PipeError;
|
||||
use crate::runtime::{RuntimeProfile, ToolPolicy};
|
||||
|
||||
const BROWSER_ACTION_TOOL_NAME: &str = "browser_action";
|
||||
const SUPERRPA_BROWSER_TOOL_NAME: &str = "superrpa_browser";
|
||||
const READ_SKILL_TOOL_NAME: &str = "read_skill";
|
||||
const OPENXML_OFFICE_TOOL_NAME: &str = "openxml_office";
|
||||
const SCREEN_HTML_EXPORT_TOOL_NAME: &str = "screen_html_export";
|
||||
const BROWSER_TOOL_CONTRACT_PROMPT: &str = "SuperRPA browser interface contract:\n- Use superrpa_browser as the preferred dedicated SuperRPA interface inside this browser host.\n- browser_action is a legacy alias with the same contract; prefer superrpa_browser when choosing between them.\n- Browser actions allowed by policy are already approved by the user inside this BrowserAttached host.\n- Do not claim a browser action was denied, blocked, or rejected unless an actual tool call returns an error.\n- expected_domain must be the bare hostname only, for example www.zhihu.com.\n- Never include scheme, path, query, fragment, or port in expected_domain.\n- selector values are executed with document.querySelector(...), so they must be valid CSS selectors only.\n- Never use XPath selectors or jQuery-style :contains().\n- Prefer direct navigation to canonical URLs when they are known, instead of clicking text links to reach common pages.\n- If you need broad page content, use getText with a valid CSS selector such as body or a stable container.\n- If a task matches an installed skill, load that skill first and then execute it through the SuperRPA interface.";
|
||||
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- Collect the live list with superrpa_browser using `getText` on `main` first; only fall back to `body` or `html` if `main` is unavailable.\n- Extract ordered rows containing `rank`, `title`, and `heat` from the live page text.\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.";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeEngine {
|
||||
profile: RuntimeProfile,
|
||||
tool_policy: ToolPolicy,
|
||||
}
|
||||
|
||||
impl RuntimeEngine {
|
||||
pub fn new(profile: RuntimeProfile) -> Self {
|
||||
Self {
|
||||
profile,
|
||||
tool_policy: ToolPolicy::for_profile(profile),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn profile(&self) -> RuntimeProfile {
|
||||
self.profile
|
||||
}
|
||||
|
||||
pub fn tool_policy(&self) -> &ToolPolicy {
|
||||
&self.tool_policy
|
||||
}
|
||||
|
||||
pub fn browser_surface_enabled(&self) -> bool {
|
||||
self.tool_policy
|
||||
.allowed_tools
|
||||
.iter()
|
||||
.any(|tool| {
|
||||
tool == BROWSER_ACTION_TOOL_NAME || tool == SUPERRPA_BROWSER_TOOL_NAME
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_agent(
|
||||
&self,
|
||||
provider: Box<dyn Provider>,
|
||||
config: &ZeroClawConfig,
|
||||
skills_dir: &Path,
|
||||
mut tools: Vec<Box<dyn zeroclaw::tools::Tool>>,
|
||||
browser_surface_present: bool,
|
||||
instruction: &str,
|
||||
) -> Result<Agent, PipeError> {
|
||||
let memory: Arc<dyn Memory> =
|
||||
Arc::from(build_memory(config).map_err(map_anyhow_to_pipe_error)?);
|
||||
let security = Arc::new(SecurityPolicy::from_config(
|
||||
&config.autonomy,
|
||||
&config.workspace_dir,
|
||||
));
|
||||
let observer: Arc<dyn Observer> = Arc::new(NoopObserver);
|
||||
let skills = load_runtime_skills(config, skills_dir);
|
||||
let (mut runtime_tools, _, _, _, _, _) = tools::all_tools_with_runtime(
|
||||
Arc::new(config.clone()),
|
||||
&security,
|
||||
Arc::new(NativeRuntime::new()),
|
||||
memory.clone(),
|
||||
None,
|
||||
None,
|
||||
&config.browser,
|
||||
&config.http_request,
|
||||
&config.web_fetch,
|
||||
&config.workspace_dir,
|
||||
&config.agents,
|
||||
config.api_key.as_deref(),
|
||||
config,
|
||||
None,
|
||||
);
|
||||
runtime_tools.append(&mut tools);
|
||||
|
||||
if matches!(
|
||||
config.skills.prompt_injection_mode,
|
||||
SkillsPromptInjectionMode::Compact
|
||||
) && skills_dir != config.workspace_dir.join("skills")
|
||||
{
|
||||
runtime_tools.retain(|tool| tool.name() != READ_SKILL_TOOL_NAME);
|
||||
runtime_tools.push(Box::new(ReadSkillTool::with_runtime_skills_dir(
|
||||
config.workspace_dir.clone(),
|
||||
Some(skills_dir.to_path_buf()),
|
||||
config.skills.allow_scripts,
|
||||
config.skills.open_skills_enabled,
|
||||
config.skills.open_skills_dir.clone(),
|
||||
)));
|
||||
}
|
||||
|
||||
Agent::builder()
|
||||
.provider(provider)
|
||||
.tools(runtime_tools)
|
||||
.memory(memory)
|
||||
.observer(observer)
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
.config(config.agent.clone())
|
||||
.model_name(
|
||||
config
|
||||
.default_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "deepseek-chat".to_string()),
|
||||
)
|
||||
.temperature(config.default_temperature)
|
||||
.workspace_dir(config.workspace_dir.clone())
|
||||
.skills(skills)
|
||||
.skills_prompt_mode(config.skills.prompt_injection_mode)
|
||||
.allowed_tools(self.allowed_tools_for_config(
|
||||
config,
|
||||
browser_surface_present,
|
||||
instruction,
|
||||
))
|
||||
.build()
|
||||
.map_err(map_anyhow_to_pipe_error)
|
||||
}
|
||||
|
||||
pub fn build_instruction(
|
||||
&self,
|
||||
instruction: &str,
|
||||
page_url: Option<&str>,
|
||||
page_title: Option<&str>,
|
||||
browser_surface_present: bool,
|
||||
) -> String {
|
||||
let trimmed_instruction = instruction.trim();
|
||||
if !browser_surface_present || !self.browser_surface_enabled() {
|
||||
return trimmed_instruction.to_string();
|
||||
}
|
||||
|
||||
let mut sections = vec![BROWSER_TOOL_CONTRACT_PROMPT.to_string()];
|
||||
if is_zhihu_hotlist_task(trimmed_instruction, page_url, page_title) {
|
||||
sections.push(ZHIHU_HOTLIST_EXECUTION_PROMPT.to_string());
|
||||
}
|
||||
if task_needs_office_export(trimmed_instruction) {
|
||||
sections.push(OFFICE_EXPORT_COMPLETION_PROMPT.to_string());
|
||||
}
|
||||
if task_needs_screen_export(trimmed_instruction) {
|
||||
sections.push(SCREEN_EXPORT_COMPLETION_PROMPT.to_string());
|
||||
}
|
||||
if let Some(page_context) = build_page_context_message(page_url, page_title) {
|
||||
sections.push(page_context);
|
||||
}
|
||||
sections.push(format!("User task: {trimmed_instruction}"));
|
||||
sections.join("\n\n")
|
||||
}
|
||||
|
||||
pub fn loaded_skill_names(
|
||||
&self,
|
||||
config: &ZeroClawConfig,
|
||||
skills_dir: &Path,
|
||||
) -> Vec<String> {
|
||||
let mut names = load_runtime_skills(config, skills_dir)
|
||||
.into_iter()
|
||||
.map(|skill| skill.name)
|
||||
.collect::<Vec<_>>();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
pub fn should_attach_openxml_office_tool(&self, instruction: &str) -> bool {
|
||||
task_needs_office_export(instruction)
|
||||
}
|
||||
|
||||
pub fn should_attach_screen_html_export_tool(&self, instruction: &str) -> bool {
|
||||
task_needs_screen_export(instruction)
|
||||
}
|
||||
|
||||
fn allowed_tools_for_config(
|
||||
&self,
|
||||
config: &ZeroClawConfig,
|
||||
browser_surface_present: bool,
|
||||
instruction: &str,
|
||||
) -> Option<Vec<String>> {
|
||||
let mut allowed_tools = self.tool_policy.allowed_tools.clone();
|
||||
if !browser_surface_present {
|
||||
allowed_tools.retain(|tool| {
|
||||
tool != BROWSER_ACTION_TOOL_NAME && tool != SUPERRPA_BROWSER_TOOL_NAME
|
||||
});
|
||||
}
|
||||
if matches!(
|
||||
config.skills.prompt_injection_mode,
|
||||
SkillsPromptInjectionMode::Compact
|
||||
) {
|
||||
allowed_tools.push(READ_SKILL_TOOL_NAME.to_string());
|
||||
}
|
||||
if task_needs_office_export(instruction) {
|
||||
allowed_tools.push(OPENXML_OFFICE_TOOL_NAME.to_string());
|
||||
}
|
||||
if task_needs_screen_export(instruction) {
|
||||
allowed_tools.push(SCREEN_HTML_EXPORT_TOOL_NAME.to_string());
|
||||
}
|
||||
if task_needs_local_file_read(instruction) {
|
||||
allowed_tools.push("file_read".to_string());
|
||||
}
|
||||
allowed_tools.dedup();
|
||||
|
||||
if matches!(self.profile, RuntimeProfile::GeneralAssistant) &&
|
||||
self.tool_policy.may_use_non_browser_tools
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(allowed_tools)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn task_needs_local_file_read(instruction: &str) -> bool {
|
||||
let normalized = instruction.trim();
|
||||
normalized.contains("/home/") ||
|
||||
normalized.contains("./") ||
|
||||
normalized.contains("../")
|
||||
}
|
||||
|
||||
pub fn is_zhihu_hotlist_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_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
|
||||
}
|
||||
|
||||
fn task_needs_office_export(instruction: &str) -> bool {
|
||||
let normalized = instruction.to_ascii_lowercase();
|
||||
normalized.contains("excel")
|
||||
|| normalized.contains(".xlsx")
|
||||
|| normalized.contains("导出")
|
||||
|| normalized.contains("xlsx")
|
||||
}
|
||||
|
||||
fn task_needs_screen_export(instruction: &str) -> bool {
|
||||
let normalized = instruction.to_ascii_lowercase();
|
||||
normalized.contains("大屏")
|
||||
|| normalized.contains("看板")
|
||||
|| normalized.contains("dashboard")
|
||||
|| normalized.contains("screen")
|
||||
|| normalized.contains("echarts")
|
||||
|| normalized.contains("演示")
|
||||
|| normalized.contains("汇报")
|
||||
}
|
||||
|
||||
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 {
|
||||
return zeroclaw::skills::load_skills_with_config(&config.workspace_dir, config);
|
||||
}
|
||||
|
||||
let mut skills = zeroclaw::skills::load_skills_with_config(&config.workspace_dir, config);
|
||||
skills.retain(|skill| {
|
||||
skill
|
||||
.location
|
||||
.as_ref()
|
||||
.map(|location| !location.starts_with(&default_skills_dir))
|
||||
.unwrap_or(true)
|
||||
});
|
||||
skills.extend(zeroclaw::skills::load_skills_from_directory(
|
||||
skills_dir,
|
||||
config.skills.allow_scripts,
|
||||
));
|
||||
skills
|
||||
}
|
||||
|
||||
fn build_page_context_message(page_url: Option<&str>, page_title: Option<&str>) -> Option<String> {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(page_url) = page_url.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
parts.push(format!("Current page URL: {page_url}"));
|
||||
}
|
||||
if let Some(page_title) = page_title.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
parts.push(format!("Current page title: {page_title}"));
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"Current browser context:\n{}",
|
||||
parts.join("\n")
|
||||
))
|
||||
}
|
||||
|
||||
fn map_anyhow_to_pipe_error(err: anyhow::Error) -> PipeError {
|
||||
PipeError::Protocol(err.to_string())
|
||||
}
|
||||
7
src/runtime/mod.rs
Normal file
7
src/runtime/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod engine;
|
||||
mod profile;
|
||||
mod tool_policy;
|
||||
|
||||
pub use engine::{is_zhihu_hotlist_task, RuntimeEngine};
|
||||
pub use profile::RuntimeProfile;
|
||||
pub use tool_policy::ToolPolicy;
|
||||
36
src/runtime/tool_policy.rs
Normal file
36
src/runtime/tool_policy.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use crate::runtime::RuntimeProfile;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ToolPolicy {
|
||||
pub requires_browser_surface: bool,
|
||||
pub may_use_non_browser_tools: bool,
|
||||
pub allowed_tools: Vec<String>,
|
||||
}
|
||||
|
||||
impl ToolPolicy {
|
||||
pub fn for_profile(profile: RuntimeProfile) -> Self {
|
||||
match profile {
|
||||
RuntimeProfile::BrowserAttached => Self {
|
||||
requires_browser_surface: false,
|
||||
may_use_non_browser_tools: true,
|
||||
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(),
|
||||
],
|
||||
},
|
||||
RuntimeProfile::GeneralAssistant => Self {
|
||||
requires_browser_surface: false,
|
||||
may_use_non_browser_tools: true,
|
||||
allowed_tools: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user