feat: add config-owned direct submit runtime

Keep browser-attached workflows on the configured direct-skill path and align the Zhihu export/browser regression contracts with the current ws merge state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
木炎
2026-04-11 15:45:42 +08:00
29 changed files with 5218 additions and 585 deletions

View File

@@ -132,6 +132,7 @@ pub fn execute_route_with_browser_backend(
transport: &dyn crate::agent::AgentEventSink,
browser_backend: Arc<dyn BrowserBackend>,
workspace_root: &Path,
skills_dir: &Path,
instruction: &str,
task_context: &CompatTaskContext,
route: WorkflowRoute,
@@ -140,7 +141,13 @@ pub fn execute_route_with_browser_backend(
match route {
WorkflowRoute::ZhihuHotlistExportXlsx | WorkflowRoute::ZhihuHotlistScreen => {
let top_n = extract_top_n(instruction);
let items = collect_hotlist_items(transport, browser_backend.as_ref(), top_n, task_context)?;
let items = collect_hotlist_items(
transport,
browser_backend.as_ref(),
skills_dir,
top_n,
task_context,
)?;
if items.is_empty() {
return Err(PipeError::Protocol(
"知乎热榜采集失败:未能从页面文本中解析到热榜条目".to_string(),
@@ -155,11 +162,12 @@ pub fn execute_route_with_browser_backend(
}
}
WorkflowRoute::ZhihuArticleEntry => {
execute_zhihu_article_entry_route(transport, browser_backend.as_ref())
execute_zhihu_article_entry_route(transport, browser_backend.as_ref(), skills_dir)
}
WorkflowRoute::ZhihuArticleDraft => execute_zhihu_article_route(
transport,
browser_backend.as_ref(),
skills_dir,
instruction,
task_context,
false,
@@ -169,6 +177,7 @@ pub fn execute_route_with_browser_backend(
WorkflowRoute::ZhihuArticlePublish => execute_zhihu_article_route(
transport,
browser_backend.as_ref(),
skills_dir,
instruction,
task_context,
true,
@@ -179,6 +188,7 @@ pub fn execute_route_with_browser_backend(
execute_generated_zhihu_article_publish_route(
transport,
browser_backend.as_ref(),
skills_dir,
instruction,
task_context,
workspace_root,
@@ -192,6 +202,7 @@ pub fn execute_route<T: Transport + 'static>(
transport: &T,
browser_tool: &BrowserPipeTool<T>,
workspace_root: &Path,
skills_dir: &Path,
instruction: &str,
task_context: &CompatTaskContext,
route: WorkflowRoute,
@@ -203,6 +214,7 @@ pub fn execute_route<T: Transport + 'static>(
transport,
browser_backend,
workspace_root,
skills_dir,
instruction,
task_context,
route,
@@ -213,10 +225,13 @@ pub fn execute_route<T: Transport + 'static>(
fn collect_hotlist_items(
transport: &dyn crate::agent::AgentEventSink,
browser_tool: &dyn BrowserBackend,
skills_dir: &Path,
top_n: usize,
task_context: &CompatTaskContext,
) -> Result<Vec<HotlistItem>, PipeError> {
if let Some(items) = ensure_hotlist_page_ready(transport, browser_tool, top_n, task_context)? {
if let Some(items) =
ensure_hotlist_page_ready(transport, browser_tool, skills_dir, top_n, task_context)?
{
return Ok(items);
}
transport.send(&AgentMessage::LogEntry {
@@ -225,7 +240,7 @@ fn collect_hotlist_items(
})?;
let response = browser_tool.invoke(
Action::Eval,
json!({ "script": load_hotlist_extractor_script(top_n)? }),
json!({ "script": load_hotlist_extractor_script(skills_dir, top_n)? }),
ZHIHU_DOMAIN,
)?;
if !response.success {
@@ -246,6 +261,7 @@ fn collect_hotlist_items(
fn ensure_hotlist_page_ready(
transport: &dyn crate::agent::AgentEventSink,
browser_tool: &dyn BrowserBackend,
skills_dir: &Path,
top_n: usize,
task_context: &CompatTaskContext,
) -> Result<Option<Vec<HotlistItem>>, PipeError> {
@@ -268,7 +284,7 @@ fn ensure_hotlist_page_ready(
// Best-effort wait for content to appear; ignore the boolean result
// we always follow up with the probe.
let _ = poll_for_hotlist_readiness(browser_tool);
if let Some(items) = probe_hotlist_extractor(transport, browser_tool, top_n)? {
if let Some(items) = probe_hotlist_extractor(transport, browser_tool, skills_dir, top_n)? {
return Ok(Some(items));
}
}
@@ -277,7 +293,7 @@ fn ensure_hotlist_page_ready(
for attempt in 0..2 {
navigate_hotlist_page(transport, browser_tool)?;
let _ = poll_for_hotlist_readiness(browser_tool);
if let Some(items) = probe_hotlist_extractor(transport, browser_tool, top_n)? {
if let Some(items) = probe_hotlist_extractor(transport, browser_tool, skills_dir, top_n)? {
return Ok(Some(items));
}
last_error = Some(format!(
@@ -304,6 +320,7 @@ fn ensure_hotlist_page_ready(
/// reports "editor_unavailable".
fn poll_for_editor_readiness(
browser_tool: &dyn BrowserBackend,
skills_dir: &Path,
desired_mode: &str,
) -> Result<Value, PipeError> {
let args = json!({ "desired_mode": desired_mode });
@@ -312,6 +329,7 @@ fn poll_for_editor_readiness(
for attempt in 0..EDITOR_READY_POLL_ATTEMPTS {
match execute_browser_skill_script(
browser_tool,
skills_dir,
"zhihu-write",
"prepare_article_editor.js",
args.clone(),
@@ -325,9 +343,7 @@ fn poll_for_editor_readiness(
last_state = Some(state);
}
Err(PipeError::PipeClosed) => return Err(PipeError::PipeClosed),
Err(_) => {
// Script may fail while the page is still navigating; tolerate.
}
Err(_) => {}
}
if attempt + 1 < EDITOR_READY_POLL_ATTEMPTS {
@@ -335,12 +351,11 @@ fn poll_for_editor_readiness(
}
}
// Return the last observed state so the caller can surface the
// "editor_unavailable" message; or make one final attempt.
match last_state {
Some(state) => Ok(state),
None => execute_browser_skill_script(
browser_tool,
skills_dir,
"zhihu-write",
"prepare_article_editor.js",
args,
@@ -352,6 +367,7 @@ fn poll_for_editor_readiness(
fn probe_hotlist_extractor(
transport: &dyn crate::agent::AgentEventSink,
browser_tool: &dyn BrowserBackend,
skills_dir: &Path,
top_n: usize,
) -> Result<Option<Vec<HotlistItem>>, PipeError> {
transport.send(&AgentMessage::LogEntry {
@@ -360,7 +376,7 @@ fn probe_hotlist_extractor(
})?;
let response = browser_tool.invoke(
Action::Eval,
json!({ "script": load_hotlist_extractor_script(top_n)? }),
json!({ "script": load_hotlist_extractor_script(skills_dir, top_n)? }),
ZHIHU_DOMAIN,
)?;
if !response.success {
@@ -535,6 +551,7 @@ pub fn finalize_screen_export(
fn execute_zhihu_article_route(
transport: &dyn crate::agent::AgentEventSink,
browser_tool: &dyn BrowserBackend,
skills_dir: &Path,
instruction: &str,
task_context: &CompatTaskContext,
publish_mode: bool,
@@ -559,6 +576,7 @@ fn execute_zhihu_article_route(
})?;
let creator_state = execute_browser_skill_script(
browser_tool,
skills_dir,
"zhihu-navigate",
"open_creator_entry.js",
json!({ "desired_target": "article_editor" }),
@@ -582,6 +600,7 @@ fn execute_zhihu_article_route(
})?;
let editor_state = poll_for_editor_readiness(
browser_tool,
skills_dir,
if publish_mode { "publish" } else { "draft" },
)?;
if is_login_required_payload(&editor_state) {
@@ -600,10 +619,11 @@ fn execute_zhihu_article_route(
message: "call zhihu-write.fill_article_draft".to_string(),
})?;
let fill_result = if browser_tool.supports_live_input() {
execute_zhihu_fill_via_live_input(browser_tool, &article, publish_mode)?
execute_zhihu_fill_via_live_input(browser_tool, skills_dir, &article, publish_mode)?
} else {
execute_browser_skill_script(
browser_tool,
skills_dir,
"zhihu-write",
"fill_article_draft.js",
json!({
@@ -641,6 +661,7 @@ fn execute_zhihu_article_route(
fn execute_generated_zhihu_article_publish_route(
transport: &dyn crate::agent::AgentEventSink,
browser_tool: &dyn BrowserBackend,
skills_dir: &Path,
instruction: &str,
task_context: &CompatTaskContext,
workspace_root: &Path,
@@ -661,6 +682,7 @@ fn execute_generated_zhihu_article_publish_route(
execute_zhihu_article_route(
transport,
browser_tool,
skills_dir,
instruction,
task_context,
true,
@@ -701,6 +723,7 @@ fn task_requests_zhihu_generated_article_publish(
fn execute_zhihu_article_entry_route(
transport: &dyn crate::agent::AgentEventSink,
browser_tool: &dyn BrowserBackend,
skills_dir: &Path,
) -> Result<String, PipeError> {
navigate_zhihu_page(transport, browser_tool, ZHIHU_CREATOR_URL)?;
transport.send(&AgentMessage::LogEntry {
@@ -709,6 +732,7 @@ fn execute_zhihu_article_entry_route(
})?;
let creator_state = execute_browser_skill_script(
browser_tool,
skills_dir,
"zhihu-navigate",
"open_creator_entry.js",
json!({ "desired_target": "article_editor" }),
@@ -730,10 +754,7 @@ fn execute_zhihu_article_entry_route(
level: "info".to_string(),
message: "call zhihu-write.prepare_article_editor".to_string(),
})?;
let editor_state = poll_for_editor_readiness(
browser_tool,
"draft",
)?;
let editor_state = poll_for_editor_readiness(browser_tool, skills_dir, "draft")?;
if is_login_required_payload(&editor_state) {
return Ok(build_login_block_message(payload_current_url(
&editor_state,
@@ -748,8 +769,9 @@ fn execute_zhihu_article_entry_route(
)))
}
fn load_hotlist_extractor_script(top_n: usize) -> Result<String, PipeError> {
fn load_hotlist_extractor_script(skills_dir: &Path, top_n: usize) -> Result<String, PipeError> {
load_browser_skill_script(
skills_dir,
"zhihu-hotlist",
"extract_hotlist.js",
json!({ "top_n": top_n.to_string() }),
@@ -834,12 +856,14 @@ fn navigate_zhihu_page(
fn execute_browser_skill_script(
browser_tool: &dyn BrowserBackend,
skills_dir: &Path,
skill_name: &str,
script_name: &str,
args: Value,
expected_domain: &str,
) -> Result<Value, PipeError> {
let wrapped_script = load_browser_skill_script(skill_name, script_name, args)?;
let wrapped_script =
load_browser_skill_script(skills_dir, skill_name, script_name, args)?;
let response = browser_tool.invoke(
Action::Eval,
json!({ "script": wrapped_script }),
@@ -866,6 +890,7 @@ fn live_input_probe_script(selector_candidates: &[&str]) -> String {
fn execute_zhihu_fill_via_live_input(
browser_tool: &dyn BrowserBackend,
skills_dir: &Path,
article: &ArticleDraft,
publish_mode: bool,
) -> Result<Value, PipeError> {
@@ -1003,6 +1028,7 @@ return JSON.stringify({{status:'ok',chunks:chunks.length}});
// enable the button after the content fill updates the editor state.
let fill_result = execute_browser_skill_script(
browser_tool,
skills_dir,
"zhihu-write",
"fill_article_draft.js",
json!({
@@ -1107,6 +1133,10 @@ mod tests {
.unwrap()
}
fn test_skills_dir() -> &'static Path {
Path::new("D:/data/ideaSpace/rust/sgClaw/claw/claw/skills")
}
struct MockWorkflowTransport {
sent: Mutex<Vec<AgentMessage>>,
responses: Mutex<VecDeque<BrowserMessage>>,
@@ -1266,6 +1296,7 @@ mod tests {
transport.as_ref(),
backend.clone(),
Path::new("."),
test_skills_dir(),
"打开知乎写文章页面",
&CompatTaskContext::default(),
WorkflowRoute::ZhihuArticleEntry,
@@ -1286,6 +1317,7 @@ mod tests {
Action::Eval,
json!({
"script": load_browser_skill_script(
test_skills_dir(),
"zhihu-navigate",
"open_creator_entry.js",
json!({ "desired_target": "article_editor" })
@@ -1298,6 +1330,7 @@ mod tests {
Action::Eval,
json!({
"script": load_browser_skill_script(
test_skills_dir(),
"zhihu-write",
"prepare_article_editor.js",
json!({ "desired_mode": "draft" })
@@ -1370,6 +1403,7 @@ mod tests {
transport.as_ref(),
backend.clone(),
Path::new("."),
test_skills_dir(),
"打开知乎写文章页面",
&CompatTaskContext::default(),
WorkflowRoute::ZhihuArticleEntry,
@@ -1390,6 +1424,7 @@ mod tests {
Action::Eval,
json!({
"script": load_browser_skill_script(
test_skills_dir(),
"zhihu-navigate",
"open_creator_entry.js",
json!({ "desired_target": "article_editor" })
@@ -1407,6 +1442,7 @@ mod tests {
Action::Eval,
json!({
"script": load_browser_skill_script(
test_skills_dir(),
"zhihu-write",
"prepare_article_editor.js",
json!({ "desired_mode": "draft" })
@@ -1495,6 +1531,7 @@ mod tests {
let summary = execute_zhihu_article_route(
transport.as_ref(),
backend.as_ref(),
test_skills_dir(),
"标题:测试标题\n正文:第一段内容",
&CompatTaskContext::default(),
false,
@@ -1625,6 +1662,7 @@ mod tests {
let summary = execute_zhihu_article_route(
transport.as_ref(),
backend.as_ref(),
test_skills_dir(),
"标题:测试标题\n正文:第一段内容",
&CompatTaskContext::default(),
false,
@@ -1655,6 +1693,7 @@ mod tests {
assert_eq!(invocations[8].0, Action::Eval);
assert_eq!(invocations[8].1["script"], json!(
load_browser_skill_script(
test_skills_dir(),
"zhihu-write",
"fill_article_draft.js",
json!({
@@ -1753,6 +1792,7 @@ mod tests {
let _ = execute_zhihu_article_route(
transport.as_ref(),
backend.as_ref(),
test_skills_dir(),
"标题:测试标题\n正文:第一段内容\n第二段内容",
&CompatTaskContext::default(),
false,
@@ -1771,6 +1811,7 @@ mod tests {
#[test]
fn zhihu_fill_script_checks_live_input_before_dom_fill_fallback() {
let script = load_browser_skill_script(
test_skills_dir(),
"zhihu-write",
"fill_article_draft.js",
json!({
@@ -1805,6 +1846,7 @@ mod tests {
#[test]
fn zhihu_fill_script_live_input_uses_editor_content_instead_of_whole_page_text() {
let script = load_browser_skill_script(
test_skills_dir(),
"zhihu-write",
"fill_article_draft.js",
json!({
@@ -1897,6 +1939,7 @@ mod tests {
transport.as_ref(),
backend.clone(),
Path::new("."),
test_skills_dir(),
"打开知乎写文章页面",
&CompatTaskContext::default(),
WorkflowRoute::ZhihuArticleEntry,
@@ -1917,6 +1960,7 @@ mod tests {
Action::Eval,
json!({
"script": load_browser_skill_script(
test_skills_dir(),
"zhihu-navigate",
"open_creator_entry.js",
json!({ "desired_target": "article_editor" })
@@ -1934,6 +1978,7 @@ mod tests {
Action::Eval,
json!({
"script": load_browser_skill_script(
test_skills_dir(),
"zhihu-write",
"prepare_article_editor.js",
json!({ "desired_mode": "draft" })
@@ -1975,7 +2020,13 @@ mod tests {
};
let browser_backend = PipeBrowserBackend::from_inner(browser_tool);
let items = collect_hotlist_items(transport.as_ref(), &browser_backend, 10, &task_context)
let items = collect_hotlist_items(
transport.as_ref(),
&browser_backend,
test_skills_dir(),
10,
&task_context,
)
.expect("hotlist collection should succeed");
assert_eq!(items.len(), 2);
@@ -2029,7 +2080,13 @@ mod tests {
};
let browser_backend = PipeBrowserBackend::from_inner(browser_tool);
let items = collect_hotlist_items(transport.as_ref(), &browser_backend, 10, &task_context)
let items = collect_hotlist_items(
transport.as_ref(),
&browser_backend,
test_skills_dir(),
10,
&task_context,
)
.expect("hotlist collection should succeed after readiness polling");
assert_eq!(items.len(), 1);
@@ -2098,7 +2155,13 @@ mod tests {
};
let browser_backend = PipeBrowserBackend::from_inner(browser_tool);
let items = collect_hotlist_items(transport.as_ref(), &browser_backend, 10, &task_context)
let items = collect_hotlist_items(
transport.as_ref(),
&browser_backend,
test_skills_dir(),
10,
&task_context,
)
.expect("hotlist collection should succeed after one navigation retry");
assert_eq!(items.len(), 1);
@@ -2165,7 +2228,13 @@ mod tests {
};
let browser_backend = PipeBrowserBackend::from_inner(browser_tool);
let items = collect_hotlist_items(transport.as_ref(), &browser_backend, 10, &task_context)
let items = collect_hotlist_items(
transport.as_ref(),
&browser_backend,
test_skills_dir(),
10,
&task_context,
)
.expect("hotlist collection should succeed via extractor probe");
assert_eq!(items.len(), 1);
@@ -2184,15 +2253,12 @@ mod tests {
}
fn load_browser_skill_script(
skills_dir: &Path,
skill_name: &str,
script_name: &str,
args: Value,
) -> Result<String, PipeError> {
let script_path = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")))
.join("skill_lib")
.join("skills")
let script_path = skills_dir
.join(skill_name)
.join("scripts")
.join(script_name);