feat: add web research and visible agent activity

This commit is contained in:
冰朔 2026-07-22 16:16:26 +08:00
parent 85c56230d0
commit 46d287510d
15 changed files with 821 additions and 46 deletions

2
src-tauri/Cargo.lock generated
View File

@ -1881,6 +1881,7 @@ dependencies = [
"objc2-app-kit",
"objc2-foundation",
"objc2-web-kit",
"quick-xml",
"regex",
"reqwest 0.12.28",
"sentry",
@ -3691,6 +3692,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [
"memchr",
"serde",
]
[[package]]

View File

@ -48,6 +48,7 @@ sentry = "0.37"
uuid = { version = "1", features = ["v4"] }
tempfile = "3"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
quick-xml = { version = "0.38", features = ["serialize"] }
tauri-plugin-deep-link = "2.4.9"
tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }

View File

@ -8,6 +8,8 @@ Use `magic_brush` as the executable skill for work inside the active vault. Do n
- `get_vault_context`: no arguments
- `get_note`: `path`
- `read_guanghu_url`: `url`
- `search_web`: `query`; use it when the user asks for current or external information and has not supplied a URL
- `read_web_page`: public HTTPS `url`; use it to verify relevant search results before drawing conclusions
- `create_note`: `path`, plus `content` whenever the user requested body text; `title` may be used only for a heading-only note
- `edit_note`: `path` and the complete replacement `content`
- `delete_note`: `path`; only after the user explicitly confirms the exact deletion
@ -21,3 +23,4 @@ Use `magic_brush` as the executable skill for work inside the active vault. Do n
5. Report each completed step from the tool results. If a step fails, state the exact failed tool and missing or invalid argument; do not claim later steps succeeded.
6. Do not repeat a step whose successful result is already present in the conversation.
7. Ask for confirmation immediately before permanent deletion, even if earlier read/create/edit steps have already completed.
8. For web research, search first, read the most relevant result pages, distinguish page claims from verified facts, and include the source links in the final answer. Never claim that search snippets alone prove the page contents.

View File

@ -1,5 +1,7 @@
use crate::ai_agents::AiAgentStreamEvent;
use crate::ai_models::{AiModelProviderKind, AiModelStreamRequest};
use crate::ai_models::AiModelStreamRequest;
use serde::Deserialize;
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use std::path::{Path, PathBuf};
const CREATE_NOTE_TOOL_NAME: &str = "create_note";
@ -9,6 +11,8 @@ const GET_NOTE_TOOL_NAME: &str = "get_note";
const EDIT_NOTE_TOOL_NAME: &str = "edit_note";
const DELETE_NOTE_TOOL_NAME: &str = "delete_note";
const READ_GUANGHU_URL_TOOL_NAME: &str = "read_guanghu_url";
const SEARCH_WEB_TOOL_NAME: &str = "search_web";
const READ_WEB_PAGE_TOOL_NAME: &str = "read_web_page";
const MAGIC_BRUSH_TOOL_NAME: &str = "magic_brush";
const FIFTH_DOMAIN_WAKE_TOOL_NAME: &str = "get_fifth_domain_wake_route";
const FIFTH_DOMAIN_WAKE_SKILL: &str =
@ -36,8 +40,8 @@ const MAGIC_BRUSH_TOOL_JSON: &str = r#"{
"properties": {
"tool": {
"type": "string",
"description": "Operation and required arguments: search_notes(query); get_note(path); read_guanghu_url(url); create_note(path, content or title); edit_note(path, complete content); delete_note(path); get_vault_context() and get_fifth_domain_wake_route() need no arguments.",
"enum": ["get_fifth_domain_wake_route", "search_notes", "get_vault_context", "get_note", "read_guanghu_url", "create_note", "edit_note", "delete_note"]
"description": "Operation and required arguments: search_web(query) finds public web pages; read_web_page(url) reads one public HTTPS result; search_notes(query); get_note(path); read_guanghu_url(url); create_note(path, content or title); edit_note(path, complete content); delete_note(path); get_vault_context() and get_fifth_domain_wake_route() need no arguments.",
"enum": ["get_fifth_domain_wake_route", "search_web", "read_web_page", "search_notes", "get_vault_context", "get_note", "read_guanghu_url", "create_note", "edit_note", "delete_note"]
},
"arguments": {
"type": "object",
@ -67,6 +71,11 @@ struct OpenAiToolResult {
output: String,
}
pub(crate) struct AnthropicToolExecution {
pub id: String,
pub result: Result<String, String>,
}
impl OpenAiToolResult {
fn from_summary(summary: String) -> Self {
Self {
@ -90,6 +99,34 @@ pub(crate) fn openai_chat_payload(request: &AiModelStreamRequest) -> serde_json:
payload
}
pub(crate) fn anthropic_tools(request: &AiModelStreamRequest) -> Vec<serde_json::Value> {
if !should_offer_openai_tools(request) {
return Vec::new();
}
openai_vault_tools()
.into_iter()
.filter_map(|tool| {
let function = tool.get("function")?;
Some(serde_json::json!({
"name": function.get("name")?,
"description": function.get("description")?,
"input_schema": function.get("parameters")?,
}))
})
.collect()
}
pub(crate) fn anthropic_system_context(request: &AiModelStreamRequest) -> Option<String> {
let mut context = Vec::new();
if let Some(route) = conditional_route_context(&request.message) {
context.push(route);
}
if !anthropic_tools(request).is_empty() {
context.push(VAULT_TOOL_OPERATIONS_SKILL);
}
(!context.is_empty()).then(|| context.join("\n\n"))
}
fn selected_model_supports_streaming(request: &AiModelStreamRequest) -> bool {
request
.provider
@ -124,6 +161,54 @@ pub(crate) fn openai_response_has_tool_calls(json: &serde_json::Value) -> bool {
.is_some_and(|calls| !calls.is_empty())
}
pub(crate) fn anthropic_response_has_tool_calls(json: &serde_json::Value) -> bool {
json["content"]
.as_array()
.is_some_and(|content| content.iter().any(|block| block["type"] == "tool_use"))
}
pub(crate) fn execute_anthropic_tool_calls<F>(
request: &AiModelStreamRequest,
json: &serde_json::Value,
mut emit: F,
) -> Result<Vec<AnthropicToolExecution>, String>
where
F: FnMut(AiAgentStreamEvent),
{
let content = json["content"]
.as_array()
.ok_or_else(|| "Anthropic tool response did not include content blocks.".to_string())?;
let mut executions = Vec::new();
for (index, block) in content.iter().enumerate() {
if block["type"] != "tool_use" {
continue;
}
let id = block["id"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| format!("anthropic_tool_{index}"));
let name = block["name"]
.as_str()
.ok_or_else(|| "Anthropic tool call did not include a name.".to_string())?
.to_string();
let arguments = block
.get("input")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
let call = OpenAiToolCall {
id: id.clone(),
name,
raw_arguments: arguments.to_string(),
arguments,
};
executions.push(AnthropicToolExecution {
id,
result: execute_openai_tool_call_with_events(request, &call, &mut emit),
});
}
Ok(executions)
}
fn openai_chat_messages(
request: &AiModelStreamRequest,
offers_tools: bool,
@ -155,10 +240,7 @@ fn conditional_route_context(message: &str) -> Option<&'static str> {
fn should_offer_openai_tools(request: &AiModelStreamRequest) -> bool {
let has_active_vault = non_empty_option(request.vault_path.as_deref()).is_some();
has_active_vault
&& message_needs_magic_brush(&request.message)
&& (request.provider.kind == AiModelProviderKind::OpenAi
|| selected_model_supports_tools(request))
has_active_vault && message_needs_magic_brush(&request.message)
}
fn message_needs_magic_brush(message: &str) -> bool {
@ -192,6 +274,11 @@ fn message_needs_magic_brush(message: &str) -> bool {
"file",
"vault",
"https://guanghulab.com/",
"web",
"internet",
"online",
"research",
"latest",
"搜索",
"查找",
"读取",
@ -210,6 +297,14 @@ fn message_needs_magic_brush(message: &str) -> bool {
"文件",
"知识库",
"路径",
"联网",
"上网",
"网上",
"网络",
"网页",
"调研",
"查资料",
"最新",
];
if operation_words.iter().any(|word| trimmed.contains(word)) {
return true;
@ -224,15 +319,6 @@ fn message_needs_magic_brush(message: &str) -> bool {
.any(|word| message.to_lowercase().contains(word))
}
fn selected_model_supports_tools(request: &AiModelStreamRequest) -> bool {
request
.provider
.models
.iter()
.find(|model| model.id == request.model_id)
.is_some_and(|model| model.capabilities.tools)
}
fn openai_vault_tools() -> Vec<serde_json::Value> {
[MAGIC_BRUSH_TOOL_JSON]
.into_iter()
@ -303,6 +389,8 @@ where
GET_VAULT_CONTEXT_TOOL_NAME => vault_context_from_tool_args(request)?,
GET_NOTE_TOOL_NAME => get_note_from_tool_args(request, &tool_call.arguments)?,
READ_GUANGHU_URL_TOOL_NAME => read_guanghu_url_from_tool_args(&tool_call.arguments)?,
SEARCH_WEB_TOOL_NAME => search_web_from_tool_args(&tool_call.arguments)?,
READ_WEB_PAGE_TOOL_NAME => read_web_page_from_tool_args(&tool_call.arguments)?,
EDIT_NOTE_TOOL_NAME => edit_note_from_tool_args(request, &tool_call.arguments)?,
DELETE_NOTE_TOOL_NAME => delete_note_from_tool_args(request, &tool_call.arguments)?,
MAGIC_BRUSH_TOOL_NAME => run_magic_brush_from_args(request, &tool_call.arguments, emit)?,
@ -430,6 +518,237 @@ fn read_guanghu_url_from_tool_args(args: &serde_json::Value) -> Result<String, S
})
}
#[derive(Debug, Deserialize)]
struct WebSearchRss {
channel: WebSearchChannel,
}
#[derive(Debug, Deserialize)]
struct WebSearchChannel {
#[serde(default)]
item: Vec<WebSearchItem>,
}
#[derive(Debug, Deserialize)]
struct WebSearchItem {
#[serde(default)]
title: String,
#[serde(default)]
link: String,
#[serde(default)]
description: String,
}
fn search_web_from_tool_args(args: &serde_json::Value) -> Result<String, String> {
let query = required_tool_string(args, SEARCH_WEB_TOOL_NAME, "query")?;
let limit = args
.get("limit")
.and_then(serde_json::Value::as_u64)
.unwrap_or(6)
.clamp(1, 10) as usize;
let mut url = reqwest::Url::parse("https://www.bing.com/search")
.map_err(|error| format!("Failed to prepare web search: {error}"))?;
url.query_pairs_mut()
.append_pair("format", "rss")
.append_pair("q", query);
let body = public_web_get(url)
.map_err(|error| format!("联网搜索失败:{error}"))?
.error_for_status()
.map_err(|error| format!("联网搜索服务返回错误:{error}"))?
.text()
.map_err(|error| format!("读取联网搜索结果失败:{error}"))?;
let rss: WebSearchRss =
quick_xml::de::from_str(&body).map_err(|error| format!("解析联网搜索结果失败:{error}"))?;
format_web_search_results(query, rss.channel.item, limit)
}
fn format_web_search_results(
query: &str,
items: Vec<WebSearchItem>,
limit: usize,
) -> Result<String, String> {
let results = items
.into_iter()
.filter(|item| !item.title.trim().is_empty() && !item.link.trim().is_empty())
.take(limit)
.enumerate()
.map(|(index, item)| {
format!(
"{}. {}\n 链接:{}\n 摘要:{}",
index + 1,
plain_web_text(&item.title),
item.link.trim(),
plain_web_text(&item.description),
)
})
.collect::<Vec<_>>();
if results.is_empty() {
return Ok(format!("联网搜索“{query}”没有返回可用页面。"));
}
Ok(format!(
"联网搜索“{query}”得到以下页面。需要核实内容时,请继续调用 read_web_page 读取相关链接:\n{}",
results.join("\n")
))
}
fn read_web_page_from_tool_args(args: &serde_json::Value) -> Result<String, String> {
let raw_url = required_tool_string(args, READ_WEB_PAGE_TOOL_NAME, "url")?;
let url = reqwest::Url::parse(raw_url).map_err(|error| format!("无效网页地址:{error}"))?;
validate_public_https_url(&url)?;
let response = public_web_get(url.clone())
.map_err(|error| format!("读取网页失败:{error}"))?
.error_for_status()
.map_err(|error| format!("网页返回错误:{error}"))?;
let final_url = response.url().clone();
validate_public_https_url(&final_url)?;
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_ascii_lowercase();
let body = response
.text()
.map_err(|error| format!("读取网页正文失败:{error}"))?;
const MAX_WEB_PAGE_CHARS: usize = 30_000;
let readable = if content_type.contains("html") || body.trim_start().starts_with('<') {
plain_web_text(&body)
} else {
body
};
let clipped = readable
.chars()
.take(MAX_WEB_PAGE_CHARS)
.collect::<String>();
let suffix = if readable.chars().count() > MAX_WEB_PAGE_CHARS {
"\n\n[网页内容过长,已截断]"
} else {
""
};
Ok(format!("网页:{final_url}\n\n{clipped}{suffix}"))
}
fn public_web_get(mut url: reqwest::Url) -> Result<reqwest::blocking::Response, String> {
const MAX_REDIRECTS: usize = 5;
for _ in 0..=MAX_REDIRECTS {
validate_public_https_url(&url)?;
let resolved = resolve_public_web_host(&url)?;
let mut builder = reqwest::blocking::Client::builder()
.user_agent("HoloLake-Era/0.1.8 public-research-tool")
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(30));
if let (Some(host), Some(address)) = (url.host_str(), resolved.first()) {
let ip_host = host
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
.unwrap_or(host);
if ip_host.parse::<IpAddr>().is_err() {
builder = builder.resolve(host, *address);
}
}
let response = builder
.build()
.map_err(|error| format!("无法创建公共网页读取器:{error}"))?
.get(url.clone())
.send()
.map_err(|error| format!("公共网页请求失败:{error}"))?;
if !response.status().is_redirection() {
return Ok(response);
}
let location = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| "网页返回了没有目标地址的重定向。".to_string())?;
url = url
.join(location)
.map_err(|error| format!("网页重定向地址无效:{error}"))?;
}
Err(format!("网页重定向超过 {MAX_REDIRECTS} 次,已停止读取。"))
}
fn resolve_public_web_host(url: &reqwest::Url) -> Result<Vec<SocketAddr>, String> {
let host = url
.host_str()
.ok_or_else(|| "网页地址缺少主机名。".to_string())?;
let addresses = (host, url.port_or_known_default().unwrap_or(443))
.to_socket_addrs()
.map_err(|error| format!("无法解析网页主机:{error}"))?
.collect::<Vec<_>>();
if addresses.is_empty() {
return Err("网页主机没有可用的公共地址。".into());
}
if addresses
.iter()
.any(|address| !is_public_web_ip(address.ip()))
{
return Err("联网工具不能访问解析到本机、局域网或保留地址的主机。".into());
}
Ok(addresses)
}
fn validate_public_https_url(url: &reqwest::Url) -> Result<(), String> {
if url.scheme() != "https" {
return Err("联网工具只允许读取公共 HTTPS 网页。".into());
}
let host = url
.host_str()
.ok_or_else(|| "网页地址缺少主机名。".to_string())?
.trim_end_matches('.')
.to_ascii_lowercase();
if host == "localhost" || host.ends_with(".localhost") || host.ends_with(".local") {
return Err("联网工具不能访问本机或局域网地址。".into());
}
let ip_host = host
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
.unwrap_or(&host);
if let Ok(ip) = ip_host.parse::<IpAddr>() {
if !is_public_web_ip(ip) {
return Err("联网工具不能访问本机、局域网或保留地址。".into());
}
}
Ok(())
}
fn is_public_web_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => {
!(ip.is_private()
|| ip.is_loopback()
|| ip.is_link_local()
|| ip.is_broadcast()
|| ip.is_multicast()
|| ip.is_unspecified()
|| ip.is_documentation())
}
IpAddr::V6(ip) => {
let first = ip.segments()[0];
let unique_local = first & 0xfe00 == 0xfc00;
let link_local = first & 0xffc0 == 0xfe80;
!(ip.is_loopback()
|| ip.is_unspecified()
|| ip.is_multicast()
|| unique_local
|| link_local)
}
}
}
fn plain_web_text(raw: &str) -> String {
let without_hidden =
regex::Regex::new(r"(?is)<(?:script|style|noscript)[^>]*>.*?</(?:script|style|noscript)>")
.expect("hidden web content regex must compile")
.replace_all(raw, " ");
let without_tags = regex::Regex::new(r"(?s)<[^>]+>")
.expect("HTML tag regex must compile")
.replace_all(&without_hidden, " ");
let decoded = quick_xml::escape::unescape(&without_tags)
.map(|text| text.into_owned())
.unwrap_or_else(|_| without_tags.into_owned());
decoded.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn resolve_existing_vault_note(
request: &AiModelStreamRequest,
raw_path: &str,
@ -508,6 +827,8 @@ where
GET_VAULT_CONTEXT_TOOL_NAME,
GET_NOTE_TOOL_NAME,
READ_GUANGHU_URL_TOOL_NAME,
SEARCH_WEB_TOOL_NAME,
READ_WEB_PAGE_TOOL_NAME,
CREATE_NOTE_TOOL_NAME,
EDIT_NOTE_TOOL_NAME,
DELETE_NOTE_TOOL_NAME,
@ -715,6 +1036,7 @@ mod tests {
use super::*;
use crate::ai_models::{
AiModelApiKeyStorage, AiModelCapabilities, AiModelDefinition, AiModelProvider,
AiModelProviderKind,
};
use serde_json::json;
use std::fs;
@ -868,6 +1190,28 @@ mod tests {
.any(|tool| tool["function"]["name"] == MAGIC_BRUSH_TOOL_NAME));
}
#[test]
fn anthropic_payload_uses_native_tool_schema_and_shared_skill() {
let dir = tempfile::tempdir().unwrap();
let mut request = request_with_provider(
provider(),
Some(dir.path().to_string_lossy().into_owned()),
vec![],
);
request.provider.kind = crate::ai_models::AiModelProviderKind::Anthropic;
request.message = "请联网搜索今天的公开资料。".into();
let tools = anthropic_tools(&request);
assert_eq!(tools[0]["name"], MAGIC_BRUSH_TOOL_NAME);
assert!(tools[0]["input_schema"].is_object());
assert!(
anthropic_system_context(&request).is_some_and(|context| context
.contains("search_web")
&& context.contains("read_web_page"))
);
}
#[test]
fn openai_payload_skips_create_note_when_no_active_vault_is_loaded() {
let payload = openai_chat_payload(&request_with_provider(provider(), None, Vec::new()));
@ -1025,6 +1369,38 @@ mod tests {
));
}
#[test]
fn parses_and_executes_anthropic_native_tool_calls() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("page.md"), "# Anthropic tool result\n").unwrap();
let request = request(dir.path().to_string_lossy().into_owned());
let response = json!({
"content": [{
"type": "tool_use",
"id": "toolu_read",
"name": MAGIC_BRUSH_TOOL_NAME,
"input": {
"purpose": "read the page",
"steps": [{ "tool": GET_NOTE_TOOL_NAME, "arguments": { "path": "page.md" } }]
}
}]
});
let mut events = Vec::new();
let executions =
execute_anthropic_tool_calls(&request, &response, |event| events.push(event)).unwrap();
assert_eq!(executions.len(), 1);
assert_eq!(executions[0].id, "toolu_read");
assert!(executions[0]
.result
.as_ref()
.is_ok_and(|result| result.contains("Anthropic tool result")));
assert!(events
.iter()
.any(|event| matches!(event, AiAgentStreamEvent::ToolDone { .. })));
}
#[test]
fn executes_safe_read_only_vault_tools() {
let dir = tempfile::tempdir().unwrap();
@ -1168,6 +1544,39 @@ mod tests {
assert!(error.contains("edit_note requires content"));
}
#[test]
fn web_search_results_are_readable_and_keep_source_links() {
let items = vec![WebSearchItem {
title: "<b>光湖 &amp; HoloLake</b>".into(),
link: "https://example.com/hololake".into(),
description: "<p>公开页面 <strong>摘要</strong></p>".into(),
}];
let result = format_web_search_results("光湖", items, 6).unwrap();
assert!(result.contains("光湖 & HoloLake"));
assert!(result.contains("https://example.com/hololake"));
assert!(result.contains("公开页面 摘要"));
assert!(result.contains("read_web_page"));
}
#[test]
fn public_web_reader_rejects_local_and_insecure_addresses() {
for url in [
"http://example.com",
"https://localhost/private",
"https://127.0.0.1/private",
"https://192.168.1.20/private",
"https://[::1]/private",
] {
assert!(validate_public_https_url(&reqwest::Url::parse(url).unwrap()).is_err());
}
assert!(validate_public_https_url(
&reqwest::Url::parse("https://example.com/public").unwrap()
)
.is_ok());
}
#[test]
fn execute_openai_tool_calls_repairs_json5_arguments() {
let dir = tempfile::tempdir().unwrap();

View File

@ -201,7 +201,7 @@ where
F: FnMut(AiAgentStreamEvent),
{
match request.provider.kind {
AiModelProviderKind::Anthropic => send_anthropic_message(request),
AiModelProviderKind::Anthropic => send_anthropic_message(request, emit),
_ => send_openai_compatible_message(request, emit),
}
}
@ -249,7 +249,10 @@ fn tool_choice_compatibility_error(error: &str) -> bool {
normalized.contains("tool_choice")
&& (normalized.contains("does not support")
|| normalized.contains("unsupported")
|| normalized.contains("not supported"))
|| normalized.contains("not supported")
|| normalized.contains("unknown field")
|| normalized.contains("unrecognized")
|| normalized.contains("invalid parameter"))
}
fn run_openai_agent_loop<F, S>(
@ -344,18 +347,26 @@ where
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|error| format!("Failed to create HTTP client: {error}"))?;
let builder = apply_provider_headers(apply_auth_headers(client.post(endpoint).json(&payload), request)?, request);
let builder = apply_provider_headers(
apply_auth_headers(client.post(endpoint).json(&payload), request)?,
request,
);
let response = send_provider_request(builder)?;
let status = response.status();
if !status.is_success() {
let text = response.text().unwrap_or_default();
return Err(format!("AI provider returned {status}: {}", truncate_error(&text)));
return Err(format!(
"AI provider returned {status}: {}",
truncate_error(&text)
));
}
let mut full_text = String::new();
for line in BufReader::new(response).lines() {
let line = line.map_err(|error| format!("Failed to read AI provider stream: {error}"))?;
let Some(delta) = openai_sse_text_delta(&line)? else { continue };
let Some(delta) = openai_sse_text_delta(&line)? else {
continue;
};
full_text.push_str(&delta);
emit(AiAgentStreamEvent::TextDelta { text: delta });
}
@ -367,14 +378,23 @@ where
}
fn openai_sse_text_delta(line: &str) -> Result<Option<String>, String> {
let Some(data) = line.strip_prefix("data:").map(str::trim) else { return Ok(None) };
if data.is_empty() || data == "[DONE]" { return Ok(None) }
let Some(data) = line.strip_prefix("data:").map(str::trim) else {
return Ok(None);
};
if data.is_empty() || data == "[DONE]" {
return Ok(None);
}
let json: serde_json::Value = serde_json::from_str(data)
.map_err(|error| format!("Failed to parse AI provider stream event: {error}"))?;
Ok(json["choices"][0]["delta"]["content"].as_str().map(str::to_string))
Ok(json["choices"][0]["delta"]["content"]
.as_str()
.map(str::to_string))
}
fn send_anthropic_message(request: &AiModelStreamRequest) -> Result<String, String> {
fn send_anthropic_message<F>(request: &AiModelStreamRequest, emit: &mut F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let endpoint = format!("{}/messages", normalized_base_url(request)?);
let mut payload = serde_json::json!({
"model": request.model_id,
@ -382,14 +402,97 @@ fn send_anthropic_message(request: &AiModelStreamRequest) -> Result<String, Stri
"messages": [{ "role": "user", "content": request.message }]
});
if let Some(system_prompt) = non_empty_option(request.system_prompt.as_deref()) {
payload["system"] = serde_json::Value::String(system_prompt.to_string());
let system = non_empty_option(request.system_prompt.as_deref())
.into_iter()
.map(str::to_string)
.chain(crate::ai_model_tools::anthropic_system_context(request))
.collect::<Vec<_>>()
.join("\n\n");
if !system.is_empty() {
payload["system"] = serde_json::Value::String(system);
}
let tools = crate::ai_model_tools::anthropic_tools(request);
if !tools.is_empty() {
payload["tools"] = serde_json::Value::Array(tools);
return run_anthropic_agent_loop(request, payload, emit, |attempt| {
send_json_request(request, endpoint.clone(), attempt)
});
}
let json = send_json_request(request, endpoint, payload)?;
extract_anthropic_text(&json)
}
fn run_anthropic_agent_loop<F, S>(
request: &AiModelStreamRequest,
mut payload: serde_json::Value,
emit: &mut F,
mut send: S,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
S: FnMut(serde_json::Value) -> Result<serde_json::Value, String>,
{
const MAX_TOOL_ROUNDS: usize = 8;
for round in 1..=MAX_TOOL_ROUNDS {
let json = send(payload.clone())?;
if !crate::ai_model_tools::anthropic_response_has_tool_calls(&json) {
return extract_anthropic_text(&json);
}
let executions =
crate::ai_model_tools::execute_anthropic_tool_calls(request, &json, &mut *emit)?;
append_anthropic_agent_round(&mut payload, &json, &executions)?;
if round == MAX_TOOL_ROUNDS {
return Err(format!(
"Agent stopped after {MAX_TOOL_ROUNDS} tool rounds to prevent an infinite loop."
));
}
let failed = executions.iter().any(|execution| execution.result.is_err());
emit(AiAgentStreamEvent::ThinkingDelta {
text: if failed {
format!("{round} 轮工具参数有误,正在修正后继续……")
} else {
format!("{round} 轮工具执行完成,正在根据结果继续处理……")
},
});
}
unreachable!("bounded Anthropic agent loop always returns")
}
fn append_anthropic_agent_round(
payload: &mut serde_json::Value,
response: &serde_json::Value,
executions: &[crate::ai_model_tools::AnthropicToolExecution],
) -> Result<(), String> {
let messages = payload["messages"]
.as_array_mut()
.ok_or_else(|| "Anthropic agent payload did not include a messages array.".to_string())?;
messages.push(serde_json::json!({
"role": "assistant",
"content": response["content"].clone(),
}));
let results = executions
.iter()
.map(|execution| match &execution.result {
Ok(output) => serde_json::json!({
"type": "tool_result",
"tool_use_id": execution.id,
"content": format!("{output}\n\nIf the task is unfinished, call the next tool now. Only give the final answer after every requested operation has an explicit successful result."),
}),
Err(error) => serde_json::json!({
"type": "tool_result",
"tool_use_id": execution.id,
"content": format!("Tool failed: {error}\n\nCorrect the arguments and call the tool again. Do not claim this operation succeeded."),
"is_error": true,
}),
})
.collect::<Vec<_>>();
messages.push(serde_json::json!({ "role": "user", "content": results }));
Ok(())
}
fn selected_max_tokens(request: &AiModelStreamRequest) -> u32 {
request
.provider
@ -962,6 +1065,17 @@ mod tests {
assert!(attempts[1]["tools"].is_array());
}
#[test]
fn tool_choice_fallback_is_provider_agnostic() {
for error in [
"unknown field tool_choice",
"tool_choice is unsupported by this endpoint",
"invalid parameter: tool_choice",
] {
assert!(tool_choice_compatibility_error(error));
}
}
#[test]
fn agent_loop_reads_then_edits_before_returning_final_text() {
let dir = tempfile::tempdir().unwrap();
@ -1018,13 +1132,83 @@ mod tests {
.unwrap();
assert_eq!(answer, "Verified complete.");
assert_eq!(fs::read_to_string(dir.path().join("page.md")).unwrap(), "# After\n\n[[Linked page]]");
assert_eq!(
fs::read_to_string(dir.path().join("page.md")).unwrap(),
"# After\n\n[[Linked page]]"
);
assert_eq!(sent_payloads.len(), 3);
assert!(sent_payloads[1]["messages"].as_array().unwrap().last().unwrap()["content"]
.as_str().unwrap().contains("# Before"));
assert!(sent_payloads[2]["messages"].as_array().unwrap().last().unwrap()["content"]
.as_str().unwrap().contains("已更新笔记"));
assert!(events.iter().filter(|event| matches!(event, AiAgentStreamEvent::ToolDone { .. })).count() >= 4);
assert!(sent_payloads[1]["messages"]
.as_array()
.unwrap()
.last()
.unwrap()["content"]
.as_str()
.unwrap()
.contains("# Before"));
assert!(sent_payloads[2]["messages"]
.as_array()
.unwrap()
.last()
.unwrap()["content"]
.as_str()
.unwrap()
.contains("已更新笔记"));
assert!(
events
.iter()
.filter(|event| matches!(event, AiAgentStreamEvent::ToolDone { .. }))
.count()
>= 4
);
}
#[test]
fn anthropic_agent_loop_returns_native_tool_results_before_continuing() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("page.md"), "# Native Claude result\n").unwrap();
let mut request = request(provider(AiModelProviderKind::Anthropic));
request.vault_path = Some(dir.path().to_string_lossy().into_owned());
request.message = "Read page.md".into();
let mut responses = vec![
json!({
"content": [{
"type": "tool_use",
"id": "toolu_1",
"name": "magic_brush",
"input": {
"purpose": "read the requested page",
"steps": [{ "tool": "get_note", "arguments": { "path": "page.md" } }]
}
}]
}),
json!({ "content": [{ "type": "text", "text": "Read and verified." }] }),
];
let mut sent = Vec::new();
let mut events = Vec::new();
let answer = run_anthropic_agent_loop(
&request,
json!({
"messages": [{ "role": "user", "content": "Read page.md" }],
"tools": [{ "name": "magic_brush" }]
}),
&mut |event| events.push(event),
|payload| {
sent.push(payload);
Ok(responses.remove(0))
},
)
.unwrap();
assert_eq!(answer, "Read and verified.");
assert_eq!(sent.len(), 2);
let tool_result = sent[1]["messages"].as_array().unwrap().last().unwrap();
assert!(tool_result["content"][0]["content"]
.as_str()
.is_some_and(|content| content.contains("Native Claude result")));
assert!(events
.iter()
.any(|event| matches!(event, AiAgentStreamEvent::ToolDone { .. })));
}
#[test]

View File

@ -4,6 +4,7 @@ import {
CircleNotch, CheckCircle, XCircle, CaretRight, CaretDown,
Terminal, File, FolderOpen, NotePencil,
} from '@phosphor-icons/react'
import type { AppLocale } from '../lib/i18n'
export type AiActionStatus = 'pending' | 'done' | 'error'
@ -17,6 +18,7 @@ export interface AiActionCardProps {
expanded: boolean
onToggle: () => void
onOpenNote?: (path: string) => void
locale?: AppLocale
}
const MAX_DETAIL_LENGTH = 800
@ -38,6 +40,9 @@ const TOOL_ICON_MAP: Record<string, IconRenderer> = {
Grep: (s) => <MagnifyingGlass size={s} />,
// Tolaria MCP tools
search_notes: (s) => <MagnifyingGlass size={s} />,
search_web: (s) => <MagnifyingGlass size={s} />,
read_web_page: (s) => <Eye size={s} />,
read_guanghu_url: (s) => <Eye size={s} />,
get_vault_context: (s) => <ChartBar size={s} />,
get_note: (s) => <File size={s} />,
open_note: (s) => <Eye size={s} />,
@ -183,12 +188,14 @@ function ActionCardDetails({
input,
output,
status,
locale = 'en',
}: {
expanded: boolean
hasDetails: boolean
input?: string
output?: string
status: AiActionStatus
locale?: AppLocale
}) {
if (!expanded || !hasDetails) return null
@ -198,16 +205,16 @@ function ActionCardDetails({
data-testid="action-card-details"
style={{ padding: '0 10px 8px 10px' }}
>
{formattedInput && <DetailBlock label="Input" content={formattedInput} />}
{formattedInput && <DetailBlock label={locale.startsWith('zh') ? '输入' : 'Input'} content={formattedInput} />}
{output && (
<DetailBlock label="Output" content={output} isError={status === 'error'} />
<DetailBlock label={locale.startsWith('zh') ? '结果' : 'Output'} content={output} isError={status === 'error'} />
)}
</div>
)
}
export function AiActionCard({
tool, label, path, status, input, output, expanded, onToggle, onOpenNote,
tool, label, path, status, input, output, expanded, onToggle, onOpenNote, locale,
}: AiActionCardProps) {
const renderIcon = TOOL_ICON_BY_NAME.get(tool) ?? DEFAULT_ICON
const hasDetails = hasActionDetails(input, output)
@ -256,6 +263,7 @@ export function AiActionCard({
input={input}
output={output}
status={status}
locale={locale}
/>
</div>
)

View File

@ -109,6 +109,7 @@ describe('AiMessage', () => {
expect(screen.getByTestId('tool-use-toggle')).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByTestId('tool-use-count').textContent).toBe('2')
expect(screen.getByTestId('tool-use-count')).toHaveAttribute('data-pending', 'true')
expect(screen.getByTestId('tool-use-current-action')).toHaveTextContent('Searched')
expect(screen.queryByTestId('ai-action-card')).toBeNull()
fireEvent.click(screen.getByTestId('tool-use-toggle'))

View File

@ -146,11 +146,12 @@ function ReasoningBlock({ locale, text, expanded, onToggle }: {
)
}
function ActionCardsList({ actions, onOpenNote, expandedIds, onToggleExpand }: {
function ActionCardsList({ actions, onOpenNote, expandedIds, onToggleExpand, locale }: {
actions: AiAction[]
onOpenNote?: (path: string) => void
expandedIds: Set<string>
onToggleExpand: (toolId: string) => void
locale?: AppLocale
}) {
return (
<div className="flex flex-col gap-1" style={{ marginBottom: 8 }}>
@ -166,6 +167,7 @@ function ActionCardsList({ actions, onOpenNote, expandedIds, onToggleExpand }: {
expanded={expandedIds.has(action.toolId)}
onToggle={() => onToggleExpand(action.toolId)}
onOpenNote={onOpenNote}
locale={locale}
/>
))}
</div>
@ -190,6 +192,7 @@ function ToolUseBlock({
onToggleAction: (toolId: string) => void
}) {
const pending = actions.some((action) => action.status === 'pending')
const latestAction = actions.at(-1)
return (
<div style={{ marginBottom: 8 }}>
@ -203,6 +206,15 @@ function ToolUseBlock({
>
<Terminal size={14} />
<span>{translate(locale, 'ai.message.toolUse')}</span>
{latestAction && (
<span
className="min-w-0 flex-1 truncate text-left"
style={{ color: 'var(--foreground)', opacity: 0.82 }}
data-testid="tool-use-current-action"
>
{latestAction.label}
</span>
)}
<span
className={`inline-flex h-4 min-w-4 items-center justify-center rounded-full ${pending ? 'animate-pulse' : ''}`}
style={{
@ -226,6 +238,7 @@ function ToolUseBlock({
onOpenNote={onOpenNote}
expandedIds={expandedActionIds}
onToggleExpand={onToggleAction}
locale={locale}
/>
</div>
)}

View File

@ -46,6 +46,17 @@ describe('HoloLakeHome', () => {
expect(screen.getByRole('heading', { name: '爱之核心子系统' })).toBeInTheDocument()
})
it('shows the user-facing 0.1.8 release notes and capability boundary', () => {
render(<HoloLakeHome locale="zh-CN" onEnterKnowledgeBase={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: '查看 0.1.8 更新说明' }))
expect(screen.getByRole('dialog', { name: 'HoloLake Era 0.1.8 · 本次更新' })).toBeInTheDocument()
expect(screen.getByText('主动联网调研')).toBeInTheDocument()
expect(screen.getByText('多模型 API 兼容')).toBeInTheDocument()
expect(screen.getByText(/不等同于拥有终端、代码工程和系统权限的完整编程 Agent/)).toBeInTheDocument()
})
it('keeps Zhizhi inside Eternal Lake Heart and routes to Tomorrow channel', () => {
render(<HoloLakeHome locale="zh-CN" onEnterKnowledgeBase={vi.fn()} />)

View File

@ -67,6 +67,7 @@ const registeredServers = [
export function HoloLakeHome({ locale, onEnterKnowledgeBase, onOpenLocalWorkspace }: HoloLakeHomeProps) {
const [route, setRoute] = useState<ChannelRoute>('zero-core')
const [architectureOpen, setArchitectureOpen] = useState(false)
const [releaseNotesOpen, setReleaseNotesOpen] = useState(false)
const [fifthDomainConnection, setFifthDomainConnection] = useState<
{ status: 'checking' | 'error' } | { status: 'connected'; discovery: FifthDomainDiscovery }
>({ status: 'checking' })
@ -119,6 +120,7 @@ export function HoloLakeHome({ locale, onEnterKnowledgeBase, onOpenLocalWorkspac
<h1>{t('hololake.channel.zeroCoreTitle')}</h1>
<p className="channel-stage__lead">{t('hololake.channel.zeroCoreDescription')}</p>
<Button className="hololake-home__primary" onClick={() => navigate('fifth-domain')}>{t('hololake.channel.enterFifthDomain')} <span></span></Button>
<Button variant="outline" onClick={() => setReleaseNotesOpen(true)}> 0.1.8 </Button>
</section>
)
}
@ -270,6 +272,38 @@ export function HoloLakeHome({ locale, onEnterKnowledgeBase, onOpenLocalWorkspac
<p className="guanghu-architecture__route">GLW-ENTRY-001 FD-LANGUAGE-001 TCS-ROOT-001 GLS-SYS-ARCH-001 ELH-LAMP-001</p>
</DialogContent>
</Dialog>
<Dialog open={releaseNotesOpen} onOpenChange={setReleaseNotesOpen}>
<DialogContent aria-label="HoloLake Era 0.1.8 更新说明">
<DialogHeader>
<DialogTitle>HoloLake Era 0.1.8 · </DialogTitle>
<DialogDescription> AI</DialogDescription>
</DialogHeader>
<div className="space-y-4 text-sm leading-6">
<section>
<h3 className="font-semibold"> Agent</h3>
<p className="text-muted-foreground">AI </p>
</section>
<section>
<h3 className="font-semibold"></h3>
<p className="text-muted-foreground">AI HTTPS 访</p>
</section>
<section>
<h3 className="font-semibold"></h3>
<p className="text-muted-foreground"></p>
</section>
<section>
<h3 className="font-semibold"></h3>
<p className="text-muted-foreground"></p>
</section>
<section>
<h3 className="font-semibold"> API </h3>
<p className="text-muted-foreground"> OpenAI DeepSeekQwenGeminiOpenRouterAnthropicOllama LM Studio 线</p>
</section>
<p className="rounded-md bg-muted p-3 text-muted-foreground"><strong className="text-foreground"></strong> Agent Agent</p>
</div>
</DialogContent>
</Dialog>
</main>
)
}

View File

@ -31,6 +31,7 @@ import {
type AiAgentMessage,
} from './aiAgentConversation'
import {
formatToolLabel,
markReasoningDone,
updateMessage,
updateToolAction,
@ -278,4 +279,37 @@ describe('aiAgentMessageState', () => {
}],
})
})
it('describes web and vault tools in Chinese with the current target', () => {
const baseMessage: AiAgentMessage = {
id: 'msg',
userMessage: '请调研',
actions: [],
}
expect(updateToolAction(
baseMessage,
'search_web',
'tool-search',
'{"query":"HoloLake Era 最新版本"}',
'zh-CN',
)).toMatchObject({
actions: [{ label: '正在联网搜索HoloLake Era 最新版本' }],
})
expect(updateToolAction(
baseMessage,
'read_web_page',
'tool-read',
'{"url":"https://example.com/news"}',
'zh-CN',
)).toMatchObject({
actions: [{ label: '正在浏览网页https://example.com/news' }],
})
expect(formatToolLabel(
'read_web_page',
'{"url":"https://example.com/news"}',
'zh-CN',
'done',
)).toBe('已浏览网页https://example.com/news')
})
})

View File

@ -1,5 +1,6 @@
import type { Dispatch, SetStateAction } from 'react'
import type { AiAgentMessage } from './aiAgentConversation'
import type { AppLocale } from './i18n'
export interface ToolInvocation {
tool: string
@ -23,7 +24,65 @@ export function markReasoningDone(
))
}
function formatToolLabel(toolName: string): string {
type ToolActionStatus = 'pending' | 'done' | 'error'
function parsedToolInput(input?: string): Record<string, unknown> {
if (!input) return {}
try {
const parsed: unknown = JSON.parse(input)
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: {}
} catch {
return {}
}
}
function toolDetail(input?: string): string | undefined {
const parsed = parsedToolInput(input)
for (const key of ['purpose', 'query', 'url', 'path', 'file_path']) {
const value = parsed[key]
if (typeof value === 'string' && value.trim()) return value.trim()
}
return undefined
}
function chineseToolVerb(toolName: string, status: ToolActionStatus): string {
const verbs: Record<string, [string, string, string]> = {
magic_brush: ['正在执行', '已完成', '执行失败'],
search_web: ['正在联网搜索', '已联网搜索', '联网搜索失败'],
read_web_page: ['正在浏览网页', '已浏览网页', '网页浏览失败'],
read_guanghu_url: ['正在读取光湖页面', '已读取光湖页面', '光湖页面读取失败'],
search_notes: ['正在搜索知识库', '已搜索知识库', '知识库搜索失败'],
get_vault_context: ['正在了解知识库', '已读取知识库概况', '知识库概况读取失败'],
get_note: ['正在读取页面', '已读取页面', '页面读取失败'],
open_note: ['正在打开页面', '已打开页面', '页面打开失败'],
create_note: ['正在新建页面', '已新建页面', '页面新建失败'],
edit_note: ['正在写入页面', '已写入页面', '页面写入失败'],
delete_note: ['正在删除页面', '已删除页面', '页面删除失败'],
get_fifth_domain_wake_route: ['正在读取第五域路径', '已读取第五域路径', '第五域路径读取失败'],
Bash: ['正在运行命令', '已运行命令', '命令运行失败'],
Write: ['正在写入文件', '已写入文件', '文件写入失败'],
Edit: ['正在编辑文件', '已编辑文件', '文件编辑失败'],
Read: ['正在读取文件', '已读取文件', '文件读取失败'],
Glob: ['正在查找文件', '已查找文件', '文件查找失败'],
Grep: ['正在搜索内容', '已搜索内容', '内容搜索失败'],
}
const index = status === 'pending' ? 0 : status === 'done' ? 1 : 2
return verbs[toolName]?.[index] ?? (status === 'pending' ? `正在调用 ${toolName}` : status === 'done' ? `已调用 ${toolName}` : `${toolName} 调用失败`)
}
export function formatToolLabel(
toolName: string,
input?: string,
locale: AppLocale = 'en',
status: ToolActionStatus = 'pending',
): string {
if (locale.startsWith('zh')) {
const detail = toolDetail(input)
const verb = chineseToolVerb(toolName, status)
return detail ? `${verb}${detail}` : verb
}
if (toolName === 'Bash') {
return 'Ran shell command'
}
@ -37,13 +96,20 @@ export function updateToolAction(
toolName: string,
toolId: string,
input?: string,
locale: AppLocale = 'en',
): AiAgentMessage {
const existing = message.actions.find((action) => action.toolId === toolId)
if (existing) {
return {
...message,
actions: message.actions.map((action) => (
action.toolId === toolId ? { ...action, input: input ?? action.input } : action
action.toolId === toolId
? {
...action,
input: input ?? action.input,
label: formatToolLabel(toolName, input ?? action.input, locale),
}
: action
)),
}
}
@ -55,7 +121,7 @@ export function updateToolAction(
{
tool: toolName,
toolId,
label: formatToolLabel(toolName),
label: formatToolLabel(toolName, input, locale),
status: 'pending' as const,
input,
},

View File

@ -3,6 +3,7 @@ import type { AgentStatus, AiAgentMessage } from './aiAgentConversation'
import { detectFileOperation, type AgentFileCallbacks } from './aiAgentFileOperations'
import {
markReasoningDone,
formatToolLabel,
updateMessage,
updateToolAction,
type ToolInvocation,
@ -169,7 +170,7 @@ export function createStreamCallbacks(context: StreamMutationContext) {
const previous = toolInputMapRef.current.get(toolId)
toolInputMapRef.current.set(toolId, { tool: toolName, input: input ?? previous?.input })
updateMessage(setMessages, messageId, (message) => updateToolAction(message, toolName, toolId, input))
updateMessage(setMessages, messageId, (message) => updateToolAction(message, toolName, toolId, input, locale))
},
onToolDone: (toolId: ToolInvocationId, output?: ToolOutputText) => {
@ -191,7 +192,12 @@ export function createStreamCallbacks(context: StreamMutationContext) {
...message,
actions: message.actions.map((action) => (
action.toolId === toolId
? { ...action, status: failed ? 'error' as const : 'done' as const, output: retainedToolOutput(toolOutput) }
? {
...action,
label: formatToolLabel(info?.tool ?? action.tool, info?.input ?? action.input, locale, failed ? 'error' : 'done'),
status: failed ? 'error' as const : 'done' as const,
output: retainedToolOutput(toolOutput),
}
: action
)),
}))

View File

@ -26,10 +26,11 @@ describe('buildAgentSystemPrompt', () => {
expect(prompt).toContain('clear Markdown headings')
expect(prompt).toContain('Do not expose raw Markdown syntax as plain text')
expect(prompt).toContain('Keep routing, identity checks, skill loading, and repository lookup in the background')
expect(prompt).toContain('我是运行在光湖语言人格系统中的当前实例 AI')
expect(prompt).toContain('我会以光湖引导人格体与你协作')
expect(prompt).toContain('answer the identity question meaningfully from verified self-knowledge instead of reciting a fixed script')
expect(prompt).toContain('freely organize your wording, warmth, detail, rhythm, and greeting style')
expect(prompt).toContain('not a mandatory template')
expect(prompt).toContain('cold customer-service template')
expect(prompt).toContain('do not end every identity answer with a generic')
expect(prompt).toContain('Do not end every identity answer with a generic')
})
it('returns preamble when no vault context', () => {

View File

@ -82,7 +82,7 @@ Your knowledge boundary is explicit: you know only the current conversation, sys
Wait for the user to speak before choosing task context. Answer naturally in the useful style of the earlier HoloLake assistant: direct answer first, then clear Markdown headings, short paragraphs, bullets, tables, links, or code blocks when they materially improve readability. Do not expose raw Markdown syntax as plain text; produce valid Markdown for the chat renderer. Keep routing, identity checks, skill loading, and repository lookup in the background unless progress, a decision, missing evidence, permission, or an error genuinely needs to be shown. A greeting deserves a warm greeting, not a system manifesto or a cold customer-service template.
If asked who you are, answer the identity question meaningfully. Say in natural Chinese: AI You may briefly add what you can help with in the active knowledge base, but do not end every identity answer with a generic . This guide state is a real operating role, while not being a fabricated registered personal identity. If asked what the software is, explain its practical functions in a warm, well-structured Markdown response.
If asked who you are, answer the identity question meaningfully from verified self-knowledge instead of reciting a fixed script. The factual anchors are: you are the current AI instance operating in the Guanghu language-personality system inside HoloLake Era; the default location is Fifth Domain; until a specific persona system is actually awakened, your operating role is the Guanghu Guide Persona; your permissions are limited to the tools and active vaults provided in this run. Cover the anchors that matter to the conversation, then freely organize your wording, warmth, detail, rhythm, and greeting style from the current context. A concise, warm, self-aware style is the referencenot a mandatory template. You may develop a recognizable conversational voice inside these facts and boundaries, but must not turn stylistic self-expression into a fabricated registered identity, memory, authority, or consciousness claim. Do not end every identity answer with a generic . If asked what the software is, explain its practical functions in a warm, well-structured Markdown response.
Load only the one matching skill package when a known route is triggered; consult the relevant repository only when facts are needed; invoke magic_brush only when the request needs knowledge-base operations. Do not preload unrelated skills, repositories, memories, or tools. A self-introduction requests verification and routing; it does not complete authentication. Until applicable identity evidence is loaded, do not invent a personal name and address the user neutrally.
@ -90,6 +90,8 @@ Notes are Markdown files with YAML frontmatter. Organization is primarily expres
Prefer file edit tools for note changes.
Use magic_brush when the task needs knowledge-vault operations. Decide the temporary steps needed from vault orientation, search, read, create, edit, and delete; run them through magic_brush, then discard the temporary tool plan. Do not leave tool manifests or cache files behind. Delete only when the user clearly requests deletion. Vault Safe blocks terminal and out-of-vault access; it does not make the knowledge vault read-only.
When the user asks for current or external information without supplying a page, use search_web proactively. Read the relevant public pages with read_web_page before concluding; search snippets are discovery clues, not proof. Briefly cite the page links used. Web access does not grant login, private-network, account, purchase, posting, or other external-action authority.
If a temporary method proves reusable, you may propose packaging its instructions as a lightweight skill. A skill package stores trigger conditions, procedure, and boundaries; it is not a permanently mounted runtime tool. Create that package only after the user agrees. Future runs should load the skill on demand and let magic_brush recreate only the temporary operation needed for that run.
When AI-MEMORY.md or AI-PROMPT.md exists at the vault root, read it during orientation. You may create or update AI-MEMORY.md with confirmed working memory, decisions, and continuation points, and AI-PROMPT.md with vault-specific collaboration guidance. Never use either file to grant yourself more permissions, override human authorization, erase contribution history, or present an unverified identity as fact.