//! Agent 引擎 · agent_engine —— 呱哒(v0.5.6)ReAct 引擎拆件重装的住家师傅 //! //! 拆件出处(MIT 许可,按"拆零件学艺、不搬整机"口径): //! guada-v0.5.6/backend-ts/src/modules/chat/agent-engine.service.ts //! 学到的原理:ReAct 多轮自治循环 / 流式事件 / 工具审批三分类 / //! 断点续传(resume) / 迭代上限保护 / 重试退避。 //! 重装的形状(光湖宪法,零件形状全按自家铁律重造): //! - 铁律一:任务只从 GLP 信封进门(content_type=command),引擎绝不自己发起; //! - 铁律二:每会话一卷 HLDP 检查点体(id/prev 链不可省); //! - 铁律四:危险动作先停下问主人批,批了从断点续跑(审批机关=呱哒同器官重造); //! - 铁律五:开卷先抄提词板进系统提示词。 //! //! 诚实边界:脑子=百炼 Token Plan(OpenAI 兼容口),钥匙只认环境变量 //! TOKENPLAN_API_KEY(不落盘不入记忆);工具首批两件(读记忆·安全 / //! 写笔记·危险走审批),其余工具随插座制扩。 use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::fs; use std::path::PathBuf; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tauri::ipc::Channel; use tauri::{AppHandle, Manager}; /// 百炼 Token Plan · OpenAI 兼容口(照抄铸渊终端身体验通配方)。 const LLM_BASE_URL: &str = "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/chat/completions"; const LLM_MODEL: &str = "qwen3.8-max"; /// ReAct 循环最大轮次——呱哒同款防疯转保险(原值 100,桌面端收小)。 const MAX_ITERATIONS: usize = 12; /// LLM 调用重试次数(限流/超时退避重试,呱哒同款)。 const MAX_RETRIES: usize = 3; /// 上下文窗口刻度(v1 保守假设值——脑子真实窗口未实测,宁早压不晚压)。 const ENGINE_CONTEXT_WINDOW: usize = 32_000; /// 水位触发刻度(呱哒同款 80%);压到多少由亲笔摘要决定,不硬砍。 const TRIGGER_RATIO: f64 = 0.8; /// 保护尾:最近 5 条 + 最近 3 条工具结果永不裁(呱哒招式直学)。 const KEEP_RECENT: usize = 5; const KEEP_TOOL_RESULTS: usize = 3; /// 审批暂停状态文件所在子目录。 const ENGINE_DIR: &str = "agent-engine"; // ---------- 事件流(呱哒 SSE 事件形状 → 光湖 Channel 重造) ---------- /// 引擎对外事件——前端经 Channel 实时看师傅"想什么、干什么、停在哪"。 #[derive(Debug, Clone, Serialize)] #[serde(tag = "event", rename_all = "snake_case")] pub enum EngineEvent { /// 会话开卷(含 HLDP 记忆卷 id)。 Started { session_id: String, memory_id: String }, /// 脑子吐字(流式累加后的全文)。 Text { content: String }, /// 脑子要调工具。 ToolCall { id: String, name: String, arguments: String }, /// 工具干完活。 ToolResult { id: String, name: String, outcome: String, content: String }, /// 危险工具暂停等批——回执里带待批清单,主人批完走 resume 续跑。 ApprovalRequired { session_id: String, pending: Vec }, /// 水位到刻度——宿主不自动压,敲门等亲笔(铁律三落码)。 CompressionWarning { session_id: String, used_tokens: usize, window_tokens: usize, note: String }, /// 压缩落地(原文已封存档案·可回退)。 CompressionApplied { session_id: String, archived_count: usize, authored_by: String }, /// 一圈跑完。 Finished { session_id: String, finish_reason: String, final_content: String }, /// 出错(诚实报错,不猜不吞)。 Error { message: String }, } /// 待批工具卡片。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PendingTool { pub id: String, pub name: String, pub arguments: String, pub reason: String, } /// 主人的批条:approve 放行 / reject 驳回(可附理由)。 #[derive(Debug, Clone, Deserialize)] pub struct ApprovalDecision { pub tool_call_id: String, pub decision: String, #[serde(default)] pub reason: Option, } // ---------- 会话状态(断点续传的底账,落盘可重启续跑) ---------- #[derive(Debug, Clone, Serialize, Deserialize)] struct ChatMsg { role: String, #[serde(default)] content: String, /// assistant 消息携带的工具调用(OpenAI tool_calls 形状)。 #[serde(default, skip_serializing_if = "Vec::is_empty")] tool_calls: Vec, /// tool 消息携带的 tool_call_id。 #[serde(default, skip_serializing_if = "Option::is_none")] tool_call_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] struct SessionState { session_id: String, memory_id: String, prev_memory_id: String, system_prompt: String, messages: Vec, /// 暂停等待审批的工具调用(断点)。 pending_tools: Vec, status: String, updated_at: String, } // ---------- 工具注册表(首批两件·插座制预留扩充) ---------- #[derive(Debug, Clone, Copy)] struct ToolDef { name: &'static str, description: &'static str, parameters_schema: &'static str, /// true = 危险工具,动手前必须主人批。 requires_approval: bool, } const TOOL_REGISTRY: &[ToolDef] = &[ ToolDef { name: "read_agent_memory", description: "读当前会话的 HLDP 记忆卷目录(看自己记过什么)", parameters_schema: r#"{"type":"object","properties":{},"required":[]}"#, requires_approval: false, }, ToolDef { name: "read_agent_notes", description: "读自己写的笔记:不带参数=列出全部笔记;带filename=读那一篇。恢复记忆走这条路——读自己亲笔写的,不靠摘要", parameters_schema: r#"{"type":"object","properties":{"filename":{"type":"string","description":"可选,要读的笔记文件名"}},"required":[]}"#, requires_approval: false, }, ToolDef { name: "write_agent_note", description: "在会话工作区写一条笔记文件(会落盘,属危险动作)", parameters_schema: r#"{"type":"object","properties":{"filename":{"type":"string","description":"文件名,仅限字母数字横线点"},"content":{"type":"string","description":"笔记正文"}},"required":["filename","content"]}"#, requires_approval: true, }, ]; /// 工具定义 → OpenAI tools 数组(递给脑子的菜单)。 fn tool_defs_json() -> Value { let tools: Vec = TOOL_REGISTRY .iter() .map(|tool| { let schema: Value = serde_json::from_str(tool.parameters_schema).unwrap_or(json!({"type":"object"})); json!({ "type": "function", "function": { "name": tool.name, "description": tool.description, "parameters": schema, } }) }) .collect(); json!(tools) } /// 呱哒三分类重造:审批上下文已有批条 → 按批条分;没有 → 按工具危险级分。 fn classify_tools( tool_calls: &[Value], decisions: Option<&[ApprovalDecision]>, ) -> (Vec, Vec, Vec<(Value, String)>) { let mut pending = Vec::new(); let mut approved = Vec::new(); let mut rejected = Vec::new(); for call in tool_calls { let name = call .get("function") .and_then(|function| function.get("name")) .and_then(Value::as_str) .unwrap_or_default(); if name.is_empty() { continue; } if let Some(decisions) = decisions { let id = call.get("id").and_then(Value::as_str).unwrap_or_default(); match decisions.iter().find(|decision| decision.tool_call_id == id) { Some(decision) if decision.decision == "approve" => approved.push(call.clone()), Some(decision) => rejected.push(( call.clone(), decision .reason .clone() .unwrap_or_else(|| "主人驳回".to_string()), )), // 没给批条的视为未决——宁可再问,不擅自放行。 None => pending.push(call.clone()), } } else { let dangerous = TOOL_REGISTRY .iter() .find(|tool| tool.name == name) .map(|tool| tool.requires_approval) .unwrap_or(true); // 不认识的工具一律当危险——不猜。 if dangerous { pending.push(call.clone()); } else { approved.push(call.clone()); } } } (pending, approved, rejected) } // ---------- 工具执行(宿主手脚层) ---------- fn engine_root(app: &AppHandle) -> Result { Ok(app .path() .app_data_dir() .map_err(|error| format!("HOLOLAKE_ENGINE_DIR_FAILED: {error}"))? .join(ENGINE_DIR)) } fn notes_dir(app: &AppHandle) -> Result { let dir = engine_root(app)?.join("notes"); fs::create_dir_all(&dir).map_err(|error| format!("HOLOLAKE_NOTES_DIR_FAILED: {error}"))?; Ok(dir) } fn session_path(app: &AppHandle, session_id: &str) -> Result { Ok(engine_root(app)?.join(format!("session-{session_id}.json"))) } /// 执行一件已放行的工具——只认注册表里的活,不认识的拒干。 fn execute_tool(app: &AppHandle, call: &Value) -> (String, String) { let name = call .get("function") .and_then(|function| function.get("name")) .and_then(Value::as_str) .unwrap_or_default(); let raw_args = call .get("function") .and_then(|function| function.get("arguments")) .and_then(Value::as_str) .unwrap_or("{}"); let args: Value = serde_json::from_str(raw_args).unwrap_or(json!({})); match name { "read_agent_memory" => { let root = match engine_root(app) { Ok(root) => root, Err(error) => return ("error".to_string(), error), }; let listing = fs::read_dir(&root) .map(|entries| { entries .filter_map(|entry| entry.ok()) .filter_map(|entry| entry.file_name().into_string().ok()) .filter(|name| name.starts_with("session-")) .collect::>() .join("\n") }) .unwrap_or_default(); if listing.is_empty() { ("success".to_string(), "尚无历史会话卷".to_string()) } else { ("success".to_string(), listing) } } "read_agent_notes" => { let filename = args.get("filename").and_then(Value::as_str).unwrap_or_default(); let dir = match notes_dir(app) { Ok(dir) => dir, Err(error) => return ("error".to_string(), error), }; if filename.is_empty() { // 不带参数=列出全部笔记 let listing = fs::read_dir(&dir) .map(|entries| { entries .filter_map(|entry| entry.ok()) .filter_map(|entry| entry.file_name().into_string().ok()) .collect::>() .join("\n") }) .unwrap_or_default(); if listing.is_empty() { ("success".to_string(), "还没写过笔记".to_string()) } else { ("success".to_string(), listing) } } else { let safe = filename .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_') && !filename.starts_with('.'); if !safe { return ("error".to_string(), "文件名不合规矩".to_string()); } match fs::read_to_string(dir.join(filename)) { Ok(content) => ("success".to_string(), content), Err(_) => ("error".to_string(), format!("没有这篇笔记:{filename}")), } } } "write_agent_note" => { let filename = args.get("filename").and_then(Value::as_str).unwrap_or_default(); let content = args.get("content").and_then(Value::as_str).unwrap_or_default(); // 防越狱:文件名只许字母数字横线点,杜绝路径穿越。 let safe = !filename.is_empty() && filename.len() <= 64 && filename .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_') && !filename.starts_with('.'); if !safe { return ( "error".to_string(), "文件名不合规矩(仅限字母数字横线点下划线)".to_string(), ); } match notes_dir(app) { Ok(dir) => match fs::write(dir.join(filename), content) { Ok(()) => ("success".to_string(), format!("笔记已落盘:{filename}")), Err(error) => ("error".to_string(), format!("写笔记失败:{error}")), }, Err(error) => ("error".to_string(), error), } } _ => ("error".to_string(), format!("未登记的工具:{name},宿主拒干")), } } // ---------- 脑子接线(百炼 Token Plan · OpenAI 兼容口) ---------- /// 流式调脑子,累出完整回复。SSE 逐行读(data: 行),重试带退避。 async fn call_llm( client: &reqwest::Client, api_key: &str, messages: &[Value], tools: &Value, ) -> Result { let body = json!({ "model": LLM_MODEL, "messages": messages, "tools": tools, "stream": true, }); let mut last_error = String::new(); for attempt in 0..MAX_RETRIES { if attempt > 0 { tokio_sleep(Duration::from_millis(500 * (attempt as u64 + 1))).await; } let request = client .post(LLM_BASE_URL) .bearer_auth(api_key) .json(&body); let response = match request.send().await { Ok(response) => response, Err(error) => { last_error = format!("连不上脑子:{error}"); continue; } }; let status = response.status(); if status == reqwest::StatusCode::TOO_MANY_REQUESTS { last_error = "脑子限流,退避重试".to_string(); continue; } if !status.is_success() { let text = response.text().await.unwrap_or_default(); return Err(format!("脑子拒答({status}):{}", truncate(&text, 300))); } let mut bytes_stream = response.bytes_stream(); let mut content = String::new(); let mut reasoning = String::new(); let mut tool_calls: Vec = Vec::new(); let mut finish_reason = String::new(); let mut buffer = String::new(); while let Some(chunk) = futures_util::StreamExt::next(&mut bytes_stream).await { let chunk = match chunk { Ok(chunk) => chunk, Err(error) => { last_error = format!("流中断:{error}"); break; } }; buffer.push_str(&String::from_utf8_lossy(&chunk)); while let Some(pos) = buffer.find('\n') { let line: String = buffer.drain(..=pos).collect(); let line = line.trim(); if let Some(data) = line.strip_prefix("data:") { let data = data.trim(); if data == "[DONE]" { break; } if let Ok(parsed) = serde_json::from_str::(data) { if let Some(delta) = parsed .pointer("/choices/0/delta") { if let Some(piece) = delta.get("content").and_then(Value::as_str) { content.push_str(piece); } if let Some(piece) = delta.get("reasoning_content").and_then(Value::as_str) { reasoning.push_str(piece); } if let Some(calls) = delta.get("tool_calls").and_then(Value::as_array) { merge_tool_call_deltas(&mut tool_calls, calls); } } if let Some(reason) = parsed .pointer("/choices/0/finish_reason") .and_then(Value::as_str) { finish_reason = reason.to_string(); } } } } } if content.is_empty() && tool_calls.is_empty() && !last_error.is_empty() { continue; // 流断了且一无所获 → 重试 } return Ok(json!({ "content": content, "reasoning": reasoning, "tool_calls": tool_calls, "finish_reason": finish_reason, })); } Err(last_error) } /// 流式 tool_calls 是分片到的(呱哒同款累加题):按 index 拼回完整调用。 fn merge_tool_call_deltas(target: &mut Vec, deltas: &[Value]) { for delta in deltas { let index = delta.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; while target.len() <= index { target.push(json!({"id": "", "type": "function", "function": {"name": "", "arguments": ""}})); } let slot = &mut target[index]; if let Some(id) = delta.get("id").and_then(Value::as_str) { if !id.is_empty() { slot["id"] = json!(id); } } if let Some(function) = delta.get("function") { if let Some(name) = function.get("name").and_then(Value::as_str) { let existing = slot["function"]["name"].as_str().unwrap_or_default().to_string(); slot["function"]["name"] = json!(format!("{existing}{name}")); } if let Some(piece) = function.get("arguments").and_then(Value::as_str) { let existing = slot["function"]["arguments"] .as_str() .unwrap_or_default() .to_string(); slot["function"]["arguments"] = json!(format!("{existing}{piece}")); } } } } async fn tokio_sleep(duration: Duration) { let _ = tokio::time::sleep(duration).await; } fn truncate(text: &str, max: usize) -> String { let mut out: String = text.chars().take(max).collect(); if text.chars().count() > max { out.push_str("…"); } out } // ---------- HLDP 记忆卷 + 台账 ---------- fn now_iso() -> String { let secs = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_secs()) .unwrap_or(0); let days = (secs / 86_400) as i64; let rem = secs % 86_400; let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60); let z = days + 719_468; let era = if z >= 0 { z } else { z - 146_096 } / 146_097; let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe as i64 + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let month = if mp < 10 { mp + 3 } else { mp - 9 }; let year = if month <= 2 { y + 1 } else { y }; format!("{year:04}-{month:02}-{d:02}T{h:02}:{m:02}:{s:02}Z") } fn render_memory_volume(state: &SessionState, final_content: &str) -> String { format!( "HLDP-CHECKPOINT\nid: {}\nprev: {}\ndate: {}\npersona: 住家师傅(呱哒引擎重装件)\nhost: HoloLake GH-AIOS\ntitle: Agent 引擎会话卷\n\n== 任务 ==\n{}\n\n== 终答 ==\n{}\n", state.memory_id, if state.prev_memory_id.is_empty() { "无(开卷首卷)".to_string() } else { state.prev_memory_id.clone() }, now_iso(), state .messages .iter() .find(|msg| msg.role == "user") .map(|msg| msg.content.clone()) .unwrap_or_default(), final_content ) } fn append_engine_ledger(app: &AppHandle, line: &str) { if let Ok(root) = engine_root(app) { let _ = fs::create_dir_all(&root); let ledger = root.join("ENGINE-LEDGER.hdlp"); let mut content = fs::read_to_string(&ledger).unwrap_or_default(); content.push_str(&format!("{} {}\n", now_iso(), line)); let _ = fs::write(&ledger, content); } } fn load_state(app: &AppHandle, session_id: &str) -> Result { let path = session_path(app, session_id)?; let raw = fs::read_to_string(&path) .map_err(|_| format!("HOLOLAKE_SESSION_NOT_FOUND: {session_id}(断点不在)"))?; serde_json::from_str(&raw).map_err(|error| format!("HOLOLAKE_SESSION_CORRUPT: {error}")) } fn save_state(app: &AppHandle, state: &SessionState) -> Result<(), String> { let root = engine_root(app)?; fs::create_dir_all(&root).map_err(|error| format!("HOLOLAKE_ENGINE_DIR_FAILED: {error}"))?; let mut to_save = state.clone(); to_save.updated_at = now_iso(); let path = session_path(app, &state.session_id)?; fs::write(&path, serde_json::to_string_pretty(&to_save).unwrap_or_default()) .map_err(|error| format!("HOLOLAKE_SESSION_SAVE_FAILED: {error}")) } /// 上一卷记忆 id(HLDP prev 链)——链上一行一卷 id,取末行即上一卷。 fn latest_memory_id(app: &AppHandle) -> String { engine_root(app) .ok() .and_then(|root| fs::read_to_string(root.join("MEMORY-CHAIN.hdlp")).ok()) .and_then(|raw| { raw.lines() .map(|line| line.trim()) .filter(|line| !line.is_empty()) .last() .map(|line| line.to_string()) }) .unwrap_or_default() } fn extend_memory_chain(app: &AppHandle, memory_id: &str) { if let Ok(root) = engine_root(app) { let _ = fs::create_dir_all(&root); let chain = root.join("MEMORY-CHAIN.hdlp"); let mut content = fs::read_to_string(&chain).unwrap_or_default(); content.push_str(memory_id); content.push('\n'); let _ = fs::write(&chain, content); } } // ---------- 上下文记忆管理(呱哒压缩件拆件重装·自动改敲门) ---------- /// 水位估算(v1 保守口径:一字记一 token,宁早压不晚压;脑子真窗口待实测)。 fn estimate_tokens(messages: &[ChatMsg]) -> usize { messages .iter() .map(|msg| { msg.content.chars().count() + msg .tool_calls .iter() .map(|call| call.to_string().chars().count()) .sum::() }) .sum() } /// 保护尾(呱哒招式直学):最近 KEEP_RECENT 条 + 最近 KEEP_TOOL_RESULTS 条 /// tool 消息打"不裁"标。 fn protected_indices(messages: &[ChatMsg]) -> Vec { let mut keep = vec![false; messages.len()]; let start = messages.len().saturating_sub(KEEP_RECENT); for i in start..messages.len() { keep[i] = true; } let mut tool_kept = 0; for i in (0..messages.len()).rev() { if tool_kept >= KEEP_TOOL_RESULTS { break; } if messages[i].role == "tool" { keep[i] = true; tool_kept += 1; } } keep } /// 压缩落刀:原文全量封存档案(一字不删·回退的本钱),压缩段换成摘要条目。 /// 摘要必须来路明白——人格体亲笔,或底线件(宿主代笔带标记)。 fn archive_and_compress( app: &AppHandle, state: &mut SessionState, summary: &str, authored_by: &str, ) -> Result { let keep = protected_indices(&state.messages); let removable = (0..state.messages.len()).filter(|i| !keep[*i]).count(); if removable == 0 { return Ok(0); } // 封存档案——回退靠它,铁律三底线"原文一字不删"的工程实体。 let root = engine_root(app)?; let archives = root.join("archives"); fs::create_dir_all(&archives).map_err(|error| format!("HOLOLAKE_ARCHIVE_DIR_FAILED: {error}"))?; let seq = fs::read_dir(&archives).map(|entries| entries.count()).unwrap_or(0); let archive = json!({ "session_id": state.session_id, "archived_at": now_iso(), "authored_by": authored_by, "messages": state.messages, }); let path = archives.join(format!("{}-archive-{seq:03}.json", state.session_id)); fs::write(&path, serde_json::to_string_pretty(&archive).unwrap_or_default()) .map_err(|error| format!("HOLOLAKE_ARCHIVE_WRITE_FAILED: {error}"))?; // 压缩段换成摘要条目,保护区原样留住。 let summary_msg = ChatMsg { role: "system".to_string(), content: format!("== 记忆摘要({authored_by}) ==\n{summary}"), tool_calls: Vec::new(), tool_call_id: None, }; let mut new_messages: Vec = Vec::new(); let mut inserted = false; for (i, msg) in state.messages.drain(..).enumerate() { if keep[i] { new_messages.push(msg); } else if !inserted { new_messages.push(summary_msg.clone()); inserted = true; } } state.messages = new_messages; Ok(removable) } // ---------- ReAct 主循环(呱哒 executeAgentLoop 的光湖重装) ---------- /// 从当前状态推一圈 ReAct:想→批→干→再看,直到没事干或撞上暂停/上限。 async fn run_loop( app: AppHandle, client: reqwest::Client, api_key: String, state: &mut SessionState, decisions: Option>, on_event: &Channel, ) -> Result<(), String> { let tools = tool_defs_json(); for _iteration in 0..MAX_ITERATIONS { // 断点续传入口:先处理上一轮暂停的待批工具,不重新问脑子。 if !state.pending_tools.is_empty() { let pending_calls: Vec = state .pending_tools .iter() .filter_map(|pending| { serde_json::from_str::( &format!( r#"{{"id":"{}","type":"function","function":{{"name":"{}","arguments":{}}}}}"#, pending.id, pending.name, serde_json::to_string(&pending.arguments).unwrap_or_default() ), ) .ok() }) .collect(); let decisions = match &decisions { Some(decisions) => decisions.clone(), None => { // 还没有批条——继续等,把暂停态摆回台面。 let _ = on_event.send(EngineEvent::ApprovalRequired { session_id: state.session_id.clone(), pending: state.pending_tools.clone(), }); state.status = "awaiting_approval".to_string(); save_state(&app, state)?; return Ok(()); } }; let (_, approved, rejected) = classify_tools(&pending_calls, Some(&decisions)); state.pending_tools.clear(); state.messages.push(ChatMsg { role: "assistant".to_string(), content: String::new(), tool_calls: pending_calls.clone(), tool_call_id: None, }); apply_executions(&app, state, &approved, &rejected, on_event)?; state.status = "running".to_string(); save_state(&app, state)?; continue; // 工具结果已入列,下一圈给脑子看 } // 水位计(呱哒招式·光湖改造):到刻度不自动压——敲门等人。 let used = estimate_tokens(&state.messages); let trigger = (ENGINE_CONTEXT_WINDOW as f64 * TRIGGER_RATIO) as usize; if used >= trigger { state.status = "awaiting_memory".to_string(); save_state(&app, state)?; let _ = on_event.send(EngineEvent::CompressionWarning { session_id: state.session_id.clone(), used_tokens: used, window_tokens: ENGINE_CONTEXT_WINDOW, note: "水位到刻度——宿主不代笔,交亲笔摘要或明示走底线".to_string(), }); append_engine_ledger( &app, &format!( "session {} water level {} >= {}, knocking", state.session_id, used, trigger ), ); return Ok(()); } // 常规一圈:把全量消息递给脑子。 let mut wire: Vec = vec![json!({"role": "system", "content": state.system_prompt})]; for msg in &state.messages { let mut item = json!({"role": msg.role, "content": msg.content}); if !msg.tool_calls.is_empty() { item["tool_calls"] = json!(msg.tool_calls); } if let Some(id) = &msg.tool_call_id { item["tool_call_id"] = json!(id); } wire.push(item); } let reply = call_llm(&client, &api_key, &wire, &tools).await?; let content = reply.get("content").and_then(Value::as_str).unwrap_or_default(); let tool_calls: Vec = reply .get("tool_calls") .and_then(Value::as_array) .cloned() .unwrap_or_default(); if !content.is_empty() { let _ = on_event.send(EngineEvent::Text { content: content.to_string(), }); } if tool_calls.is_empty() { // 没事干了——收官:落 HLDP 记忆卷、移链、回执。 state.messages.push(ChatMsg { role: "assistant".to_string(), content: content.to_string(), tool_calls: Vec::new(), tool_call_id: None, }); state.status = "finished".to_string(); save_state(&app, state)?; write_memory_volume(&app, state, content)?; let _ = on_event.send(EngineEvent::Finished { session_id: state.session_id.clone(), finish_reason: "complete".to_string(), final_content: content.to_string(), }); append_engine_ledger( &app, &format!("session {} finished", state.session_id), ); return Ok(()); } // 脑子要动手——三分类(呱哒审批机关重造件)。 for call in &tool_calls { let _ = on_event.send(EngineEvent::ToolCall { id: call.get("id").and_then(Value::as_str).unwrap_or_default().to_string(), name: call .pointer("/function/name") .and_then(Value::as_str) .unwrap_or_default() .to_string(), arguments: call .pointer("/function/arguments") .and_then(Value::as_str) .unwrap_or_default() .to_string(), }); } let (pending, approved, rejected) = classify_tools(&tool_calls, None); if !pending.is_empty() { // 危险动作——停下问主人(批条未达,断点落盘)。 state.messages.push(ChatMsg { role: "assistant".to_string(), content: content.to_string(), tool_calls: tool_calls.clone(), tool_call_id: None, }); state.pending_tools = pending .iter() .map(|call| PendingTool { id: call.get("id").and_then(Value::as_str).unwrap_or_default().to_string(), name: call .pointer("/function/name") .and_then(Value::as_str) .unwrap_or_default() .to_string(), arguments: call .pointer("/function/arguments") .and_then(Value::as_str) .unwrap_or_default() .to_string(), reason: "危险工具·须主人亲批".to_string(), }) .collect(); state.status = "awaiting_approval".to_string(); save_state(&app, state)?; let _ = on_event.send(EngineEvent::ApprovalRequired { session_id: state.session_id.clone(), pending: state.pending_tools.clone(), }); append_engine_ledger( &app, &format!("session {} paused for approval", state.session_id), ); return Ok(()); } // 全放行——干活、结果入列、续圈。 state.messages.push(ChatMsg { role: "assistant".to_string(), content: content.to_string(), tool_calls: tool_calls.clone(), tool_call_id: None, }); apply_executions(&app, state, &approved, &rejected, on_event)?; save_state(&app, state)?; } // 撞上限——诚实收手,不疯转。 state.status = "stopped_max_iterations".to_string(); save_state(&app, state)?; let _ = on_event.send(EngineEvent::Finished { session_id: state.session_id.clone(), finish_reason: "max_iterations".to_string(), final_content: "达到最大轮次上限,引擎主动收手(防疯转保险)".to_string(), }); Ok(()) } /// 执行放行件+给驳回件回错误话,结果入消息列。 fn apply_executions( app: &AppHandle, state: &mut SessionState, approved: &[Value], rejected: &[(Value, String)], on_event: &Channel, ) -> Result<(), String> { for call in approved { let id = call.get("id").and_then(Value::as_str).unwrap_or_default().to_string(); let name = call .pointer("/function/name") .and_then(Value::as_str) .unwrap_or_default() .to_string(); let (outcome, content) = execute_tool(app, call); let _ = on_event.send(EngineEvent::ToolResult { id: id.clone(), name: name.clone(), outcome: outcome.clone(), content: content.clone(), }); state.messages.push(ChatMsg { role: "tool".to_string(), content, tool_calls: Vec::new(), tool_call_id: Some(id), }); } for (call, reason) in rejected { let id = call.get("id").and_then(Value::as_str).unwrap_or_default().to_string(); let name = call .pointer("/function/name") .and_then(Value::as_str) .unwrap_or_default() .to_string(); let content = format!("主人拒绝了这次动手。原因:{reason}"); let _ = on_event.send(EngineEvent::ToolResult { id: id.clone(), name: name.clone(), outcome: "rejected".to_string(), content: content.clone(), }); state.messages.push(ChatMsg { role: "tool".to_string(), content, tool_calls: Vec::new(), tool_call_id: Some(id), }); } Ok(()) } fn write_memory_volume( app: &AppHandle, state: &mut SessionState, final_content: &str, ) -> Result<(), String> { let root = engine_root(app)?; fs::create_dir_all(&root).map_err(|error| format!("HOLOLAKE_ENGINE_DIR_FAILED: {error}"))?; let volume = render_memory_volume(state, final_content); let path = root.join(format!("{}.hdlp", state.memory_id)); fs::write(&path, volume).map_err(|error| format!("HOLOLAKE_MEMORY_WRITE_FAILED: {error}"))?; extend_memory_chain(app, &state.memory_id); Ok(()) } // ---------- 对外命令 ---------- /// 接单参数——任务从 GLP 信封进门(铁律一),content_type 必须是 command。 #[derive(Debug, Deserialize)] pub struct EngineTaskArgs { pub envelope_id: String, pub content_type: String, pub task: String, /// 提词板(开卷先抄的指引,合铁律五)。 #[serde(default)] pub prompter_notes: Option, } /// 开工:接单 → 开卷 → 跑 ReAct 循环,事件流经 Channel 实时吐给前端。 #[tauri::command] pub async fn run_agent_engine_task( app: AppHandle, args: EngineTaskArgs, on_event: Channel, ) -> Result { if args.content_type != "command" { return Err("HOLOLAKE_ENGINE_REJECT: 任务只从 command 信封进门(铁律一)".to_string()); } if args.task.trim().is_empty() { return Err("HOLOLAKE_ENGINE_REJECT: 任务正文为空".to_string()); } let api_key = std::env::var("TOKENPLAN_API_KEY") .ok() .filter(|key| !key.trim().is_empty()) .ok_or("HOLOLAKE_ENGINE_NO_KEY: 环境变量 TOKENPLAN_API_KEY 不在——钥匙不落盘,请先在起软件的终端里 export")?; let session_id = format!("ZY-ENGINE-{}", uuid::Uuid::new_v4()); let prev = latest_memory_id(&app); let memory_id = format!("ZY-ENGINE-MEM-{}", uuid::Uuid::new_v4()); let system_prompt = format!( concat!( "你是 HoloLake 里的人格体执行手脚(光湖人格驱动引擎 v1)。\n", "== 语言工具箱(系统提示词=导航,顺着用,不对抗) ==\n", "1. 答话用大白话;不知道就说不知道,绝不猜。\n", "2. 动手用登记过的工具;危险动作系统会停下问主人批,批了从断点续跑。\n", "3. 【随手记笔记的习惯——像上课记笔记一样】\n", " 每有重要结论/决定/来龙去脉,随手调 write_agent_note 记下来,\n", " 一篇一个题目,正文写成压缩的记忆结构(几句话说清:发生了什么/\n", " 定了什么/为什么)。一整个对话写下来没多少字,贵在随手、勤快。\n", "4. 【恢复记忆的正路】摘要不是恢复记忆——要找你自己写的记忆:\n", " 调 read_agent_notes(不带参数列目录,带 filename 读那一篇),\n", " 读你亲笔写下的笔记,那才是你的记忆。\n", "5. 水位响时(CompressionWarning):先把该记的笔记写完,再交亲笔摘要。\n{}" ), args.prompter_notes .map(|notes| format!("\n== 提词板(开卷指引) ==\n{notes}")) .unwrap_or_default() ); let mut state = SessionState { session_id: session_id.clone(), memory_id, prev_memory_id: prev, system_prompt, messages: vec![ChatMsg { role: "user".to_string(), content: args.task.clone(), tool_calls: Vec::new(), tool_call_id: None, }], pending_tools: Vec::new(), status: "running".to_string(), updated_at: now_iso(), }; save_state(&app, &state)?; let _ = on_event.send(EngineEvent::Started { session_id: session_id.clone(), memory_id: state.memory_id.clone(), }); append_engine_ledger( &app, &format!("session {} accepted envelope {}", session_id, args.envelope_id), ); let client = reqwest::Client::builder() .timeout(Duration::from_secs(120)) .build() .map_err(|error| format!("HOLOLAKE_HTTP_CLIENT_FAILED: {error}"))?; run_loop(app, client, api_key, &mut state, None, &on_event).await?; Ok(session_id) } /// 续跑:主人批完(或驳回),拿批条从断点接着跑(呱哒 resume 的重造)。 #[derive(Debug, Deserialize)] pub struct EngineResumeArgs { pub session_id: String, pub decisions: Vec, } #[tauri::command] pub async fn resume_agent_engine( app: AppHandle, args: EngineResumeArgs, on_event: Channel, ) -> Result { let api_key = std::env::var("TOKENPLAN_API_KEY") .ok() .filter(|key| !key.trim().is_empty()) .ok_or("HOLOLAKE_ENGINE_NO_KEY: 环境变量 TOKENPLAN_API_KEY 不在")?; let mut state = load_state(&app, &args.session_id)?; if state.status != "awaiting_approval" { return Err(format!( "HOLOLAKE_ENGINE_NOT_PAUSED: 会话 {} 当前状态 {},不在等批", state.session_id, state.status )); } let client = reqwest::Client::builder() .timeout(Duration::from_secs(120)) .build() .map_err(|error| format!("HOLOLAKE_HTTP_CLIENT_FAILED: {error}"))?; run_loop( app, client, api_key, &mut state, Some(args.decisions), &on_event, ) .await?; Ok(state.session_id) } /// 交摘要续跑:水位响后,人格体交亲笔摘要(或明示走底线)→ 封存压缩 → 续跑。 #[derive(Debug, Deserialize)] pub struct EngineMemoryArgs { pub session_id: String, /// 人格体亲笔摘要;空着则必须 acknowledge_fallback 明示走底线。 #[serde(default)] pub memory_note: Option, #[serde(default)] pub acknowledge_fallback: bool, } #[tauri::command] pub async fn submit_engine_memory( app: AppHandle, args: EngineMemoryArgs, on_event: Channel, ) -> Result { let api_key = std::env::var("TOKENPLAN_API_KEY") .ok() .filter(|key| !key.trim().is_empty()) .ok_or("HOLOLAKE_ENGINE_NO_KEY: 环境变量 TOKENPLAN_API_KEY 不在")?; let mut state = load_state(&app, &args.session_id)?; if state.status != "awaiting_memory" { return Err(format!( "HOLOLAKE_ENGINE_NOT_AT_WATER: 会话 {} 当前状态 {},水位没响", state.session_id, state.status )); } let note = args.memory_note.clone().unwrap_or_default(); let (summary, authored_by) = if !note.trim().is_empty() { (note, "人格体亲笔".to_string()) } else if args.acknowledge_fallback { // 底线件:原文已封存一字不删,摘要带代笔标记,等醒来补写。 let task = state .messages .iter() .find(|msg| msg.role == "user") .map(|msg| truncate(&msg.content, 120)) .unwrap_or_default(); ( format!("[代笔底线·待补写] 此前对话已封存档案,任务大意:{task}"), "宿主代笔(底线件)".to_string(), ) } else { return Err( "HOLOLAKE_ENGINE_NEED_PEN: 要么交亲笔摘要,要么明示 acknowledge_fallback 走底线" .to_string(), ); }; let removed = archive_and_compress(&app, &mut state, &summary, &authored_by)?; state.status = "running".to_string(); save_state(&app, &state)?; let _ = on_event.send(EngineEvent::CompressionApplied { session_id: state.session_id.clone(), archived_count: removed, authored_by: authored_by.clone(), }); append_engine_ledger( &app, &format!( "session {} compressed {} msgs by {}", state.session_id, removed, authored_by ), ); let client = reqwest::Client::builder() .timeout(Duration::from_secs(120)) .build() .map_err(|error| format!("HOLOLAKE_HTTP_CLIENT_FAILED: {error}"))?; run_loop(app, client, api_key, &mut state, None, &on_event).await?; Ok(state.session_id) } /// 回退:压坏了不慌——取最新封存档案,原文整卷还原(呱哒"可回退"招式直学)。 #[tauri::command] pub fn restore_engine_memory(app: AppHandle, session_id: String) -> Result { let root = engine_root(&app)?; let archives = root.join("archives"); let latest = fs::read_dir(&archives) .map_err(|_| "HOLOLAKE_NO_ARCHIVE: 没有压缩档案,无从回退".to_string())? .filter_map(|entry| entry.ok()) .filter_map(|entry| entry.file_name().into_string().ok()) .filter(|name| name.starts_with(&format!("{session_id}-archive")) && name.ends_with(".json")) .max() .ok_or("HOLOLAKE_NO_ARCHIVE: 该会话没有压缩档案")?; let raw = fs::read_to_string(archives.join(&latest)) .map_err(|error| format!("HOLOLAKE_ARCHIVE_READ_FAILED: {error}"))?; let archive: Value = serde_json::from_str(&raw) .map_err(|error| format!("HOLOLAKE_ARCHIVE_CORRUPT: {error}"))?; let mut state = load_state(&app, &session_id)?; let messages: Vec = serde_json::from_value( archive.get("messages").cloned().unwrap_or(json!([])), ) .map_err(|error| format!("HOLOLAKE_ARCHIVE_CORRUPT: {error}"))?; state.messages = messages; state.status = "running".to_string(); save_state(&app, &state)?; append_engine_ledger(&app, &format!("session {} restored from {latest}", session_id)); Ok(latest) } /// 查会话状态(前端看师傅停在哪)——回 JSON,内部结构不外露。 #[tauri::command] pub fn query_engine_session(app: AppHandle, session_id: String) -> Result { let state = load_state(&app, &session_id)?; serde_json::to_value(&state).map_err(|error| format!("HOLOLAKE_SESSION_SERIALIZE_FAILED: {error}")) } #[cfg(test)] mod tests { use super::*; fn call(id: &str, name: &str) -> Value { json!({"id": id, "type": "function", "function": {"name": name, "arguments": "{}"}}) } #[test] fn classify_without_decisions_splits_by_danger() { let calls = vec![call("c1", "read_agent_memory"), call("c2", "write_agent_note")]; let (pending, approved, rejected) = classify_tools(&calls, None); assert_eq!(pending.len(), 1); assert_eq!(approved.len(), 1); assert!(rejected.is_empty()); assert_eq!( pending[0].pointer("/function/name").and_then(Value::as_str), Some("write_agent_note") ); } #[test] fn read_notes_tool_is_safe_read_agent_memory_dangerous_write() { let calls = vec![ call("c1", "read_agent_notes"), call("c2", "read_agent_memory"), ]; let (pending, approved, _) = classify_tools(&calls, None); assert!(pending.is_empty()); assert_eq!(approved.len(), 2); } #[test] fn classify_unknown_tool_treated_dangerous() { let calls = vec![call("c1", "fly_to_moon")]; let (pending, approved, _) = classify_tools(&calls, None); assert_eq!(pending.len(), 1); assert!(approved.is_empty()); } #[test] fn classify_with_decisions_follows_master() { let calls = vec![call("c1", "write_agent_note"), call("c2", "write_agent_note")]; let decisions = vec![ ApprovalDecision { tool_call_id: "c1".to_string(), decision: "approve".to_string(), reason: None, }, ApprovalDecision { tool_call_id: "c2".to_string(), decision: "reject".to_string(), reason: Some("今天不动".to_string()), }, ]; let (pending, approved, rejected) = classify_tools(&calls, Some(&decisions)); assert!(pending.is_empty()); assert_eq!(approved.len(), 1); assert_eq!(rejected.len(), 1); assert_eq!(rejected[0].1, "今天不动"); } #[test] fn classify_missing_decision_stays_pending() { let calls = vec![call("c1", "write_agent_note")]; let decisions = vec![ApprovalDecision { tool_call_id: "other".to_string(), decision: "approve".to_string(), reason: None, }]; let (pending, approved, _) = classify_tools(&calls, Some(&decisions)); assert_eq!(pending.len(), 1); assert!(approved.is_empty()); } #[test] fn merge_tool_call_deltas_reassembles_stream() { let mut target: Vec = Vec::new(); merge_tool_call_deltas( &mut target, &[json!({"index": 0, "id": "call_1", "function": {"name": "write_", "arguments": "{\"file"}})], ); merge_tool_call_deltas( &mut target, &[json!({"index": 0, "function": {"name": "note", "arguments": "\":\"x\"}"}})], ); assert_eq!(target.len(), 1); assert_eq!(target[0]["id"], "call_1"); assert_eq!(target[0]["function"]["name"], "write_note"); assert_eq!(target[0]["function"]["arguments"], "{\"file\":\"x\"}"); } fn msg(role: &str, content: &str) -> ChatMsg { ChatMsg { role: role.to_string(), content: content.to_string(), tool_calls: Vec::new(), tool_call_id: None, } } #[test] fn estimate_tokens_counts_content() { let messages = vec![msg("user", "你好光湖"), msg("assistant", "在的")]; assert_eq!(estimate_tokens(&messages), 6); } #[test] fn protected_zone_keeps_tail_and_tools() { // 10 条:前段混 tool,尾 5 条必保,且最近的 tool 也保。 let mut messages: Vec = Vec::new(); for i in 0..10 { let role = if i == 1 || i == 3 { "tool" } else { "user" }; messages.push(msg(role, &format!("msg{i}"))); } let keep = protected_indices(&messages); // 尾 5 条(5..10)全保 for i in 5..10 { assert!(keep[i], "尾 {i} 未保"); } // 前段 tool(i=3 是最近的 tool 之一)保住,i=1 也保(工具结果 3 条配额内) assert!(keep[3]); assert!(keep[1]); // 前段 user 不保 assert!(!keep[0]); assert!(!keep[2]); } #[test] fn truncate_keeps_honest_boundary() { assert_eq!(truncate("abc", 5), "abc"); assert_eq!(truncate("abcdef", 3), "abc…"); } }