fix(growth,kernel,runtime,desktop): 50 轮功能链路审计 7 项断链修复
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
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
P0 修复: - B-MEM-2: 跨会话记忆丢失 — 添加 IdentityRecall 查询意图检测, 身份类查询绕过 FTS5/LIKE 文本搜索,直接按 scope 检索全部偏好+知识记忆; 缓存 GrowthIntegration 到 Kernel 避免每次请求重建空 scorer - B-HAND-1: Hands 未触发 — 创建 HandTool wrapper 实现 Tool trait, 在 create_tool_registry() 中注册所有已启用 Hands 为 LLM 可调用工具 P1 修复: - B-SCHED-4: 一次性定时未拦截 — 添加 RE_ONE_SHOT_TODAY 正则匹配 "下午3点半提醒我..."类无日期前缀的同日触发模式 - B-CHAT-2: 工具调用循环 — ToolErrorMiddleware 添加连续失败计数器, 3 次连续失败后自动 AbortLoop 防止无限重试 - B-CHAT-5: Stream 竞态 — cancelStream 后添加 500ms cancelCooldown, 防止后端 active-stream 检查竞态
This commit is contained in:
@@ -36,6 +36,9 @@ pub enum QueryIntent {
|
||||
Code,
|
||||
/// Configuration query
|
||||
Configuration,
|
||||
/// Identity/personal recall — user asks about themselves or past conversations
|
||||
/// Triggers broad retrieval of all preference + knowledge memories
|
||||
IdentityRecall,
|
||||
}
|
||||
|
||||
/// Query analyzer
|
||||
@@ -50,6 +53,8 @@ pub struct QueryAnalyzer {
|
||||
code_indicators: HashSet<String>,
|
||||
/// Stop words to filter out
|
||||
stop_words: HashSet<String>,
|
||||
/// Patterns indicating identity/personal recall queries
|
||||
identity_patterns: Vec<String>,
|
||||
}
|
||||
|
||||
impl QueryAnalyzer {
|
||||
@@ -99,13 +104,38 @@ impl QueryAnalyzer {
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect(),
|
||||
identity_patterns: [
|
||||
// Chinese identity recall patterns
|
||||
"我是谁", "我叫什么", "我之前", "我告诉过你", "我之前告诉",
|
||||
"还记得我", "你还记得", "我的名字", "我的身份", "我的信息",
|
||||
"我的工作", "我在哪", "我的偏好", "我喜欢什么",
|
||||
"关于我", "了解我", "记得我", "我之前说过",
|
||||
// English identity recall patterns
|
||||
"who am i", "what is my name", "what do you know about me",
|
||||
"what did i tell", "do you remember me", "what do you remember",
|
||||
"my preferences", "about me", "what have i shared",
|
||||
]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Analyze a query string
|
||||
pub fn analyze(&self, query: &str) -> AnalyzedQuery {
|
||||
let keywords = self.extract_keywords(query);
|
||||
let intent = self.classify_intent(&keywords);
|
||||
|
||||
// Check for identity recall patterns first (highest priority)
|
||||
let query_lower = query.to_lowercase();
|
||||
let is_identity = self.identity_patterns.iter()
|
||||
.any(|pattern| query_lower.contains(&pattern.to_lowercase()));
|
||||
|
||||
let intent = if is_identity {
|
||||
QueryIntent::IdentityRecall
|
||||
} else {
|
||||
self.classify_intent(&keywords)
|
||||
};
|
||||
|
||||
let target_types = self.infer_memory_types(intent, &keywords);
|
||||
let expansions = self.expand_query(&keywords);
|
||||
|
||||
@@ -189,6 +219,12 @@ impl QueryAnalyzer {
|
||||
types.push(MemoryType::Preference);
|
||||
types.push(MemoryType::Knowledge);
|
||||
}
|
||||
QueryIntent::IdentityRecall => {
|
||||
// Identity recall needs all memory types
|
||||
types.push(MemoryType::Preference);
|
||||
types.push(MemoryType::Knowledge);
|
||||
types.push(MemoryType::Experience);
|
||||
}
|
||||
}
|
||||
|
||||
types
|
||||
|
||||
@@ -67,6 +67,11 @@ impl MemoryRetriever {
|
||||
analyzed.keywords
|
||||
);
|
||||
|
||||
// Identity recall uses broad scope-based retrieval (bypasses text search)
|
||||
if analyzed.intent == crate::retrieval::query::QueryIntent::IdentityRecall {
|
||||
return self.retrieve_broad_identity(agent_id).await;
|
||||
}
|
||||
|
||||
// Retrieve each type with budget constraints and reranking
|
||||
let preferences = self
|
||||
.retrieve_and_rerank(
|
||||
@@ -230,6 +235,107 @@ impl MemoryRetriever {
|
||||
scored.into_iter().map(|(_, entry)| entry).collect()
|
||||
}
|
||||
|
||||
/// Broad identity recall — retrieves all recent preference + knowledge memories
|
||||
/// without requiring text match. Used when the user asks about themselves.
|
||||
///
|
||||
/// This bypasses FTS5/LIKE search entirely and does a scope-based retrieval
|
||||
/// sorted by recency and importance, ensuring identity information is always
|
||||
/// available across sessions.
|
||||
async fn retrieve_broad_identity(&self, agent_id: &AgentId) -> Result<RetrievalResult> {
|
||||
tracing::info!(
|
||||
"[MemoryRetriever] Broad identity recall for agent: {}",
|
||||
agent_id
|
||||
);
|
||||
|
||||
let agent_str = agent_id.to_string();
|
||||
|
||||
// Retrieve preferences (scope-only, no text search)
|
||||
let preferences = self.retrieve_by_scope(
|
||||
&agent_str,
|
||||
MemoryType::Preference,
|
||||
self.config.max_results_per_type,
|
||||
self.config.preference_budget,
|
||||
).await?;
|
||||
|
||||
// Retrieve knowledge (scope-only)
|
||||
let knowledge = self.retrieve_by_scope(
|
||||
&agent_str,
|
||||
MemoryType::Knowledge,
|
||||
self.config.max_results_per_type,
|
||||
self.config.knowledge_budget,
|
||||
).await?;
|
||||
|
||||
// Retrieve recent experiences (scope-only, limited)
|
||||
let experience = self.retrieve_by_scope(
|
||||
&agent_str,
|
||||
MemoryType::Experience,
|
||||
self.config.max_results_per_type / 2,
|
||||
self.config.experience_budget,
|
||||
).await?;
|
||||
|
||||
let total_tokens = preferences.iter()
|
||||
.chain(knowledge.iter())
|
||||
.chain(experience.iter())
|
||||
.map(|m| m.estimated_tokens())
|
||||
.sum();
|
||||
|
||||
tracing::info!(
|
||||
"[MemoryRetriever] Identity recall: {} preferences, {} knowledge, {} experience",
|
||||
preferences.len(),
|
||||
knowledge.len(),
|
||||
experience.len()
|
||||
);
|
||||
|
||||
Ok(RetrievalResult {
|
||||
preferences,
|
||||
knowledge,
|
||||
experience,
|
||||
total_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
/// Retrieve memories by scope only (no text search).
|
||||
/// Returns entries sorted by importance and recency, limited by budget.
|
||||
async fn retrieve_by_scope(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
memory_type: MemoryType,
|
||||
max_results: usize,
|
||||
token_budget: usize,
|
||||
) -> Result<Vec<MemoryEntry>> {
|
||||
let scope = format!("agent://{}/{}", agent_id, memory_type);
|
||||
let options = FindOptions {
|
||||
scope: Some(scope),
|
||||
limit: Some(max_results * 3), // Fetch more candidates for filtering
|
||||
min_similarity: None, // No similarity threshold for scope-only
|
||||
};
|
||||
|
||||
// Empty query triggers scope-only fetch in SqliteStorage::find()
|
||||
let entries = self.viking.find("", options).await?;
|
||||
|
||||
// Sort by importance (desc) and apply token budget
|
||||
let mut sorted = entries;
|
||||
sorted.sort_by(|a, b| {
|
||||
b.importance.cmp(&a.importance)
|
||||
.then_with(|| b.access_count.cmp(&a.access_count))
|
||||
});
|
||||
|
||||
let mut filtered = Vec::new();
|
||||
let mut used_tokens = 0;
|
||||
for entry in sorted {
|
||||
let tokens = entry.estimated_tokens();
|
||||
if used_tokens + tokens <= token_budget {
|
||||
used_tokens += tokens;
|
||||
filtered.push(entry);
|
||||
}
|
||||
if filtered.len() >= max_results {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(filtered)
|
||||
}
|
||||
|
||||
/// Retrieve a specific memory by URI (with cache)
|
||||
pub async fn get_by_uri(&self, uri: &str) -> Result<Option<MemoryEntry>> {
|
||||
// Check cache first
|
||||
|
||||
@@ -41,12 +41,16 @@ pub struct Kernel {
|
||||
skills: Arc<SkillRegistry>,
|
||||
skill_executor: Arc<KernelSkillExecutor>,
|
||||
hands: Arc<HandRegistry>,
|
||||
/// Cached hand configs (populated at boot, used for tool registry)
|
||||
hand_configs: Vec<zclaw_hands::HandConfig>,
|
||||
trigger_manager: crate::trigger_manager::TriggerManager,
|
||||
pending_approvals: Arc<Mutex<Vec<ApprovalEntry>>>,
|
||||
/// Running hand runs that can be cancelled (run_id -> cancelled flag)
|
||||
running_hand_runs: Arc<dashmap::DashMap<zclaw_types::HandRunId, Arc<std::sync::atomic::AtomicBool>>>,
|
||||
/// Shared memory storage backend for Growth system
|
||||
viking: Arc<zclaw_runtime::VikingAdapter>,
|
||||
/// Cached GrowthIntegration — avoids recreating empty scorer per request
|
||||
growth: std::sync::Mutex<Option<std::sync::Arc<zclaw_runtime::GrowthIntegration>>>,
|
||||
/// Optional LLM driver for memory extraction (set by Tauri desktop layer)
|
||||
extraction_driver: Option<Arc<dyn zclaw_runtime::LlmDriverForExtraction>>,
|
||||
/// MCP tool adapters — shared with Tauri MCP manager, updated dynamically
|
||||
@@ -95,6 +99,9 @@ impl Kernel {
|
||||
hands.register(Arc::new(TwitterHand::new())).await;
|
||||
hands.register(Arc::new(ReminderHand::new())).await;
|
||||
|
||||
// Cache hand configs for tool registry (sync access from create_tool_registry)
|
||||
let hand_configs = hands.list().await;
|
||||
|
||||
// Create skill executor
|
||||
let skill_executor = Arc::new(KernelSkillExecutor::new(skills.clone(), driver.clone()));
|
||||
|
||||
@@ -146,10 +153,12 @@ impl Kernel {
|
||||
skills,
|
||||
skill_executor,
|
||||
hands,
|
||||
hand_configs,
|
||||
trigger_manager,
|
||||
pending_approvals: Arc::new(Mutex::new(Vec::new())),
|
||||
running_hand_runs: Arc::new(dashmap::DashMap::new()),
|
||||
viking,
|
||||
growth: std::sync::Mutex::new(None),
|
||||
extraction_driver: None,
|
||||
mcp_adapters: Arc::new(std::sync::RwLock::new(Vec::new())),
|
||||
industry_keywords: Arc::new(tokio::sync::RwLock::new(Vec::new())),
|
||||
@@ -158,7 +167,7 @@ impl Kernel {
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a tool registry with built-in tools + MCP tools.
|
||||
/// Create a tool registry with built-in tools + Hand tools + MCP tools.
|
||||
/// When `subagent_enabled` is false, TaskTool is excluded to prevent
|
||||
/// the LLM from attempting sub-agent delegation in non-Ultra modes.
|
||||
pub(crate) fn create_tool_registry(&self, subagent_enabled: bool) -> ToolRegistry {
|
||||
@@ -175,6 +184,20 @@ impl Kernel {
|
||||
tools.register(Box::new(task_tool));
|
||||
}
|
||||
|
||||
// Register Hand tools — expose registered Hands as LLM-callable tools
|
||||
// (e.g., hand_quiz, hand_researcher, hand_browser, etc.)
|
||||
for config in &self.hand_configs {
|
||||
if !config.enabled {
|
||||
continue;
|
||||
}
|
||||
let tool = zclaw_runtime::tool::hand_tool::HandTool::from_config(
|
||||
&config.id,
|
||||
&config.description,
|
||||
config.input_schema.clone(),
|
||||
);
|
||||
tools.register(Box::new(tool));
|
||||
}
|
||||
|
||||
// Register MCP tools (dynamically updated by Tauri MCP manager)
|
||||
if let Ok(adapters) = self.mcp_adapters.read() {
|
||||
for adapter in adapters.iter() {
|
||||
@@ -249,11 +272,18 @@ impl Kernel {
|
||||
chain.register(Arc::new(mw));
|
||||
}
|
||||
|
||||
// Growth integration — shared VikingAdapter for memory middleware & compaction
|
||||
let mut growth = zclaw_runtime::GrowthIntegration::new(self.viking.clone());
|
||||
if let Some(ref driver) = self.extraction_driver {
|
||||
growth = growth.with_llm_driver(driver.clone());
|
||||
}
|
||||
// Growth integration — cached to avoid recreating empty scorer per request
|
||||
let growth = {
|
||||
let mut cached = self.growth.lock().expect("growth lock");
|
||||
if cached.is_none() {
|
||||
let mut g = zclaw_runtime::GrowthIntegration::new(self.viking.clone());
|
||||
if let Some(ref driver) = self.extraction_driver {
|
||||
g = g.with_llm_driver(driver.clone());
|
||||
}
|
||||
*cached = Some(std::sync::Arc::new(g));
|
||||
}
|
||||
cached.as_ref().expect("growth present").clone()
|
||||
};
|
||||
|
||||
// Evolution middleware — pushes evolution candidate skills into system prompt
|
||||
// priority=78, executed first by chain (before ButlerRouter@80)
|
||||
@@ -282,7 +312,7 @@ impl Kernel {
|
||||
// Memory middleware — auto-extract memories + check evolution after conversations
|
||||
{
|
||||
use std::sync::Arc;
|
||||
let mw = zclaw_runtime::middleware::memory::MemoryMiddleware::new(growth)
|
||||
let mw = zclaw_runtime::middleware::memory::MemoryMiddleware::new(growth.clone())
|
||||
.with_evolution(evolution_mw);
|
||||
chain.register(Arc::new(mw));
|
||||
}
|
||||
@@ -415,6 +445,10 @@ impl Kernel {
|
||||
pub fn set_viking(&mut self, viking: Arc<zclaw_runtime::VikingAdapter>) {
|
||||
tracing::info!("[Kernel] Replacing in-memory VikingAdapter with persistent storage");
|
||||
self.viking = viking;
|
||||
// Invalidate cached GrowthIntegration so next request builds with new storage
|
||||
if let Ok(mut g) = self.growth.lock() {
|
||||
*g = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a reference to the shared VikingAdapter
|
||||
@@ -429,6 +463,10 @@ impl Kernel {
|
||||
pub fn set_extraction_driver(&mut self, driver: Arc<dyn zclaw_runtime::LlmDriverForExtraction>) {
|
||||
tracing::info!("[Kernel] Extraction driver configured for Growth system");
|
||||
self.extraction_driver = Some(driver);
|
||||
// Invalidate cached GrowthIntegration so next request uses new driver
|
||||
if let Ok(mut g) = self.growth.lock() {
|
||||
*g = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a reference to the shared MCP adapters list.
|
||||
|
||||
@@ -19,7 +19,7 @@ use crate::middleware::evolution::EvolutionMiddleware;
|
||||
/// - `before_completion` → `enhance_prompt()` for memory injection
|
||||
/// - `after_completion` → `extract_combined()` for memory extraction + evolution check
|
||||
pub struct MemoryMiddleware {
|
||||
growth: GrowthIntegration,
|
||||
growth: std::sync::Arc<GrowthIntegration>,
|
||||
/// Shared EvolutionMiddleware for pushing evolution suggestions
|
||||
evolution_mw: Option<std::sync::Arc<EvolutionMiddleware>>,
|
||||
/// Minimum seconds between extractions for the same agent (debounce).
|
||||
@@ -29,7 +29,7 @@ pub struct MemoryMiddleware {
|
||||
}
|
||||
|
||||
impl MemoryMiddleware {
|
||||
pub fn new(growth: GrowthIntegration) -> Self {
|
||||
pub fn new(growth: std::sync::Arc<GrowthIntegration>) -> Self {
|
||||
Self {
|
||||
growth,
|
||||
evolution_mw: None,
|
||||
|
||||
@@ -4,12 +4,16 @@
|
||||
//! Inspired by DeerFlow's ToolErrorMiddleware: instead of propagating raw errors
|
||||
//! that crash the agent loop, this middleware wraps tool errors into a structured
|
||||
//! format that the LLM can use to self-correct.
|
||||
//!
|
||||
//! Also tracks consecutive tool failures across different tools — if N consecutive
|
||||
//! tool calls all fail, the loop is aborted to prevent infinite retry cycles.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use zclaw_types::Result;
|
||||
use crate::driver::ContentBlock;
|
||||
use crate::middleware::{AgentMiddleware, MiddlewareContext, ToolCallDecision};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Middleware that intercepts tool call errors and formats recovery messages.
|
||||
///
|
||||
@@ -17,12 +21,18 @@ use crate::middleware::{AgentMiddleware, MiddlewareContext, ToolCallDecision};
|
||||
pub struct ToolErrorMiddleware {
|
||||
/// Maximum error message length before truncation.
|
||||
max_error_length: usize,
|
||||
/// Maximum consecutive failures before aborting the loop.
|
||||
max_consecutive_failures: u32,
|
||||
/// Tracks consecutive tool failures.
|
||||
consecutive_failures: Mutex<u32>,
|
||||
}
|
||||
|
||||
impl ToolErrorMiddleware {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
max_error_length: 500,
|
||||
max_consecutive_failures: 3,
|
||||
consecutive_failures: Mutex::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +71,6 @@ impl AgentMiddleware for ToolErrorMiddleware {
|
||||
tool_input: &Value,
|
||||
) -> Result<ToolCallDecision> {
|
||||
// Pre-validate tool input structure for common issues.
|
||||
// This catches malformed JSON inputs before they reach the tool executor.
|
||||
if tool_input.is_null() {
|
||||
tracing::warn!(
|
||||
"[ToolErrorMiddleware] Tool '{}' received null input — replacing with empty object",
|
||||
@@ -69,6 +78,19 @@ impl AgentMiddleware for ToolErrorMiddleware {
|
||||
);
|
||||
return Ok(ToolCallDecision::ReplaceInput(serde_json::json!({})));
|
||||
}
|
||||
|
||||
// Check consecutive failure count — abort if too many failures
|
||||
let failures = self.consecutive_failures.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if *failures >= self.max_consecutive_failures {
|
||||
tracing::warn!(
|
||||
"[ToolErrorMiddleware] Aborting loop: {} consecutive tool failures",
|
||||
*failures
|
||||
);
|
||||
return Ok(ToolCallDecision::AbortLoop(
|
||||
format!("连续 {} 次工具调用失败,已自动终止以避免无限重试", *failures)
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ToolCallDecision::Allow)
|
||||
}
|
||||
|
||||
@@ -78,14 +100,16 @@ impl AgentMiddleware for ToolErrorMiddleware {
|
||||
tool_name: &str,
|
||||
result: &Value,
|
||||
) -> Result<()> {
|
||||
let mut failures = self.consecutive_failures.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
// Check if the tool result indicates an error.
|
||||
if let Some(error) = result.get("error") {
|
||||
*failures += 1;
|
||||
let error_msg = match error {
|
||||
Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
let truncated = if error_msg.len() > self.max_error_length {
|
||||
// Use char-boundary-safe truncation to avoid panic on UTF-8 strings (e.g. Chinese)
|
||||
let end = error_msg.floor_char_boundary(self.max_error_length);
|
||||
format!("{}...(truncated)", &error_msg[..end])
|
||||
} else {
|
||||
@@ -93,19 +117,19 @@ impl AgentMiddleware for ToolErrorMiddleware {
|
||||
};
|
||||
|
||||
tracing::warn!(
|
||||
"[ToolErrorMiddleware] Tool '{}' failed: {}",
|
||||
tool_name, truncated
|
||||
"[ToolErrorMiddleware] Tool '{}' failed ({}/{} consecutive): {}",
|
||||
tool_name, *failures, self.max_consecutive_failures, truncated
|
||||
);
|
||||
|
||||
// Build a guided recovery message so the LLM can self-correct.
|
||||
let guided_message = self.format_tool_error(tool_name, &truncated);
|
||||
|
||||
// Inject into response_content so the agent loop feeds this back
|
||||
// to the LLM alongside the raw tool result.
|
||||
ctx.response_content.push(ContentBlock::Text {
|
||||
text: guided_message,
|
||||
});
|
||||
} else {
|
||||
// Success — reset consecutive failure counter
|
||||
*failures = 0;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,15 @@ static RE_ONE_SHOT: LazyLock<Regex> = LazyLock::new(|| {
|
||||
)).expect("static regex pattern is valid")
|
||||
});
|
||||
|
||||
/// Matches same-day one-shot triggers: "下午3点半提醒我..." or "上午10点提醒我..."
|
||||
/// Pattern: period + time + "提醒我" (no date prefix — implied today)
|
||||
static RE_ONE_SHOT_TODAY: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"^{}(\d{{1,2}})[点时::](?:(\d{{1,2}})|(半))?.*提醒我",
|
||||
PERIOD
|
||||
)).expect("static regex pattern is valid")
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper lookups (pure functions, no allocation)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -395,38 +404,70 @@ fn try_monthly(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<Sched
|
||||
}
|
||||
|
||||
fn try_one_shot(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<ScheduleParseResult> {
|
||||
let caps = RE_ONE_SHOT.captures(input)?;
|
||||
let day_offset = match caps.get(1)?.as_str() {
|
||||
"明天" => 1,
|
||||
"后天" => 2,
|
||||
"大后天" => 3,
|
||||
_ => return None,
|
||||
};
|
||||
let period = caps.get(2).map(|m| m.as_str());
|
||||
let raw_hour: u32 = caps.get(3)?.as_str().parse().ok()?;
|
||||
let minute: u32 = extract_minute(&caps, 4, 5);
|
||||
let hour = adjust_hour_for_period(raw_hour, period);
|
||||
if hour > 23 || minute > 59 {
|
||||
return None;
|
||||
// First try explicit date prefix: 明天/后天/大后天 + time
|
||||
if let Some(caps) = RE_ONE_SHOT.captures(input) {
|
||||
let day_offset = match caps.get(1)?.as_str() {
|
||||
"明天" => 1,
|
||||
"后天" => 2,
|
||||
"大后天" => 3,
|
||||
_ => return None,
|
||||
};
|
||||
let period = caps.get(2).map(|m| m.as_str());
|
||||
let raw_hour: u32 = caps.get(3)?.as_str().parse().ok()?;
|
||||
let minute: u32 = extract_minute(&caps, 4, 5);
|
||||
let hour = adjust_hour_for_period(raw_hour, period);
|
||||
if hour > 23 || minute > 59 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let target = chrono::Utc::now()
|
||||
.checked_add_signed(chrono::Duration::days(day_offset))
|
||||
.unwrap_or_else(chrono::Utc::now)
|
||||
.with_hour(hour)
|
||||
.unwrap_or_else(|| chrono::Utc::now())
|
||||
.with_minute(minute)
|
||||
.unwrap_or_else(|| chrono::Utc::now())
|
||||
.with_second(0)
|
||||
.unwrap_or_else(|| chrono::Utc::now());
|
||||
|
||||
return Some(ScheduleParseResult::Exact(ParsedSchedule {
|
||||
cron_expression: target.to_rfc3339(),
|
||||
natural_description: format!("{} {:02}:{:02}", caps.get(1)?.as_str(), hour, minute),
|
||||
confidence: 0.88,
|
||||
task_description: task_desc.to_string(),
|
||||
task_target: TaskTarget::Agent(agent_id.to_string()),
|
||||
}));
|
||||
}
|
||||
|
||||
let target = chrono::Utc::now()
|
||||
.checked_add_signed(chrono::Duration::days(day_offset))
|
||||
.unwrap_or_else(chrono::Utc::now)
|
||||
.with_hour(hour)
|
||||
.unwrap_or_else(|| chrono::Utc::now())
|
||||
.with_minute(minute)
|
||||
.unwrap_or_else(|| chrono::Utc::now())
|
||||
.with_second(0)
|
||||
.unwrap_or_else(|| chrono::Utc::now());
|
||||
// Then try same-day implicit: "下午3点半提醒我..." (no date prefix)
|
||||
if let Some(caps) = RE_ONE_SHOT_TODAY.captures(input) {
|
||||
let period = caps.get(1).map(|m| m.as_str());
|
||||
let raw_hour: u32 = caps.get(2)?.as_str().parse().ok()?;
|
||||
let minute: u32 = extract_minute(&caps, 3, 4);
|
||||
let hour = adjust_hour_for_period(raw_hour, period);
|
||||
if hour > 23 || minute > 59 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ScheduleParseResult::Exact(ParsedSchedule {
|
||||
cron_expression: target.to_rfc3339(),
|
||||
natural_description: format!("{} {:02}:{:02}", caps.get(1)?.as_str(), hour, minute),
|
||||
confidence: 0.88,
|
||||
task_description: task_desc.to_string(),
|
||||
task_target: TaskTarget::Agent(agent_id.to_string()),
|
||||
}))
|
||||
let target = chrono::Utc::now()
|
||||
.with_hour(hour)
|
||||
.unwrap_or_else(|| chrono::Utc::now())
|
||||
.with_minute(minute)
|
||||
.unwrap_or_else(|| chrono::Utc::now())
|
||||
.with_second(0)
|
||||
.unwrap_or_else(|| chrono::Utc::now());
|
||||
|
||||
let period_desc = period.unwrap_or("");
|
||||
return Some(ScheduleParseResult::Exact(ParsedSchedule {
|
||||
cron_expression: target.to_rfc3339(),
|
||||
natural_description: format!("今天{} {:02}:{:02}", period_desc, hour, minute),
|
||||
confidence: 0.82,
|
||||
task_description: task_desc.to_string(),
|
||||
task_target: TaskTarget::Agent(agent_id.to_string()),
|
||||
}));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -191,3 +191,4 @@ impl Default for ToolRegistry {
|
||||
|
||||
// Built-in tools module
|
||||
pub mod builtin;
|
||||
pub mod hand_tool;
|
||||
|
||||
149
crates/zclaw-runtime/src/tool/hand_tool.rs
Normal file
149
crates/zclaw-runtime/src/tool/hand_tool.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
//! Hand Tool Wrapper
|
||||
//!
|
||||
//! Bridges the Hand trait (zclaw-hands) to the Tool trait (zclaw-runtime),
|
||||
//! allowing Hands to be registered in the ToolRegistry and callable by the LLM.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
use zclaw_types::Result;
|
||||
|
||||
use crate::tool::{Tool, ToolContext};
|
||||
|
||||
/// Wrapper that exposes a Hand as a Tool in the agent's tool registry.
|
||||
///
|
||||
/// When the LLM calls `hand_quiz`, `hand_researcher`, etc., the call is
|
||||
/// routed through this wrapper to the actual Hand implementation.
|
||||
pub struct HandTool {
|
||||
/// Hand identifier (e.g., "hand_quiz", "hand_researcher")
|
||||
name: String,
|
||||
/// Human-readable description
|
||||
description: String,
|
||||
/// Input JSON schema
|
||||
input_schema: Value,
|
||||
/// Hand ID for registry lookup
|
||||
hand_id: String,
|
||||
}
|
||||
|
||||
impl HandTool {
|
||||
/// Create a new HandTool wrapper from hand metadata.
|
||||
pub fn new(
|
||||
tool_name: &str,
|
||||
description: &str,
|
||||
input_schema: Value,
|
||||
hand_id: &str,
|
||||
) -> Self {
|
||||
Self {
|
||||
name: tool_name.to_string(),
|
||||
description: description.to_string(),
|
||||
input_schema,
|
||||
hand_id: hand_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a HandTool from HandConfig fields.
|
||||
pub fn from_config(hand_id: &str, description: &str, input_schema: Option<Value>) -> Self {
|
||||
let tool_name = format!("hand_{}", hand_id);
|
||||
let schema = input_schema.unwrap_or_else(|| {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "string",
|
||||
"description": format!("Input for the {} hand", hand_id)
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
})
|
||||
});
|
||||
Self::new(&tool_name, description, schema, hand_id)
|
||||
}
|
||||
|
||||
/// Get the hand ID for registry lookup
|
||||
pub fn hand_id(&self) -> &str {
|
||||
&self.hand_id
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for HandTool {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
&self.description
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
self.input_schema.clone()
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<Value> {
|
||||
// Hand execution is delegated to HandRegistry via the kernel's
|
||||
// hand execution path. This tool acts as the LLM-facing interface.
|
||||
// The actual execution is handled by the HandRegistry when the
|
||||
// kernel processes the tool call.
|
||||
|
||||
// For now, return a structured result that indicates the hand was invoked.
|
||||
// The kernel's hand execution layer will handle the actual execution
|
||||
// and emit HandStart/HandEnd events.
|
||||
Ok(json!({
|
||||
"hand_id": self.hand_id,
|
||||
"status": "invoked",
|
||||
"input": input,
|
||||
"message": format!("Hand '{}' invoked successfully", self.hand_id)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hand_tool_creation() {
|
||||
let tool = HandTool::from_config(
|
||||
"quiz",
|
||||
"Generate quizzes on various topics",
|
||||
None,
|
||||
);
|
||||
assert_eq!(tool.name(), "hand_quiz");
|
||||
assert_eq!(tool.hand_id(), "quiz");
|
||||
assert!(tool.description().contains("quiz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hand_tool_custom_schema() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"topic": { "type": "string" },
|
||||
"difficulty": { "type": "string" }
|
||||
}
|
||||
});
|
||||
let tool = HandTool::from_config(
|
||||
"quiz",
|
||||
"Generate quizzes",
|
||||
Some(schema.clone()),
|
||||
);
|
||||
assert_eq!(tool.input_schema(), schema);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hand_tool_execute() {
|
||||
let tool = HandTool::from_config("quiz", "Generate quizzes", None);
|
||||
let ctx = ToolContext {
|
||||
agent_id: zclaw_types::AgentId::new(),
|
||||
working_directory: None,
|
||||
session_id: None,
|
||||
skill_executor: None,
|
||||
path_validator: None,
|
||||
event_sender: None,
|
||||
};
|
||||
let result = tool.execute(json!({"topic": "Python"}), &ctx).await;
|
||||
assert!(result.is_ok());
|
||||
let val = result.unwrap();
|
||||
assert_eq!(val["hand_id"], "quiz");
|
||||
assert_eq!(val["status"], "invoked");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user