fix(hands): hand_researcher 参数容错 — LLM不传action字段时自动推断
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

根因: glm等国内LLM调用hand_researcher时不带action字段,
导致"missing field action"反复报错触发LoopGuard拦截。

修复: execute()先尝试严格反序列化,失败时调用infer_action()
从输入字段推断意图:
- 有query → search
- 有url → fetch
- 有urls → summarize
- 都没有 → 友好错误提示

验证: 160 tests PASS
This commit is contained in:
iven
2026-04-22 14:23:52 +08:00
parent bb1869bb1b
commit bc9537cd80

View File

@@ -301,6 +301,61 @@ impl ResearcherHand {
} }
} }
/// Infer action from input fields when LLM omits the `action` field.
/// Many LLMs (especially non-OpenAI models like glm) call tools without
/// including the enum tag, e.g. sending `{"query": "search terms"}` instead
/// of `{"action": "search", "query": "search terms"}`.
fn infer_action(input: &Value) -> Result<ResearcherAction> {
// Has "url" (singular) → fetch
if let Some(url) = input.get("url").and_then(|v| v.as_str()) {
if !url.is_empty() && url.starts_with("http") {
return Ok(ResearcherAction::Fetch { url: url.to_string() });
}
}
// Has "urls" (plural) → summarize
if let Some(urls) = input.get("urls").and_then(|v| v.as_array()) {
let url_list: Vec<String> = urls.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
if !url_list.is_empty() {
return Ok(ResearcherAction::Summarize { urls: url_list });
}
}
// Has "query" (string or object) → search
if let Some(query_val) = input.get("query") {
let query: ResearchQuery = if query_val.is_string() {
// LLM sent plain string: {"query": "search terms"}
ResearchQuery {
query: query_val.as_str().unwrap_or("").to_string(),
engine: SearchEngine::Auto,
depth: ResearchDepth::Standard,
max_results: 10,
include_related: false,
time_limit_secs: 60,
}
} else {
// LLM sent object: {"query": {"query": "...", "engine": "..."}}
serde_json::from_value(query_val.clone()).unwrap_or_else(|_| ResearchQuery {
query: query_val.get("query")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
engine: SearchEngine::Auto,
depth: ResearchDepth::Standard,
max_results: 10,
include_related: false,
time_limit_secs: 60,
})
};
if !query.query.trim().is_empty() {
return Ok(ResearcherAction::Search { query });
}
}
Err(zclaw_types::ZclawError::HandError(
"无法识别搜索意图:请提供 query搜索或 url获取网页参数".to_string()
))
}
/// Execute a web search — route to the configured backend /// Execute a web search — route to the configured backend
async fn execute_search(&self, query: &ResearchQuery) -> Result<Vec<SearchResult>> { async fn execute_search(&self, query: &ResearchQuery) -> Result<Vec<SearchResult>> {
query.validate().map_err(|e| zclaw_types::ZclawError::HandError(e))?; query.validate().map_err(|e| zclaw_types::ZclawError::HandError(e))?;
@@ -1030,8 +1085,11 @@ impl Hand for ResearcherHand {
} }
async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> { async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
let action: ResearcherAction = serde_json::from_value(input.clone()) // Try strict deserialization first, then fall back to inference
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Invalid action: {}", e)))?; let action: ResearcherAction = match serde_json::from_value(input.clone()) {
Ok(a) => a,
Err(_) => Self::infer_action(&input)?,
};
let start = std::time::Instant::now(); let start = std::time::Instant::now();