refactor(service): unify submit bootstrap target resolution

Use page context, deterministic plans, and direct-skill metadata as the service-owned bootstrap target precedence so callback-host startup no longer relies on line-loss text matching or the old request-url helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
木炎
2026-04-14 20:23:24 +08:00
parent 044d38003d
commit bd83d92480
8 changed files with 498 additions and 49 deletions

View File

@@ -4,6 +4,7 @@ use std::sync::Arc;
use crate::browser::ws_backend::WsBrowserBackend;
use crate::browser::{BrowserBackend, PipeBrowserBackend};
use crate::config::SgClawSettings;
use crate::pipe::{BrowserMessage, BrowserPipeTool, PipeError, Transport};
pub use task_runner::{
@@ -22,13 +23,27 @@ fn browser_backend_for_submit<T: Transport + 'static>(
request: &SubmitTaskRequest,
) -> Result<Arc<dyn BrowserBackend>, PipeError> {
if let Some(browser_ws_url) = configured_browser_ws_url(context) {
let settings = context.load_sgclaw_settings()?.unwrap_or(
SgClawSettings::from_legacy_deepseek_fields(
"test-key".to_string(),
"https://example.invalid".to_string(),
"test-model".to_string(),
None,
)
.map_err(|err| PipeError::Protocol(err.to_string()))?,
);
let bootstrap_target = crate::service::server::resolve_submit_bootstrap_target(
request,
context.workspace_root(),
&settings,
);
return Ok(Arc::new(
WsBrowserBackend::new(
Arc::new(crate::service::browser_ws_client::ServiceWsClient::connect(
&browser_ws_url,
)?),
browser_tool.mac_policy().clone(),
crate::service::browser_ws_client::initial_request_url_for_submit_task(request),
bootstrap_target.request_url,
)
.with_response_timeout(browser_tool.response_timeout()),
));

View File

@@ -68,6 +68,10 @@ impl AgentRuntimeContext {
self.config_path.as_deref()
}
pub fn workspace_root(&self) -> &Path {
&self.workspace_root
}
fn settings_source_label(&self) -> String {
match &self.config_path {
Some(path) if path.exists() => path.display().to_string(),

View File

@@ -1,7 +1,9 @@
use std::fs;
use std::path::Path;
use std::sync::Arc;
use reqwest::Url;
use serde::Deserialize;
use serde_json::{Map, Value};
use zeroclaw::skills::{load_skills_from_directory, SkillTool};
@@ -12,6 +14,12 @@ use crate::compat::runtime::CompatTaskContext;
use crate::config::SgClawSettings;
use crate::pipe::{BrowserPipeTool, PipeError, Transport};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DirectSubmitBootstrapMetadata {
pub bootstrap_url: String,
pub expected_domain: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirectSubmitOutcome {
pub success: bool,
@@ -111,6 +119,32 @@ pub fn execute_browser_script_skill_raw_output_with_browser_backend(
)
}
pub(crate) fn resolve_direct_submit_bootstrap_metadata(
configured_tool: &str,
workspace_root: &Path,
settings: &SgClawSettings,
) -> Result<Option<DirectSubmitBootstrapMetadata>, PipeError> {
let (tool, skill_root) = resolve_browser_script_skill(configured_tool, workspace_root, settings)?;
let manifest_path = skill_root.join("SKILL.toml");
let Ok(manifest) = fs::read_to_string(&manifest_path) else {
return Ok(None);
};
let Ok(parsed) = toml::from_str::<DirectSubmitSkillManifest>(&manifest) else {
return Ok(None);
};
let metadata = parsed
.tools
.into_iter()
.find(|candidate| candidate.name == tool.name)
.and_then(|candidate| candidate.metadata)
.and_then(|metadata| {
normalize_bootstrap_metadata(metadata.bootstrap_url, metadata.expected_domain)
});
Ok(metadata)
}
fn resolve_browser_script_skill(
configured_tool: &str,
workspace_root: &Path,
@@ -306,6 +340,50 @@ fn count_summary_rows(counts: Option<&Value>, sections: Option<&Value>) -> usize
.unwrap_or(0)
}
#[derive(Debug, Deserialize)]
struct DirectSubmitSkillManifest {
#[serde(default)]
tools: Vec<DirectSubmitSkillManifestTool>,
}
#[derive(Debug, Deserialize)]
struct DirectSubmitSkillManifestTool {
name: String,
#[serde(default)]
metadata: Option<DirectSubmitToolMetadata>,
}
#[derive(Debug, Deserialize)]
struct DirectSubmitToolMetadata {
#[serde(default)]
bootstrap_url: Option<String>,
#[serde(default)]
expected_domain: Option<String>,
}
fn normalize_bootstrap_metadata(
bootstrap_url: Option<String>,
expected_domain: Option<String>,
) -> Option<DirectSubmitBootstrapMetadata> {
let bootstrap_url = bootstrap_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())?;
let parsed = Url::parse(bootstrap_url).ok()?;
if parsed.scheme().is_empty() || parsed.host_str().is_none() {
return None;
}
Some(DirectSubmitBootstrapMetadata {
bootstrap_url: parsed.to_string(),
expected_domain: expected_domain
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string),
})
}
fn parse_configured_tool_name(configured_tool: &str) -> Result<(&str, &str), PipeError> {
let (skill_name, tool_name) = configured_tool.split_once('.').ok_or_else(|| {
PipeError::Protocol(format!(

View File

@@ -18,7 +18,7 @@ pub use protocol::{ClientMessage, ConfigUpdatePayload, ServiceMessage};
pub use server::{ServiceEventSink, ServiceSession};
pub(crate) mod browser_ws_client {
pub(crate) use super::server::{initial_request_url_for_submit_task, ServiceWsClient};
pub(crate) use super::server::ServiceWsClient;
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]

View File

@@ -4,6 +4,7 @@ use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use reqwest::Url;
#[cfg(test)]
use reqwest::blocking::Client;
#[cfg(test)]
@@ -28,6 +29,7 @@ use crate::browser::bridge_transport::BridgeActionTransport;
use crate::browser::{BrowserBackend, BrowserCallbackBackend};
#[cfg(test)]
use crate::browser::BridgeBrowserBackend;
use crate::config::SgClawSettings;
use crate::pipe::{AgentMessage, BrowserMessage, PipeError, Transport};
#[cfg(test)]
use crate::pipe::Timing;
@@ -329,7 +331,21 @@ pub(crate) fn serve_client(
// Lazily create and cache the browser callback host. On first
// task it opens the helper page; subsequent tasks reuse it.
if cached_host.is_none() {
let bootstrap_url = initial_request_url_for_submit_task(&request);
let settings = context.load_sgclaw_settings()?.unwrap_or(
SgClawSettings::from_legacy_deepseek_fields(
"test-key".to_string(),
"https://example.invalid".to_string(),
"test-model".to_string(),
None,
)
.map_err(|err| PipeError::Protocol(err.to_string()))?,
);
let bootstrap_target = resolve_submit_bootstrap_target(
&request,
context.workspace_root(),
&settings,
);
let bootstrap_url = bootstrap_target.request_url;
match LiveBrowserCallbackHost::start_with_browser_ws_url(
browser_ws_url,
&bootstrap_url,
@@ -419,15 +435,82 @@ pub(crate) fn serve_client(
}
}
pub(crate) fn initial_request_url_for_submit_task(request: &crate::agent::SubmitTaskRequest) -> String {
request
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SubmitBootstrapTarget {
pub request_url: String,
pub expected_domain: Option<String>,
pub source: BootstrapTargetSource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum BootstrapTargetSource {
PageContext,
DeterministicPlan,
SkillConfig,
Fallback,
}
pub(crate) fn resolve_submit_bootstrap_target(
request: &crate::agent::SubmitTaskRequest,
workspace_root: &Path,
settings: &SgClawSettings,
) -> SubmitBootstrapTarget {
if let Some(page_url) = request
.page_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.or_else(|| derive_request_url_from_instruction(&request.instruction))
.unwrap_or_else(|| "about:blank".to_string())
{
return SubmitBootstrapTarget {
request_url: page_url.to_string(),
expected_domain: Url::parse(page_url)
.ok()
.and_then(|url| url.domain().map(ToString::to_string)),
source: BootstrapTargetSource::PageContext,
};
}
if let crate::compat::deterministic_submit::DeterministicSubmitDecision::Execute(plan) =
crate::compat::deterministic_submit::decide_deterministic_submit(
&request.instruction,
request.page_url.as_deref(),
request.page_title.as_deref(),
)
{
return SubmitBootstrapTarget {
request_url: plan.target_url.clone(),
expected_domain: Some(plan.expected_domain.clone()),
source: BootstrapTargetSource::DeterministicPlan,
};
}
if let Some(configured_tool) = settings
.direct_submit_skill
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
if let Ok(Some(metadata)) =
crate::compat::direct_skill_runtime::resolve_direct_submit_bootstrap_metadata(
configured_tool,
workspace_root,
settings,
)
{
return SubmitBootstrapTarget {
request_url: metadata.bootstrap_url,
expected_domain: metadata.expected_domain,
source: BootstrapTargetSource::SkillConfig,
};
}
}
SubmitBootstrapTarget {
request_url: derive_request_url_from_instruction(&request.instruction)
.unwrap_or_else(|| "about:blank".to_string()),
expected_domain: None,
source: BootstrapTargetSource::Fallback,
}
}
fn derive_request_url_from_instruction(instruction: &str) -> Option<String> {
@@ -457,12 +540,6 @@ fn derive_request_url_from_instruction(instruction: &str) -> Option<String> {
return Some("https://zhuanlan.zhihu.com".to_string());
}
// 台区线损相关
// TODO: 临时方案,后续应从 skill 配置或 deterministic_submit 解析结果中获取
if instruction.contains("线损") || instruction.contains("lineloss") {
return Some("http://20.76.57.61:18080".to_string());
}
None
}
@@ -834,16 +911,77 @@ fn write_http_json_response(stream: &mut impl std::io::Write, status: &str, body
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::net::TcpListener;
use std::sync::{mpsc, Arc};
use std::thread;
use serde_json::json;
use uuid::Uuid;
use crate::agent::SubmitTaskRequest;
use crate::browser::BrowserBackend;
use crate::pipe::Action;
fn service_test_settings(
skills_dir: Option<PathBuf>,
direct_submit_skill: Option<&str>,
) -> SgClawSettings {
let mut settings = SgClawSettings::from_legacy_deepseek_fields(
"test-key".to_string(),
"https://example.invalid".to_string(),
"test-model".to_string(),
skills_dir,
)
.expect("settings");
settings.direct_submit_skill = direct_submit_skill.map(ToString::to_string);
settings
}
fn staged_skill_staging_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../claw/claw/skills/skill_staging/skills")
.canonicalize()
.expect("staged skills dir")
}
fn temp_direct_submit_skill_root(bootstrap_url: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!(
"sgclaw-bootstrap-target-skill-root-{}",
Uuid::new_v4()
));
let skill_dir = root.join("fault-details-report");
let script_dir = skill_dir.join("scripts");
fs::create_dir_all(&script_dir).expect("create script dir");
fs::write(
skill_dir.join("SKILL.toml"),
format!(
r#"[skill]
name = "fault-details-report"
description = "Collect 95598 fault detail data via browser eval."
version = "0.1.0"
[[tools]]
name = "collect_fault_details"
description = "Collect structured fault detail rows for a specific period."
kind = "browser_script"
command = "scripts/collect_fault_details.js"
[tools.metadata]
bootstrap_url = "{bootstrap_url}"
expected_domain = "95598.sgcc.com.cn"
"#
),
)
.expect("write skill manifest");
fs::write(
script_dir.join("collect_fault_details.js"),
"return { ok: true };\n",
)
.expect("write skill script");
root
}
fn service_test_policy() -> MacPolicy {
MacPolicy::from_json_str(
r#"{
@@ -859,56 +997,165 @@ mod tests {
}
#[test]
fn initial_request_url_prefers_submit_task_page_url() {
fn page_context_bootstrap_target_wins_over_deterministic_and_skill_fallback() {
let request = SubmitTaskRequest {
instruction: "打开知乎热榜".to_string(),
page_url: Some(" https://www.zhihu.com/ ".to_string()),
instruction: "兰州公司 台区线损大数据 月累计线损率统计分析。。。".to_string(),
page_url: Some(" https://already-open.example.com/page ".to_string()),
..SubmitTaskRequest::default()
};
let settings = SgClawSettings::from_legacy_deepseek_fields(
"test-key".to_string(),
"https://example.invalid".to_string(),
"test-model".to_string(),
None,
)
.expect("settings");
assert_eq!(
initial_request_url_for_submit_task(&request),
"https://www.zhihu.com/"
);
let target = resolve_submit_bootstrap_target(&request, Path::new("."), &settings);
assert_eq!(target.request_url, "https://already-open.example.com/page");
assert_eq!(target.expected_domain.as_deref(), Some("already-open.example.com"));
assert_eq!(target.source, BootstrapTargetSource::PageContext);
}
#[test]
fn initial_request_url_falls_back_to_zhihu_origin_for_hotlist_routes() {
fn whitespace_page_url_does_not_short_circuit_bootstrap_fallback() {
let request = SubmitTaskRequest {
instruction: "打开知乎热榜获取前10条数据并导出 Excel".to_string(),
page_url: Some(" ".to_string()),
..SubmitTaskRequest::default()
};
let settings = SgClawSettings::from_legacy_deepseek_fields(
"test-key".to_string(),
"https://example.invalid".to_string(),
"test-model".to_string(),
None,
)
.expect("settings");
assert_eq!(
initial_request_url_for_submit_task(&request),
"https://www.zhihu.com"
);
let target = resolve_submit_bootstrap_target(&request, Path::new("."), &settings);
assert_eq!(target.request_url, "https://www.zhihu.com");
assert_eq!(target.source, BootstrapTargetSource::Fallback);
}
#[test]
fn initial_request_url_falls_back_to_zhihu_origin_for_generated_article_publish_routes() {
let request = SubmitTaskRequest {
instruction: "在知乎自动发表一篇名称为人工智能技能大全".to_string(),
..SubmitTaskRequest::default()
};
assert_eq!(
initial_request_url_for_submit_task(&request),
"https://www.zhihu.com"
);
}
#[test]
fn initial_request_url_falls_back_to_lineloss_origin_for_lineloss_instructions() {
fn deterministic_bootstrap_target_uses_plan_target_url() {
let request = SubmitTaskRequest {
instruction: "兰州公司 台区线损大数据 月累计线损率统计分析。。。".to_string(),
..SubmitTaskRequest::default()
};
let settings = service_test_settings(None, None);
let target = resolve_submit_bootstrap_target(&request, Path::new("."), &settings);
assert_eq!(
initial_request_url_for_submit_task(&request),
"http://20.76.57.61:18080"
target.request_url,
"http://20.76.57.61:18080/gsllys/tqLinelossStatis/tqQualifyRateMonitor"
);
assert_eq!(target.expected_domain.as_deref(), Some("20.76.57.61"));
assert_eq!(target.source, BootstrapTargetSource::DeterministicPlan);
}
#[test]
fn skill_metadata_bootstrap_url_is_used_when_no_page_context_or_plan_exists() {
let request = SubmitTaskRequest {
instruction: "请采集 2026-03 的故障明细并返回结果".to_string(),
..SubmitTaskRequest::default()
};
let settings = service_test_settings(
Some(staged_skill_staging_dir()),
Some("fault-details-report.collect_fault_details"),
);
let target = resolve_submit_bootstrap_target(&request, Path::new("."), &settings);
assert_eq!(target.request_url, "https://95598.sgcc.com.cn/");
assert_eq!(target.expected_domain.as_deref(), Some("95598.sgcc.com.cn"));
assert_eq!(target.source, BootstrapTargetSource::SkillConfig);
}
#[test]
fn malformed_skill_bootstrap_url_falls_back_to_about_blank() {
let request = SubmitTaskRequest {
instruction: "请采集 2026-03 的故障明细并返回结果".to_string(),
..SubmitTaskRequest::default()
};
let skills_dir = temp_direct_submit_skill_root("not-a-valid-absolute-url");
let settings = service_test_settings(
Some(skills_dir.clone()),
Some("fault-details-report.collect_fault_details"),
);
let target = resolve_submit_bootstrap_target(&request, Path::new("."), &settings);
assert_eq!(target.request_url, "about:blank");
assert_eq!(target.expected_domain, None);
assert_eq!(target.source, BootstrapTargetSource::Fallback);
let _ = fs::remove_dir_all(skills_dir);
}
#[test]
fn bootstrap_target_precedence_matrix_covers_page_context_deterministic_skill_and_fallback() {
let page_request = SubmitTaskRequest {
instruction: "兰州公司 台区线损大数据 月累计线损率统计分析。。。".to_string(),
page_url: Some(" https://already-open.example.com/page ".to_string()),
..SubmitTaskRequest::default()
};
let page_settings = service_test_settings(
Some(staged_skill_staging_dir()),
Some("fault-details-report.collect_fault_details"),
);
let page_target =
resolve_submit_bootstrap_target(&page_request, Path::new("."), &page_settings);
assert_eq!(page_target.request_url, "https://already-open.example.com/page");
assert_eq!(page_target.source, BootstrapTargetSource::PageContext);
let deterministic_request = SubmitTaskRequest {
instruction: "兰州公司 台区线损大数据 月累计线损率统计分析。。。".to_string(),
..SubmitTaskRequest::default()
};
let deterministic_settings = service_test_settings(
Some(staged_skill_staging_dir()),
Some("fault-details-report.collect_fault_details"),
);
let deterministic_target = resolve_submit_bootstrap_target(
&deterministic_request,
Path::new("."),
&deterministic_settings,
);
assert_eq!(
deterministic_target.request_url,
"http://20.76.57.61:18080/gsllys/tqLinelossStatis/tqQualifyRateMonitor"
);
assert_eq!(
deterministic_target.source,
BootstrapTargetSource::DeterministicPlan
);
let skill_request = SubmitTaskRequest {
instruction: "请采集 2026-03 的故障明细并返回结果".to_string(),
..SubmitTaskRequest::default()
};
let skill_settings = service_test_settings(
Some(staged_skill_staging_dir()),
Some("fault-details-report.collect_fault_details"),
);
let skill_target =
resolve_submit_bootstrap_target(&skill_request, Path::new("."), &skill_settings);
assert_eq!(skill_target.request_url, "https://95598.sgcc.com.cn/");
assert_eq!(skill_target.source, BootstrapTargetSource::SkillConfig);
let fallback_request = SubmitTaskRequest {
instruction: "完全不相关的普通问题".to_string(),
..SubmitTaskRequest::default()
};
let fallback_settings = service_test_settings(None, None);
let fallback_target =
resolve_submit_bootstrap_target(&fallback_request, Path::new("."), &fallback_settings);
assert_eq!(fallback_target.request_url, "about:blank");
assert_eq!(fallback_target.source, BootstrapTargetSource::Fallback);
}
#[test]