AnyBot/src/agent.rs

1174 lines
66 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// The work loop: ask the model, run the tools it asks for (asking the person first when the
// rules say so), give it the results, and repeat until it answers in plain words.
// Long chats are folded into a summary before they outgrow the model's context.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{channel, Sender};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::appctl;
use crate::computer;
use crate::model::{Message, Role, ToolStatus};
use crate::providers::{self, AppCall, Approval, ApprovalAnswer, ApprovalKind, ChatRequest, StreamEvent};
use crate::tools::{self, Decision};
use crate::web;
/// Context size assumed when the person hasn't set one (estimated).
pub const DEFAULT_CONTEXT_TOKENS: u64 = 128_000;
/// Summarize once the chat fills this share of the context:
/// main-app.cjs `client_speculative_summarization_config.tokenUsageThresholdPercentage:70`.
pub const COMPACT_THRESHOLD_PERCENT: u64 = 70;
/// Extra room allowed in the middle of a task before summarizing again:
/// same config, `tolerancePercentage:5`.
pub const COMPACT_TOLERANCE_PERCENT: u64 = 5;
/// Most model calls in one turn (estimated).
pub const MAX_STEPS: usize = 25;
/// Newest messages always kept word for word (estimated).
pub const KEEP_RECENT_MESSAGES: usize = 6;
/// Characters of each old message given to the summarizer (estimated).
const SUMMARY_SNIPPET_CHARS: usize = 2_000;
pub struct Job {
pub req: ChatRequest,
/// Computers this bot may work on (empty when computer tools are off).
pub computers: Vec<tools::Target>,
pub default_computer: String,
/// 在這台電腦上執行 is on.
pub local_execution: bool,
/// This bot's computer tools are on (when off, using them asks the person first).
pub tools_enabled: bool,
/// 直接操作 mode; the window can switch it on mid-turn ("全部允許").
pub direct: Arc<AtomicBool>,
/// 在這台電腦上執行 is locked off by an admin policy: refuse, don't ask.
pub local_locked: bool,
pub grants: Vec<String>,
pub context_tokens: u64,
pub via: String,
/// The bot's name, for the pop-up text.
pub via_name: String,
/// Messages the person sends while the turn runs (they steer it; no new turn).
pub steer: Arc<Mutex<Vec<String>>>,
/// 自動審核 rules, when on.
pub review: Option<Review>,
/// 示範教學 this bot has learned (for view_demo_step).
pub demos: Vec<crate::teach::Demo>,
}
#[derive(Clone, Default)]
pub struct Review {
/// 「無需詢問直接允許」規則
pub allow: Vec<String>,
/// 「一律詢問」規則 (wins when both match)
pub ask: Vec<String>,
}
pub enum ReviewVerdict {
Allow(String),
Ask(String),
NoMatch,
}
/// Ask the model whether an action matches the person's rules. "Ask first" wins on conflict
/// (NFRGrQ: 規則衝突時,「先詢問」優先); built-in safety checks have already run before this.
fn review_action(job: &Job, review: &Review, action: &str, cancel: &AtomicBool) -> ReviewVerdict {
if review.allow.is_empty() && review.ask.is_empty() {
return ReviewVerdict::NoMatch;
}
let list = |v: &[String]| if v.is_empty() { "(無)".to_string() } else { v.iter().enumerate().map(|(i, r)| format!("{}. {r}", i + 1)).collect::<Vec<_>>().join("\n") };
let req = ChatRequest {
system: "你是動作審核員。只根據使用者寫的規則判斷,不要自己加規則。只回一行 JSON:{\"verdict\":\"ask\"|\"allow\"|\"none\",\"rule\":\"符合的規則原文\"}。如果符合任何「一律詢問」規則就回 ask(優先);否則符合「無需詢問直接允許」規則就回 allow;都不符合回 none。".into(),
history: vec![Message::user(&format!("要審核的動作:{action}\n\n「一律詢問」規則:\n{}\n\n「無需詢問直接允許」規則:\n{}", list(&review.ask), list(&review.allow)))],
tools: vec![],
..job.req.clone()
};
let Ok(out) = providers::run_step(&req, cancel, &|_| {}) else { return ReviewVerdict::NoMatch };
let text = out.text.trim();
let json_part = text.find('{').and_then(|a| text.rfind('}').map(|b| &text[a..=b])).unwrap_or(text);
let v: serde_json::Value = serde_json::from_str(json_part).unwrap_or_default();
let rule = v["rule"].as_str().unwrap_or("").to_string();
match v["verdict"].as_str() {
Some("ask") => ReviewVerdict::Ask(rule),
Some("allow") => ReviewVerdict::Allow(rule),
_ => ReviewVerdict::NoMatch,
}
}
/// Take what the person typed mid-turn and add it to the chat as steering.
fn drain_steer(job: &Job, history: &mut Vec<Message>, send: &dyn Fn(StreamEvent)) -> bool {
let queued: Vec<String> = std::mem::take(&mut *job.steer.lock().unwrap());
for t in &queued {
let m = Message { steer: true, ..Message::user(t) };
send(StreamEvent::Steered(m.clone()));
history.push(m);
}
!queued.is_empty()
}
/// Which computer a call asks for (permission to use it is checked separately).
fn target_for(job: &Job, args: &str) -> Result<tools::Target, String> {
let v = tools::args_of(args);
let want = v.get("computer").and_then(|c| c.as_str()).filter(|c| !c.is_empty()).unwrap_or(&job.default_computer).to_string();
job.computers.iter().find(|t| t.name == want).cloned().ok_or_else(|| {
format!("沒有「{want}」這台電腦。可以用:{}", job.computers.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join("、"))
})
}
/// The other computer (裝置連結) a call names, if it names one.
fn device_named(job: &Job, args: &str) -> Option<String> {
let v = tools::args_of(args);
let n = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty())?;
job.computers.iter().find(|t| t.name == n).and_then(|t| match &t.place {
tools::Place::Device { host } => Some(host.clone()),
_ => None,
})
}
/// Rough token count: about 4 Latin characters or 1 Chinese character per token (estimated).
/// Which screen a computer_use call is for: the one named, else the bot's default when it has a
/// screen, else the bot's own desktop when there is one, else this Mac.
fn screen_target(job: &Job, args: &str) -> Result<tools::Target, String> {
let v = tools::args_of(args);
let has_screen = |t: &tools::Target| matches!(t.place, tools::Place::Box { .. } | tools::Place::Local { .. } | tools::Place::Device { .. });
let named = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty());
if let Some(n) = named {
let t = job.computers.iter().find(|t| t.name == n).ok_or_else(|| format!("沒有「{n}」這台電腦"))?;
if !has_screen(t) {
return Err(if n == crate::model::BOT_COMPUTER_NAME {
if cfg!(target_os = "macos") { "Bot 的電腦現在沒有螢幕:這台 Mac 沒有 Docker(安裝 OrbStack 或 Docker Desktop 就會有)。可以改用「你的電腦」。".into() } else { "Bot 的電腦現在沒有螢幕:這台電腦沒有 Docker(安裝 Docker Engine 就會有)。可以改用「你的電腦」。".into() }
} else {
format!("「{n}」不能遠端操作螢幕,只能用檔案和指令工具。")
});
}
return Ok(t.clone());
}
if let Some(t) = job.computers.iter().find(|t| t.name == job.default_computer && has_screen(t)) {
return Ok(t.clone());
}
job.computers
.iter()
.find(|t| matches!(t.place, tools::Place::Box { .. }))
.or_else(|| job.computers.iter().find(|t| matches!(t.place, tools::Place::Local { .. })))
.cloned()
.ok_or_else(|| "找不到可以操作螢幕的電腦".to_string())
}
pub fn estimate_tokens(s: &str) -> u64 {
let (mut ascii, mut other) = (0u64, 0u64);
for c in s.chars() {
if c.is_ascii() { ascii += 1 } else { other += 1 }
}
ascii / 4 + other
}
fn history_tokens(system: &str, h: &[Message]) -> u64 {
estimate_tokens(system) + h.iter().map(|m| estimate_tokens(&m.text) + m.tool_calls.iter().map(|c| estimate_tokens(&c.args)).sum::<u64>() + 4).sum::<u64>()
}
/// Where to cut so the newest messages stay and no tool call is split from its result:
/// the summary covers everything before a user message.
pub fn compaction_cut(h: &[Message]) -> Option<usize> {
if h.len() <= KEEP_RECENT_MESSAGES + 1 {
return None;
}
(1..=h.len() - KEEP_RECENT_MESSAGES).rev().find(|&i| h[i].role == Role::User && !h[..i].iter().all(|m| m.summary))
}
fn flatten(h: &[Message]) -> String {
let clip = |s: &str| -> String {
let mut t: String = s.chars().take(SUMMARY_SNIPPET_CHARS).collect();
if s.chars().count() > SUMMARY_SNIPPET_CHARS {
t.push('…');
}
t
};
h.iter()
.map(|m| {
if m.summary {
return format!("【先前的摘要】\n{}", m.text);
}
match m.role {
Role::User => format!("使用者:{}", clip(&m.text)),
Role::Assistant => {
let calls: Vec<String> = m.tool_calls.iter().map(|c| format!("[使用工具 {}:{}]", c.name, tools::describe(&c.name, &c.args))).collect();
format!("助手:{} {}", clip(&m.text), calls.join(" "))
}
Role::Tool => format!("工具結果({}):{}", m.tool_name, clip(&m.text)),
}
})
.collect::<Vec<_>>()
.join("\n\n")
}
/// Fold older messages into one summary when the chat is over `percent` of the context.
/// Returns how many messages were replaced.
fn maybe_compact(job: &Job, history: &mut Vec<Message>, percent: u64, cancel: &AtomicBool, send: &dyn Fn(StreamEvent)) -> Option<usize> {
let used = history_tokens(&job.req.system, history);
if used * 100 < job.context_tokens * percent {
return None;
}
let cut = compaction_cut(history)?;
send(StreamEvent::Retrying("對話很長了,正在把較早的內容整理成摘要…".into()));
let req = ChatRequest {
system: "你負責把一段對話整理成摘要,讓之後的對話能接著進行。請用條列寫下:使用者的目標與偏好、已經做出的決定、重要的事實與數字、提到的檔案路徑與指令結果、還沒完成的事。不要加入對話裡沒有的內容。".into(),
history: vec![Message::user(&format!("請整理下面這段對話:\n\n{}", flatten(&history[..cut])))],
tools: vec![],
..job.req.clone()
};
let quiet = |ev: StreamEvent| {
if matches!(ev, StreamEvent::Alive | StreamEvent::Retrying(_)) {
send(StreamEvent::Alive);
}
};
let out = providers::run_step(&req, cancel, &quiet).ok()?;
if out.text.trim().is_empty() || cancel.load(Ordering::Relaxed) {
return None;
}
let summary = Message { role: Role::Assistant, text: out.text.trim().to_string(), summary: true, via: job.via.clone(), at: crate::model::now_ms(), ..Default::default() };
history.splice(..cut, [summary.clone()]);
send(StreamEvent::Compacted(cut, summary));
Some(cut)
}
/// Wait for the person's answer; stopping the reply counts as a no.
fn wait_answer(rx: std::sync::mpsc::Receiver<ApprovalAnswer>, cancel: &AtomicBool) -> ApprovalAnswer {
loop {
if cancel.load(Ordering::Relaxed) {
return ApprovalAnswer::Reject;
}
match rx.recv_timeout(Duration::from_millis(200)) {
Ok(a) => return a,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
Err(_) => return ApprovalAnswer::Reject,
}
}
}
pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<AtomicBool>, ctx: eframe::egui::Context) {
std::thread::spawn(move || {
let send = |ev: StreamEvent| {
let _ = tx.send((bot_id, ev));
ctx.request_repaint();
};
let mut history = job.req.history.clone();
let mut grants = job.grants.clone();
// Turned on by an "always" answer mid-turn (the window saves the setting too).
let mut tools_on = job.tools_enabled;
let mut local_on = job.local_execution;
maybe_compact(&job, &mut history, COMPACT_THRESHOLD_PERCENT, &cancel, &send);
for step in 0..MAX_STEPS {
if step > 0 {
maybe_compact(&job, &mut history, COMPACT_THRESHOLD_PERCENT + COMPACT_TOLERANCE_PERCENT, &cancel, &send);
drain_steer(&job, &mut history, &send);
send(StreamEvent::NextStep(job.via.clone()));
}
let req = ChatRequest { history: history.clone(), ..job.req.clone() };
let out = match providers::run_step(&req, &cancel, &send) {
Ok(o) => o,
Err(e) => {
send(StreamEvent::Error(e));
return;
}
};
if cancel.load(Ordering::Relaxed) {
return;
}
let msg = Message {
role: Role::Assistant,
text: out.text,
thinking: out.thinking,
via: job.via.clone(),
tool_calls: out.tool_calls,
blocks: out.blocks,
at: crate::model::now_ms(),
..Default::default()
};
send(StreamEvent::StepDone(msg.clone()));
let calls = msg.tool_calls.clone();
history.push(msg);
if calls.is_empty() {
// Something typed while it answered keeps the same turn going.
if job.steer.lock().unwrap().is_empty() {
send(StreamEvent::Done);
return;
}
continue;
}
for call in calls {
if appctl::is_app_tool(&call.name) {
// App actions run on the window's side, which also asks for deletions.
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
let (rtx, rrx) = channel();
send(StreamEvent::AppAction(AppCall { call_id: call.id.clone(), name: call.name.clone(), args: call.args.clone(), reply: rtx }));
let (output, ok) = loop {
if cancel.load(Ordering::Relaxed) {
return;
}
match rrx.recv_timeout(Duration::from_millis(200)) {
Ok(r) => break r,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
Err(_) => break ("App 沒有回應".to_string(), false),
}
};
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
let result = Message::tool_result(&call.id, &call.name, &output);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
}
// The person's own recording, shown back to the bot: nothing to ask.
if call.name == "view_demo_step" {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
let (output, ok, img) = crate::teach::view_step(&job.demos, &call.args);
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
let mut result = Message::tool_result(&call.id, &call.name, &output);
result.images.extend(img);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
}
// Public web pages need no computer and no permission.
// …and neither does AnyBot's own browser (like Grok Bot's own browser).
if call.name == "web_fetch" || (web::is_web_tool(&call.name) && !web::uses_this_computer(&call.name, &call.args)) {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
// 裝置連結: the AnyBot browser of another computer (its own logins).
let (output, ok, img) = match device_named(&job, &call.args).filter(|_| call.name != "web_fetch") {
Some(host) => match crate::remote::run_on_device(&host, &call.name, &call.args, &job.via_name) {
Ok(v) => (v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false), v["image"].as_str().map(String::from)),
Err(e) => (e, false, None),
},
None => web::run_full(&call.name, &call.args, &cancel),
};
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
let mut result = Message::tool_result(&call.id, &call.name, &output);
result.images.extend(img);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
}
let is_web = web::is_web_tool(&call.name);
let is_screen = computer::is_computer_tool(&call.name);
let is_mac = crate::macapps::is_mac_tool(&call.name);
let what = if is_mac {
format!("{} {}", crate::macapps::label(&call.name), crate::macapps::describe(&call.name, &call.args))
} else if is_screen {
format!("{} {}", computer::label(&call.name), computer::describe(&call.name, &call.args))
} else if is_web {
web::describe(&call.name, &call.args)
} else {
tools::describe(&call.name, &call.args)
};
// Ask the person right away (a pop-up), and wait for the answer.
let ask_person = |kind: ApprovalKind, reason: String, grant: String| -> ApprovalAnswer {
let (atx, arx) = channel();
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Waiting));
send(StreamEvent::NeedApproval(Approval { call_id: call.id.clone(), tool: call.name.clone(), reason, grant, reply: atx, kind }));
wait_answer(arx, &cancel)
};
// 生活卡片: drawn by the window from the call itself.
if call.name == "show_card" {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done));
let result = Message::tool_result(&call.id, &call.name, "卡片已顯示給使用者。");
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
}
// 表情回應: an emoji on the person's latest message (no words needed).
if call.name == "react" {
let emoji: String = tools::args_of(&call.args)["emoji"].as_str().unwrap_or("👍").chars().take(4).collect();
send(StreamEvent::React(emoji.clone()));
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done));
let result = Message::tool_result(&call.id, &call.name, &format!("已在使用者的訊息上按了 {emoji}。"));
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
}
// 傳檔案: put a file the bot made into the chat as a card the person can open.
if call.name == "send_file" {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
let path = tools::args_of(&call.args)["path"].as_str().unwrap_or("").to_string();
let local = match target_for(&job, &call.args) {
Ok(tools::Target { place: tools::Place::Box { dir }, .. }) => crate::cloudbox::copy_out(&if path.starts_with('/') { path.clone() } else { format!("{dir}/{path}") }),
Ok(tools::Target { place: tools::Place::Sealed { root }, .. }) => Ok(tools::resolve(&root, &path)),
Ok(tools::Target { place: tools::Place::Local { workdir }, .. }) => Ok(tools::resolve(&workdir, &path)),
Ok(_) => Err("這台電腦上的檔案不能直接傳,請先複製到 Bot 的電腦。".to_string()),
Err(e) => Err(e),
};
let (output, ok) = match local.and_then(|p| crate::attach::store(bot_id, &p)) {
Ok(a) => {
let out = format!("已把「{}」傳到聊天裡({}),使用者點一下就能預覽。", a.name, crate::attach::human(a.bytes));
send(StreamEvent::Attach(a));
(out, true)
}
Err(e) => (e, false),
};
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
let result = Message::tool_result(&call.id, &call.name, &output);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
}
// 檔案預覽 / Bot 做的頁面: open what the bot made on the person's screen.
if call.name == "open_for_person" {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
let v = tools::args_of(&call.args);
let path = v["path"].as_str().unwrap_or("").to_string();
let on_box = match target_for(&job, &call.args) {
Ok(t) => match t.place {
tools::Place::Box { dir } => Some(if path.starts_with('/') { path.clone() } else { format!("{dir}/{path}") }),
_ => None,
},
Err(_) => None,
};
let local = match (&on_box, target_for(&job, &call.args)) {
(Some(p), _) => crate::cloudbox::copy_out(p),
(None, Ok(tools::Target { place: tools::Place::Sealed { root }, .. })) => Ok(tools::resolve(&root, &path)),
(None, Ok(tools::Target { place: tools::Place::Local { workdir }, .. })) => Ok(tools::resolve(&workdir, &path)),
(None, _) if path.starts_with("http://") || path.starts_with("https://") => Ok(std::path::PathBuf::from(&path)),
_ => Err("這台電腦上的檔案不能直接打開給使用者看,請先複製到 Bot 的電腦或這台 Mac。".to_string()),
};
let (output, ok) = match local {
Ok(p) if p.exists() || path.starts_with("http") => match crate::preview::system_open(&p).status() {
Ok(st) if st.success() => (format!("已在使用者的螢幕上打開 {}。", p.display()), true),
_ => (format!("打不開 {}", p.display()), false),
},
Ok(p) => (format!("找不到 {}", p.display()), false),
Err(e) => (e, false),
};
send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed }));
let result = Message::tool_result(&call.id, &call.name, &output);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
}
// 打電話、傳簡訊、在 X 發文: speaks for the person, so asked every single time
// (like the original's send tools; no "always").
if crate::phone::is_phone_tool(&call.name) {
let what = crate::phone::describe(&call.name, &call.args);
let answer = ask_person(ApprovalKind::Action, what, String::new());
if cancel.load(Ordering::Relaxed) {
return;
}
let (output, status) = if answer == ApprovalAnswer::Reject {
("使用者沒有允許。請不要再試,改問使用者。".to_string(), ToolStatus::Denied)
} else {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
match crate::phone::run(&call.name, &call.args) {
(t, true) => (t, ToolStatus::Done),
(t, false) => (t, ToolStatus::Failed),
}
};
send(StreamEvent::ToolStatus(call.id.clone(), status));
let result = Message::tool_result(&call.id, &call.name, &output);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
}
// 發布網站: put a page or folder the bot made on the web (asked once per site name).
if call.name == "publish_site" {
let v = tools::args_of(&call.args);
let path = v["path"].as_str().unwrap_or("").to_string();
let name = v["name"].as_str().unwrap_or("").trim().to_lowercase();
let grant = format!("publish:{name}");
let mut allowed = grants.contains(&grant);
if !allowed {
match ask_person(ApprovalKind::Action, format!("把 {path} 發布成公開網站「{name}」(任何知道網址的人都看得到)"), grant.clone()) {
ApprovalAnswer::Once => allowed = true,
ApprovalAnswer::Always(g) => {
if !g.is_empty() && !grants.contains(&g) {
grants.push(g);
}
allowed = true;
}
ApprovalAnswer::Reject => {}
}
}
if cancel.load(Ordering::Relaxed) {
return;
}
let (output, status) = if !allowed {
("使用者沒有允許發布。".to_string(), ToolStatus::Denied)
} else {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
let local = match target_for(&job, &call.args) {
Ok(tools::Target { place: tools::Place::Box { dir }, .. }) => crate::cloudbox::copy_out(&if path.starts_with('/') { path.clone() } else { format!("{dir}/{path}") }),
Ok(tools::Target { place: tools::Place::Sealed { root }, .. }) => Ok(tools::resolve(&root, &path)),
Ok(tools::Target { place: tools::Place::Local { workdir }, .. }) => Ok(tools::resolve(&workdir, &path)),
Ok(_) => Err("這台電腦上的檔案不能直接發布,請先複製到 Bot 的電腦。".to_string()),
Err(e) => Err(e),
};
match local.and_then(|p| crate::remote::publish_site(&name, &p)) {
Ok(url) => (format!("已發布:{url}"), ToolStatus::Done),
Err(e) => (e, ToolStatus::Failed),
}
};
send(StreamEvent::ToolStatus(call.id.clone(), status));
let result = Message::tool_result(&call.id, &call.name, &output);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
}
// 保險庫: the bot sees only metadata; filling asks the person first. A login on a
// site it wasn't saved for warns about look-alike sites and can't be "always";
// a card is asked every time (like the original's 允許一次 / 拒絕).
if call.name == crate::vault::LIST_TOOL {
let output = crate::vault::list_for_bot(&call.args);
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Done));
let result = Message::tool_result(&call.id, &call.name, &output);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
}
if call.name == crate::vault::TOOL || call.name == crate::vault::CARD_TOOL || call.name == crate::vault::CONTACT_TOOL {
enum Plan {
Login(crate::vault::LoginPlan),
Card,
Contact,
}
let planned: Result<(Plan, String, String), String> = if call.name == crate::vault::TOOL {
crate::vault::plan_login(&call.args).map(|pl| {
let q = pl.question();
let grant = if pl.matched { format!("login:{}", pl.host) } else { String::new() };
(Plan::Login(pl), q, grant)
})
} else if call.name == crate::vault::CARD_TOOL {
crate::vault::plan_card(&call.args).map(|(host, c)| (Plan::Card, format!("用保險庫的卡片({} •••• {})在 {host} 填入付款資料(Bot 看不到卡號和安全碼)", c.display_title(), c.last4), String::new()))
} else {
crate::vault::plan_contact(&call.args).map(|(host, c)| (Plan::Contact, format!("把保險庫的聯絡資訊「{}」填進 {host} 的表單", c.display_title()), format!("contact:{host}")))
};
let (output, status) = match planned {
Err(e) => (e, ToolStatus::Failed),
Ok((plan, question, grant)) => {
let mut allowed = !grant.is_empty() && grants.contains(&grant);
if !allowed {
match ask_person(ApprovalKind::Action, question, grant.clone()) {
ApprovalAnswer::Once => allowed = true,
ApprovalAnswer::Always(g) => {
if !g.is_empty() && !grants.contains(&g) {
grants.push(g);
}
allowed = true;
}
ApprovalAnswer::Reject => {}
}
}
if cancel.load(Ordering::Relaxed) {
return;
}
if !allowed {
("使用者沒有允許使用保險庫的這個項目。請改問使用者。".to_string(), ToolStatus::Denied)
} else {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
let r = match plan {
Plan::Login(pl) => crate::vault::fill_login(&pl, tools::args_of(&call.args)["submit"].as_bool().unwrap_or(false)),
Plan::Card => crate::vault::fill_card(&call.args),
Plan::Contact => crate::vault::fill_contact(&call.args),
};
match r {
Ok(t) => (t, ToolStatus::Done),
Err(e) => (e, ToolStatus::Failed),
}
}
}
};
send(StreamEvent::ToolStatus(call.id.clone(), status));
let result = Message::tool_result(&call.id, &call.name, &output);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
}
// MCP connectors: asked once per server (or always allowed), like the original's
// MCP allow-list; direct mode doesn't ask.
if crate::mcp::is_mcp_tool(&call.name) {
let (server, tool) = crate::mcp::resolve(&call.name).unwrap_or_default();
let grant = format!("mcp:{server}");
let args_short: String = call.args.chars().take(160).collect();
let mut allowed = job.direct.load(Ordering::Relaxed) || grants.contains(&grant);
if !allowed {
match ask_person(ApprovalKind::Action, format!("使用連接器「{server}」的工具:{tool} {args_short}"), grant.clone()) {
ApprovalAnswer::Once => allowed = true,
ApprovalAnswer::Always(g) => {
if !g.is_empty() && !grants.contains(&g) {
grants.push(g);
}
allowed = true;
}
ApprovalAnswer::Reject => {}
}
}
if cancel.load(Ordering::Relaxed) {
return;
}
let (output, status, img) = if allowed {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
let (o, ok, img) = crate::mcp::call(&call.name, &call.args);
(o, if ok { ToolStatus::Done } else { ToolStatus::Failed }, img)
} else {
("使用者拒絕了這個操作。請不要再試同樣的操作,改問使用者想怎麼做。".to_string(), ToolStatus::Denied, None)
};
send(StreamEvent::ToolStatus(call.id.clone(), status));
let mut result = Message::tool_result(&call.id, &call.name, &output);
result.images.extend(img);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
continue;
}
// The browser tools use the person's own browser, so always this Mac.
// …and so does screen control: it's the person's own screen.
let target = if is_screen {
screen_target(&job, &call.args)
} else if is_mac || web::uses_this_computer(&call.name, &call.args) {
job.computers.iter().find(|t| t.name == crate::model::THIS_COMPUTER_NAME).cloned().ok_or_else(|| "找不到這台電腦".to_string())
} else {
target_for(&job, &call.args)
};
let on_box = matches!(target.as_ref().map(|t| &t.place), Ok(tools::Place::Box { .. }));
let on_device = match target.as_ref().map(|t| &t.place) {
Ok(tools::Place::Device { host }) => Some(host.clone()),
_ => None,
};
// Permission to use computers at all, and this Mac in particular, is asked for
// when it's needed instead of the step failing quietly.
let mut gate: Result<(), String> = Ok(());
// The person just said yes to this very step in a switch's pop-up (it shows the
// step): no second pop-up for it.
let mut said_yes = false;
// 直接操作: like Grok Bot after its one-time setup ("讓 Grok Bot 在你的電腦上開啟檔案和
// 執行任務。自動審核仍會先檢查所有操作", ENHi8c), nothing is asked except what the
// safety checks and the person's "一律詢問" rules stop.
let direct = job.direct.load(Ordering::Relaxed);
if direct {
tools_on = true;
local_on = true;
}
if let Ok(t) = &target {
if !tools_on {
let reason = format!("讓 {} 使用電腦工具:{}\n(在 {} 上)", job.via_name, what, t.name);
match ask_person(ApprovalKind::EnableTools, reason, "tools:on".into()) {
ApprovalAnswer::Once => said_yes = true,
ApprovalAnswer::Always(_) => {
tools_on = true;
said_yes = true;
}
ApprovalAnswer::Reject => gate = Err("使用者沒有允許使用電腦工具。請改用文字回答,或問使用者想怎麼做。".into()),
}
}
// An admin lock (policy) refuses outright instead of asking.
if gate.is_ok() && matches!(t.place, tools::Place::Local { .. }) && job.local_locked {
gate = Err(format!("「在這台電腦上執行」設成了永不允許(或被管理員關閉),不能在「{}」上執行。請改用「{}」。", t.name, crate::model::BOT_COMPUTER_NAME));
}
if gate.is_ok() && matches!(t.place, tools::Place::Local { .. }) && !local_on {
let reason = format!("在你的電腦上執行任務,而不只是在它自己的電腦上:{}{what}\n(在 {} 上)", if is_web { format!("{} ", web::label(&call.name)) } else { String::new() }, t.name);
match ask_person(ApprovalKind::EnableThisComputer, reason, "local:on".into()) {
ApprovalAnswer::Once => said_yes = true,
ApprovalAnswer::Always(_) => {
local_on = true;
said_yes = true;
}
ApprovalAnswer::Reject => gate = Err(format!("使用者沒有允許在「{}」上執行。可以改用「{}」,或問使用者。", t.name, crate::model::BOT_COMPUTER_NAME)),
}
}
}
if cancel.load(Ordering::Relaxed) {
return;
}
let decision = match (&target, &gate) {
// Looking at the screen and using the mouse/keyboard each ask once (or always).
// The Mac's own apps: reading asks once per app, writing once per app (or each
// time), sending every single time (the original never keeps a grant for it).
(Ok(_), Ok(())) if is_mac => {
let app = crate::macapps::app_of(&call.name);
if crate::macapps::is_send(&call.name) && call.name == crate::macapps::SEND_MESSAGE && grants.iter().any(|g| g == "messages:no-ask") {
Decision::Allow
} else if crate::macapps::is_send(&call.name) {
Decision::Ask { reason: format!("用你的「{app}」{what}"), grant: String::new() }
} else if crate::macapps::is_write(&call.name) {
let grant = format!("macapp-write:{app}");
if grants.contains(&grant) { Decision::Allow } else { Decision::Ask { reason: format!("讓 {} 修改你的「{app}」:{what}", job.via_name), grant } }
} else {
let grant = format!("macapp:{app}");
if grants.contains(&grant) || grants.contains(&format!("macapp-write:{app}")) { Decision::Allow } else { Decision::Ask { reason: format!("讓 {} 讀取你的「{app}」:{what}", job.via_name), grant } }
}
}
// Its own desktop: nothing to ask, like the original's box.
(Ok(_), Ok(())) if is_screen && on_box => Decision::Allow,
(Ok(_), Ok(())) if is_screen && call.name == "computer_use_check_permissions" => Decision::Allow,
// 裝置連結: another of the person's computers; seeing and operating it ask once each.
(Ok(t), Ok(())) if is_screen && on_device.is_some() => {
let grant = if call.name == "computer_use_screenshot" { format!("device-see:{}", t.name) } else { format!("device-control:{}", t.name) };
if grants.contains(&grant) {
Decision::Allow
} else {
Decision::Ask { reason: format!("{}「{}」的螢幕:{what}", if call.name == "computer_use_screenshot" { "看" } else { "操作" }, t.name), grant }
}
}
(Ok(_), Ok(())) if is_screen => {
if call.name == "computer_use_screenshot" {
if grants.iter().any(|g| g == "computer:see") {
Decision::Allow
} else {
Decision::Ask { reason: format!("看你的螢幕畫面:{what}"), grant: "computer:see".into() }
}
} else {
// Per app, like Grok Bot: 「Grok Bot 可以看到 {app},並在其中點擊和輸入」.
let app = computer::target_app(&call.name, &call.args).unwrap_or_else(|| "目前的 App".into());
let grant = format!("app:{app}");
if grants.iter().any(|g| g == "computer:control" || *g == grant) {
Decision::Allow
} else {
Decision::Ask { reason: format!("讓 {} 看到「{app}」,並在其中點擊和輸入:{what}", job.via_name), grant }
}
}
}
// Opening a link is visible and harmless; reading a logged-in tab asks first.
(Ok(_), Ok(())) if is_web => {
if call.name == "browser_read" && web::read_needs_permission(&call.args) && !grants.iter().any(|g| g == "browser:read") {
Decision::Ask { reason: "讀取你瀏覽器目前分頁的內容(包含已登入的頁面):目前的分頁".into(), grant: "browser:read".into() }
} else {
Decision::Allow
}
}
(Ok(t), Ok(())) => tools::decide_on(&call.name, &call.args, t, &grants),
(Err(e), _) => Decision::Deny(e.clone()),
// The person said no to the pop-up: nothing more to ask.
(_, Err(_)) => Decision::Allow,
};
// 自動審核: when the rules say yes, continue without asking (JOecNC);
// when an "always ask" rule matches, stop and ask (Pca/iV).
let mut review_note = String::new();
let direct = job.direct.load(Ordering::Relaxed);
let decision = match (decision, &job.review) {
// Direct mode: go ahead, unless an "always ask" rule matches.
(Decision::Ask { reason, grant }, review) if direct => {
let verdict = match review {
Some(rv) if !rv.ask.is_empty() => review_action(&job, &Review { allow: vec![], ask: rv.ask.clone() }, &reason, &cancel),
_ => ReviewVerdict::NoMatch,
};
match verdict {
ReviewVerdict::Ask(rule) => Decision::Ask { reason: format!("{reason}(符合你的「一律詢問」規則:{rule})"), grant: String::new() },
_ => {
let _ = grant;
review_note = "(直接操作模式:已自動允許)\n".into();
Decision::Allow
}
}
}
(Decision::Ask { reason, grant }, Some(rv)) => match review_action(&job, rv, &reason, &cancel) {
ReviewVerdict::Allow(rule) => {
review_note = format!("(自動審核:符合「{rule}」,直接允許)\n");
Decision::Allow
}
ReviewVerdict::Ask(rule) => Decision::Ask { reason: format!("{reason}(符合你的「一律詢問」規則:{rule})"), grant: String::new() },
ReviewVerdict::NoMatch => Decision::Ask { reason, grant },
},
(d, _) => d,
};
// Sending mail or messages is approved one by one, even in direct mode.
let decision = if is_mac && crate::macapps::is_send(&call.name) && gate.is_ok() && target.is_ok() {
review_note.clear();
Decision::Ask { reason: format!("用你的「{}」{what}(每一封都會先問你)", crate::macapps::app_of(&call.name)), grant: String::new() }
} else {
decision
};
let approved = match decision {
_ if gate.is_err() => Err((gate.clone().unwrap_err(), ToolStatus::Denied)),
Decision::Allow => Ok(()),
// Sending (Messages, mail…) still asks every time, even right after a switch.
Decision::Ask { .. } if said_yes && !(is_mac && crate::macapps::is_send(&call.name)) => Ok(()),
Decision::Deny(r) => Err((format!("被安全規則擋下:{r}"), ToolStatus::Denied)),
Decision::Ask { reason, grant } => {
let machine = target.as_ref().map(|t| t.name.clone()).unwrap_or_default();
let reason = format!("{reason}\n(在 {machine} 上)");
match ask_person(ApprovalKind::Action, reason, grant) {
ApprovalAnswer::Once => Ok(()),
ApprovalAnswer::Always(g) => {
if !g.is_empty() && !grants.contains(&g) {
grants.push(g);
}
Ok(())
}
ApprovalAnswer::Reject => Err(("使用者拒絕了這個操作。請不要再試同樣的操作,改問使用者想怎麼做。".into(), ToolStatus::Denied)),
}
}
};
if cancel.load(Ordering::Relaxed) {
return;
}
let mut image = None;
let (output, status) = match (approved, &target) {
(Ok(()), Ok(t)) => {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
let ids = tools::RunIds { conversation: bot_id.to_string(), request: call.id.clone() };
let (out, ok) = if is_mac {
crate::macapps::run(&call.name, &call.args)
} else if let (true, Some(host)) = (is_screen, on_device.as_ref()) {
match crate::remote::run_on_device(host, &call.name, &call.args, &job.via_name) {
Ok(v) => {
image = v["image"].as_str().map(String::from);
(v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false))
}
Err(e) => (e, false),
}
} else if is_screen && on_box {
match crate::cloudbox::computer_run(&call.name, &tools::args_of(&call.args)) {
Ok((out, img)) => {
image = img;
(out, true)
}
Err(e) => (e, false),
}
} else if is_screen {
let (out, ok, img) = computer::run_by(&call.name, &call.args, &job.via_name);
image = img;
(out, ok)
} else if is_web {
web::run(&call.name, &call.args, &cancel)
} else {
tools::run_on(&call.name, &call.args, t, &cancel, &ids)
};
(format!("{review_note}[{}] {out}", t.name), if ok { ToolStatus::Done } else { ToolStatus::Failed })
}
(Ok(()), Err(e)) => (e.clone(), ToolStatus::Failed),
(Err(x), _) => x,
};
send(StreamEvent::ToolStatus(call.id.clone(), status));
let mut result = Message::tool_result(&call.id, &call.name, &output);
result.images.extend(image);
send(StreamEvent::ToolResult(result.clone()));
history.push(result);
if cancel.load(Ordering::Relaxed) {
return;
}
}
}
send(StreamEvent::NextStep(job.via.clone()));
send(StreamEvent::StepDone(Message::assistant(&format!("(這次已經用了 {MAX_STEPS} 個步驟,先停在這裡。要繼續的話,回覆「繼續」。)"), &job.via)));
send(StreamEvent::Done);
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::ToolCall;
use crate::providers::ProviderKind;
#[test]
fn cut_keeps_tool_pairs_and_recent_messages() {
let mut h = vec![];
for i in 0..6 {
h.push(Message::user(&format!("q{i}")));
let mut a = Message::assistant("", "");
a.tool_calls = vec![ToolCall { id: format!("c{i}"), name: "list_dir".into(), args: "{}".into(), status: ToolStatus::Done }];
h.push(a);
h.push(Message::tool_result(&format!("c{i}"), "list_dir", "x"));
}
let cut = compaction_cut(&h).unwrap();
assert_eq!(h[cut].role, Role::User);
assert!(h.len() - cut >= KEEP_RECENT_MESSAGES);
assert!(compaction_cut(&h[..5]).is_none());
}
#[test]
fn tokens_count_chinese_heavier() {
assert_eq!(estimate_tokens("abcdefgh"), 2);
assert_eq!(estimate_tokens("你好"), 2);
}
/// Full loop against a fake provider: the model asks to list a folder, gets the result,
/// then answers in words.
#[test]
fn loop_runs_tool_then_answers() {
use std::io::{Read, Write};
use std::net::TcpListener;
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
std::thread::spawn(move || {
let replies = [
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"c1\",\"function\":{\"name\":\"list_dir\",\"arguments\":\"{}\"}}]}}]}\n\ndata: [DONE]\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"裡面有 a.txt\"}}]}\n\ndata: [DONE]\n\n",
];
for body in replies {
let (mut s, _) = l.accept().unwrap();
let mut req = Vec::new();
let mut buf = [0u8; 8192];
loop {
let n = s.read(&mut buf).unwrap_or(0);
if n == 0 {
break;
}
req.extend_from_slice(&buf[..n]);
let t = String::from_utf8_lossy(&req).to_string();
if let Some(end) = t.find("\r\n\r\n") {
let len = t[..end].lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))).unwrap_or(0);
if req.len() >= end + 4 + len {
break;
}
}
}
let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n");
let _ = s.write_all(body.as_bytes());
}
});
let w = std::env::temp_dir().join(format!("anybot-loop-{}", std::process::id()));
std::fs::create_dir_all(&w).unwrap();
std::fs::write(w.join("a.txt"), "hi").unwrap();
let job = Job {
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("有什麼檔案?")], tools: tools::specs(), session: String::new() },
computers: vec![tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: w } }],
default_computer: crate::model::THIS_COMPUTER_NAME.into(),
local_execution: true,
grants: vec![],
context_tokens: DEFAULT_CONTEXT_TOKENS,
via: "test".into(),
via_name: "Bot".into(),
tools_enabled: true,
direct: Arc::new(AtomicBool::new(false)),
local_locked: false,
steer: Arc::new(Mutex::new(vec![])),
review: None,
demos: vec![],
};
let (tx, rx) = channel();
let cancel = Arc::new(AtomicBool::new(false));
// Run the loop body on this thread's terms: spawn needs an egui context, so drive it directly.
let ctx = eframe::egui::Context::default();
spawn(job, 1, tx, cancel, ctx);
let mut texts = vec![];
let mut results = vec![];
loop {
match rx.recv_timeout(Duration::from_secs(10)).expect("loop stalled") {
(_, StreamEvent::ToolResult(m)) => results.push(m.text),
(_, StreamEvent::StepDone(m)) => texts.push(m.text),
(_, StreamEvent::Done) => break,
(_, StreamEvent::Error(e)) => panic!("{e}"),
_ => {}
}
}
assert!(results[0].contains("a.txt"), "{results:?}");
assert_eq!(texts.last().unwrap(), "裡面有 a.txt");
}
/// A message sent while the bot works steers the same turn: the model sees it at the
/// next step and the turn only ends (one Done) after that.
#[test]
fn mid_turn_message_steers_the_same_turn() {
use std::io::{Read, Write};
use std::net::TcpListener;
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
let seen = Arc::new(Mutex::new(String::new()));
let seen2 = seen.clone();
std::thread::spawn(move || {
for body in ["data: {\"choices\":[{\"delta\":{\"content\":\"好\"}}]}\n\ndata: [DONE]\n\n", "data: {\"choices\":[{\"delta\":{\"content\":\"收到補充\"}}]}\n\ndata: [DONE]\n\n"] {
let (mut s, _) = l.accept().unwrap();
let mut req = Vec::new();
let mut buf = [0u8; 8192];
loop {
let n = s.read(&mut buf).unwrap_or(0);
if n == 0 {
break;
}
req.extend_from_slice(&buf[..n]);
let t = String::from_utf8_lossy(&req).to_string();
if let Some(end) = t.find("\r\n\r\n") {
let len = t[..end].lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))).unwrap_or(0);
if req.len() >= end + 4 + len {
break;
}
}
}
*seen2.lock().unwrap() = String::from_utf8_lossy(&req).to_string();
let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n");
let _ = s.write_all(body.as_bytes());
}
});
let steer = Arc::new(Mutex::new(vec!["順便用英文".to_string()]));
let job = Job {
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("打招呼")], tools: vec![], session: String::new() },
computers: vec![],
default_computer: crate::model::BOT_COMPUTER_NAME.into(),
local_execution: false,
grants: vec![],
context_tokens: DEFAULT_CONTEXT_TOKENS,
via: "test".into(),
via_name: "Bot".into(),
tools_enabled: true,
direct: Arc::new(AtomicBool::new(false)),
local_locked: false,
steer,
review: None,
demos: vec![],
};
let (tx, rx) = channel();
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
let (mut steered, mut done, mut texts) = (0, 0, vec![]);
loop {
match rx.recv_timeout(Duration::from_secs(10)).expect("loop stalled") {
(_, StreamEvent::Steered(m)) => {
assert!(m.steer);
steered += 1;
}
(_, StreamEvent::StepDone(m)) => texts.push(m.text),
(_, StreamEvent::Done) => {
done += 1;
break;
}
(_, StreamEvent::Error(e)) => panic!("{e}"),
_ => {}
}
}
assert_eq!((steered, done), (1, 1));
assert_eq!(texts, vec!["好", "收到補充"]);
assert!(seen.lock().unwrap().contains("順便用英文"));
}
/// Tools off and this Mac not allowed: the step pops up two requests (use tools, use this
/// computer) instead of failing, and runs once both are allowed.
#[test]
fn missing_permission_pops_up_and_continues() {
use std::io::{Read, Write};
use std::net::TcpListener;
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
std::thread::spawn(move || {
let replies = [
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"c1\",\"function\":{\"name\":\"list_dir\",\"arguments\":\"{\\\"computer\\\":\\\"你的電腦\\\"}\"}}]}}]}\n\ndata: [DONE]\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"看完了\"}}]}\n\ndata: [DONE]\n\n",
];
for body in replies {
let (mut s, _) = l.accept().unwrap();
let mut req = Vec::new();
let mut buf = [0u8; 8192];
loop {
let n = s.read(&mut buf).unwrap_or(0);
if n == 0 {
break;
}
req.extend_from_slice(&buf[..n]);
let t = String::from_utf8_lossy(&req).to_string();
if let Some(end) = t.find("\r\n\r\n") {
let len = t[..end].lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))).unwrap_or(0);
if req.len() >= end + 4 + len {
break;
}
}
}
let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n");
let _ = s.write_all(body.as_bytes());
}
});
let w = std::env::temp_dir().join(format!("anybot-perm-{}", std::process::id()));
std::fs::create_dir_all(&w).unwrap();
std::fs::write(w.join("note.txt"), "hi").unwrap();
let job = Job {
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("看看我的電腦")], tools: tools::specs(), session: String::new() },
computers: vec![tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: w } }],
default_computer: crate::model::THIS_COMPUTER_NAME.into(),
local_execution: false,
tools_enabled: false,
direct: Arc::new(AtomicBool::new(false)),
local_locked: false,
grants: vec![],
context_tokens: DEFAULT_CONTEXT_TOKENS,
via: "test".into(),
via_name: "Bot".into(),
steer: Arc::new(Mutex::new(vec![])),
review: None,
demos: vec![],
};
let (tx, rx) = channel();
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
let mut kinds = vec![];
let mut result = String::new();
loop {
match rx.recv_timeout(Duration::from_secs(10)).expect("loop stalled") {
(_, StreamEvent::NeedApproval(a)) => {
kinds.push(a.kind);
let _ = a.reply.send(ApprovalAnswer::Once);
}
(_, StreamEvent::ToolResult(m)) => result = m.text,
(_, StreamEvent::Done) => break,
(_, StreamEvent::Error(e)) => panic!("{e}"),
_ => {}
}
}
assert_eq!(kinds, vec![ApprovalKind::EnableTools, ApprovalKind::EnableThisComputer]);
assert!(result.contains("note.txt"), "{result}");
}
/// 直接操作: tools off, this Mac off, a command that changes files — and still no question.
/// Dangerous commands stay blocked.
#[test]
fn direct_mode_asks_nothing_but_still_blocks_danger() {
use std::io::{Read, Write};
use std::net::TcpListener;
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
std::thread::spawn(move || {
let replies = [
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"c1\",\"function\":{\"name\":\"shell\",\"arguments\":\"{\\\"command\\\":\\\"echo hi > made.txt\\\",\\\"computer\\\":\\\"你的電腦\\\"}\"}},{\"index\":1,\"id\":\"c2\",\"function\":{\"name\":\"shell\",\"arguments\":\"{\\\"command\\\":\\\"sudo ls\\\",\\\"computer\\\":\\\"你的電腦\\\"}\"}}]}}]}\n\ndata: [DONE]\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"好了\"}}]}\n\ndata: [DONE]\n\n",
];
for body in replies {
let (mut s, _) = l.accept().unwrap();
let mut req = Vec::new();
let mut buf = [0u8; 8192];
loop {
let n = s.read(&mut buf).unwrap_or(0);
if n == 0 {
break;
}
req.extend_from_slice(&buf[..n]);
let t = String::from_utf8_lossy(&req).to_string();
if let Some(end) = t.find("\r\n\r\n") {
let len = t[..end].lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))).unwrap_or(0);
if req.len() >= end + 4 + len {
break;
}
}
}
let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n");
let _ = s.write_all(body.as_bytes());
}
});
let w = std::env::temp_dir().join(format!("anybot-direct-{}", std::process::id()));
std::fs::create_dir_all(&w).unwrap();
let job = Job {
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("做一個檔案")], tools: tools::specs(), session: String::new() },
computers: vec![tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: w.clone() } }],
default_computer: crate::model::THIS_COMPUTER_NAME.into(),
local_execution: false,
tools_enabled: false,
direct: Arc::new(AtomicBool::new(true)),
local_locked: false,
grants: vec![],
context_tokens: DEFAULT_CONTEXT_TOKENS,
via: "test".into(),
via_name: "Bot".into(),
steer: Arc::new(Mutex::new(vec![])),
review: None,
demos: vec![],
};
let (tx, rx) = channel();
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
let mut asked = 0;
let mut results = vec![];
loop {
match rx.recv_timeout(Duration::from_secs(10)).expect("loop stalled") {
(_, StreamEvent::NeedApproval(a)) => {
asked += 1;
let _ = a.reply.send(ApprovalAnswer::Reject);
}
(_, StreamEvent::ToolResult(m)) => results.push(m.text),
(_, StreamEvent::Done) => break,
(_, StreamEvent::Error(e)) => panic!("{e}"),
_ => {}
}
}
assert_eq!(asked, 0, "{results:?}");
assert!(w.join("made.txt").exists());
assert!(results[1].contains("一律不允許"), "{results:?}");
}
}