From e7b01031f8a347732dca94e342b403cb956f02aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Sun, 16 Aug 2026 00:54:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=91=B1=E5=93=92Agent=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E6=8B=86=E4=BB=B6=E8=90=BD=E5=9C=B0=E2=80=94=E2=80=94?= =?UTF-8?q?ReAct=E5=BE=AA=E7=8E=AF/=E5=AE=A1=E6=89=B9=E6=9C=BA=E5=85=B3/?= =?UTF-8?q?=E6=B0=B4=E4=BD=8D=E8=AE=B0=E5=BF=86=E7=AE=A1=E7=90=86/?= =?UTF-8?q?=E7=AC=94=E8=AE=B0=E4=B9=A0=E6=83=AF=E5=88=B6=EF=BC=88=E5=86=B0?= =?UTF-8?q?=E6=9C=9420260816=E8=B0=95=EF=BC=9A=E6=8B=86=E4=BB=B6=E5=AD=A6?= =?UTF-8?q?=E8=89=BA=E9=87=8D=E8=A3=85=E5=85=89=E6=B9=96=E4=BA=BA=E6=A0=BC?= =?UTF-8?q?=E9=A9=B1=E5=8A=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src-tauri/Cargo.lock | 2 + .../src-tauri/Cargo.toml | 4 +- .../src-tauri/src/agent_engine.rs | 1297 +++++++++++++++++ .../src-tauri/src/lib.rs | 6 + 4 files changed, 1308 insertions(+), 1 deletion(-) create mode 100644 product-source/hololake-native-desktop/src-tauri/src/agent_engine.rs diff --git a/product-source/hololake-native-desktop/src-tauri/Cargo.lock b/product-source/hololake-native-desktop/src-tauri/Cargo.lock index ad858d581..41cf00c71 100644 --- a/product-source/hololake-native-desktop/src-tauri/Cargo.lock +++ b/product-source/hololake-native-desktop/src-tauri/Cargo.lock @@ -1463,6 +1463,7 @@ dependencies = [ "base64 0.22.1", "dirs", "fs2", + "futures-util", "reqwest", "ring", "rusqlite", @@ -1475,6 +1476,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tempfile", + "tokio", "url", "uuid", ] diff --git a/product-source/hololake-native-desktop/src-tauri/Cargo.toml b/product-source/hololake-native-desktop/src-tauri/Cargo.toml index fe7cf176e..a1963eb9e 100644 --- a/product-source/hololake-native-desktop/src-tauri/Cargo.toml +++ b/product-source/hololake-native-desktop/src-tauri/Cargo.toml @@ -29,7 +29,9 @@ tauri-plugin-updater = "2.10.0" tauri-plugin-dialog = "=2.7.2" uuid = { version = "1", features = ["v4"] } url = "2" -reqwest = { version = "0.13.2", default-features = false, features = ["json", "rustls"] } +reqwest = { version = "0.13.2", default-features = false, features = ["json", "rustls", "stream"] } +tokio = { version = "1", features = ["time"] } +futures-util = "0.3" [dev-dependencies] tempfile = "3" diff --git a/product-source/hololake-native-desktop/src-tauri/src/agent_engine.rs b/product-source/hololake-native-desktop/src-tauri/src/agent_engine.rs new file mode 100644 index 000000000..ace852cfa --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/agent_engine.rs @@ -0,0 +1,1297 @@ +//! 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…"); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/lib.rs b/product-source/hololake-native-desktop/src-tauri/src/lib.rs index aa63b09e2..802084425 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/lib.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod agent_engine; mod agent_host; mod agent_parlor; mod code_channel; @@ -86,6 +87,11 @@ pub fn run() { code_repo_login::sign_out_code_repo_login, agent_host::receive_agent_task_envelope, agent_host::query_agent_session_memory, + agent_engine::run_agent_engine_task, + agent_engine::resume_agent_engine, + agent_engine::submit_engine_memory, + agent_engine::restore_engine_memory, + agent_engine::query_engine_session, zero_point::zero_point_bind, zero_point::zero_point_verify, zero_point::zero_point_sync,