wip: checkpoint 2026-03-29 runtime work

This commit is contained in:
zyl
2026-03-29 22:44:30 +08:00
parent 7d9036b2d4
commit e294fbb9b1
30 changed files with 6759 additions and 161 deletions

316
src/runtime/engine.rs Normal file
View 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
View 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;

View 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(),
},
}
}
}