Long tasks finish and the box's browser works from a cloud brain

- A turn may take up to 100 steps (was 25, which cut off real work; Grok Bot sets no limit).
  Every 25 steps the bot is asked whether it is still getting anywhere.
- The system prompt asks for independent tools in one step and combined commands.
- Browser steps for the box always go to the box's Chromium. They used to depend on whether
  the brain itself had a Chrome, so a cloud brain sent them to "the person's browser".
- The box's DevTools forward passes bytes with a plain read/write loop. std::io::copy splices
  on Linux and held back short requests, so the box browser never opened from a Linux brain.
- A gateway's HTML error page shows as plain words.
- Real test a_brain_without_chrome_browses_on_its_box (ignored): passes in a Linux container
  with no Chrome against a real box, and fails without the routing fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
This commit is contained in:
王性驊 2026-11-06 16:51:25 +08:00
parent deec774a56
commit 8e9736a7db
6 changed files with 197 additions and 10 deletions

View File

@ -583,7 +583,10 @@ AnyBot 是一個桌面聊天 App,介面和運作方式照 Grok Bot 重做。
- **一個回合**:從你送出一則訊息(或 Bot 被喚醒)開始,到模型用純文字回答、不再要求工具為止。 - **一個回合**:從你送出一則訊息(或 Bot 被喚醒)開始,到模型用純文字回答、不再要求工具為止。
- 中間可以來回很多步:模型要用工具,App 執行並交回結果,模型再決定下一步。 - 中間可以來回很多步:模型要用工具,App 執行並交回結果,模型再決定下一步。
- **步驟上限**:一個回合最多 25 步(估計值)。用完時會顯示:「(這次已經用了 25 個步驟,先停在這裡。要繼續的話,回覆「繼續」。)」 - **步驟上限**:一個回合最多 100 步,只是防止失控的保險(Grok Bot 本身不設上限:`cloud_agent_stream_conversation_turn_window` 的 `maxTurns:null`;browser-use 預設 100)。原本的 25 步會把正常的長工作切斷(修一台伺服器的 Docker 用了 50 多條指令)。用完時會顯示:「(這次已經用了 100 個步驟,先停在這裡。要繼續的話,回覆「繼續」。)」
- 每 25 步,模型會在最新的工具結果後面看到一句提醒:方向對就繼續;最近幾步都沒進展就停下來,用一兩句話告訴你卡在哪、要你做什麼(聊天記錄裡看不到這句)。
- 鬼打牆另外有攔截:同一個畫面或網頁動作第 3 次會被警告,第 4 次起不執行。
- 系統提示要模型每一步做多一點:互不相依的工具同一步一起呼叫、查東西的指令合成一條、等待寫在同一條指令裡。
- **每一步開始前會依序做**: - **每一步開始前會依序做**:
1. 從第 2 步起,檢查要不要整理摘要(門檻 75%)。 1. 從第 2 步起,檢查要不要整理摘要(門檻 75%)。
2. 把你中途打的話加進去。 2. 把你中途打的話加進去。

View File

@ -22,8 +22,13 @@ pub const COMPACT_THRESHOLD_PERCENT: u64 = 70;
/// Extra room allowed in the middle of a task before summarizing again: /// Extra room allowed in the middle of a task before summarizing again:
/// same config, `tolerancePercentage:5`. /// same config, `tolerancePercentage:5`.
pub const COMPACT_TOLERANCE_PERCENT: u64 = 5; pub const COMPACT_TOLERANCE_PERCENT: u64 = 5;
/// Most model calls in one turn (estimated). /// Most model calls in one turn: a guard against a runaway turn, not a budget. Grok Bot leaves
pub const MAX_STEPS: usize = 25; /// its turn window open (main-app.cjs `cloud_agent_stream_conversation_turn_window`
/// `fallbackValues:{maxTurns:null}`) and browser-use stops at 100; 25 cut off real work (fixing
/// a server's Docker took 50+ commands). Going in circles is stopped long before this by step_key.
pub const MAX_STEPS: usize = 100;
/// Every this many steps the bot is asked whether it is getting anywhere (estimated).
pub const CHECK_IN_STEPS: usize = 25;
/// Newest messages always kept word for word (estimated). /// Newest messages always kept word for word (estimated).
pub const KEEP_RECENT_MESSAGES: usize = 6; pub const KEEP_RECENT_MESSAGES: usize = 6;
/// Characters of each old message given to the summarizer (estimated). /// Characters of each old message given to the summarizer (estimated).
@ -147,6 +152,14 @@ fn browser_place(job: &Job, name: &str, args: &str) -> (BrowserAt, String) {
} }
} }
/// Is this browser step for the bot's own browser (no permission, no person's computer)? The
/// box's Chromium always is — whether *this* computer has a Chrome doesn't matter there. (Asking
/// this computer made a cloud brain, which has no Chrome, send every browser_open/read meant for
/// the box to the person's browser on the server: 「打不開瀏覽器」, 「Safari 沒有回應」.)
fn bots_browser(place: &BrowserAt, name: &str, args: &str) -> bool {
matches!(place, BrowserAt::Box) || !web::uses_this_computer(name, args)
}
/// Substring search with word boundaries on the ASCII parts: "mac" must not light up inside /// Substring search with word boundaries on the ASCII parts: "mac" must not light up inside
/// "macOS", and a device named "Ann" must not light up on "anniversary". Loose matching let /// "macOS", and a device named "Ann" must not light up on "anniversary". Loose matching let
/// unrelated words — including words from page text that arrives inside a message — unlock a /// unrelated words — including words from page text that arrives inside a message — unlock a
@ -260,6 +273,17 @@ fn step_key(name: &str, args: &str) -> Option<String> {
Some(format!("{name} {v}")) Some(format!("{name} {v}"))
} }
/// Every CHECK_IN_STEPS steps, the newest result carries a question for the bot: still getting
/// somewhere, or stuck? Only the model sees it (the chat keeps the result as it was).
fn check_in(history: &mut [Message], step: usize) {
if step == 0 || !step.is_multiple_of(CHECK_IN_STEPS) {
return;
}
if let Some(m) = history.last_mut().filter(|m| m.role == Role::Tool) {
m.text.push_str(&format!("\n\n(這一回合已經做了 {step} 步,最多 {MAX_STEPS} 步。方向對、有進展就繼續做完;如果最近幾步都沒有進展,停下來用一兩句話告訴使用者卡在哪、需要他做什麼。)"));
}
}
/// A step's result, with a word first when the step was moved back to the chosen computer. /// A step's result, with a word first when the step was moved back to the chosen computer.
fn noted(moved: &std::collections::HashMap<String, String>, id: &str, text: &str) -> String { fn noted(moved: &std::collections::HashMap<String, String>, id: &str, text: &str) -> String {
match moved.get(id) { match moved.get(id) {
@ -443,6 +467,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
if step > 0 { if step > 0 {
maybe_compact(&job, &mut history, COMPACT_THRESHOLD_PERCENT + COMPACT_TOLERANCE_PERCENT, &cancel, &send); maybe_compact(&job, &mut history, COMPACT_THRESHOLD_PERCENT + COMPACT_TOLERANCE_PERCENT, &cancel, &send);
drain_steer(&job, &mut history, &send); drain_steer(&job, &mut history, &send);
check_in(&mut history, step);
send(StreamEvent::NextStep(job.via.clone())); send(StreamEvent::NextStep(job.via.clone()));
} }
let req = ChatRequest { history: history.clone(), ..job.req.clone() }; let req = ChatRequest { history: history.clone(), ..job.req.clone() };
@ -542,10 +567,10 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
} }
// Public web pages need no computer and no permission. // Public web pages need no computer and no permission.
// …and neither does AnyBot's own browser (like Grok Bot's own browser). // …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)) { let at = web::is_web_tool(&call.name).then(|| browser_place(&job, &call.name, &call.args));
if let Some((place, on)) = at.filter(|(place, _)| call.name == "web_fetch" || bots_browser(place, &call.name, &call.args)) {
send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running)); send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running));
// 裝置連結: the AnyBot browser of another computer (its own logins). // 裝置連結: the AnyBot browser of another computer (its own logins).
let (place, on) = browser_place(&job, &call.name, &call.args);
let (output, ok, img) = match place { let (output, ok, img) = match place {
BrowserAt::Device(host) => match crate::remote::run_on_device(&host, &call.name, &call.args, &job.via_name) { BrowserAt::Device(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)), Ok(v) => (v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false), v["image"].as_str().map(String::from)),
@ -1195,6 +1220,112 @@ mod tests {
assert!(!mentions("在 macOS 的登入機制下,幫 Bot 的電腦截圖", &mac, &roles)); assert!(!mentions("在 macOS 的登入機制下,幫 Bot 的電腦截圖", &mac, &roles));
} }
#[test]
fn long_turns_check_in_instead_of_stopping() {
let mut h = vec![Message::user("修好它"), Message::tool_result("t1", "shell", "ok")];
check_in(&mut h, CHECK_IN_STEPS - 1);
assert_eq!(h[1].text, "ok");
check_in(&mut h, CHECK_IN_STEPS);
assert!(h[1].text.starts_with("ok") && h[1].text.contains(&format!("已經做了 {CHECK_IN_STEPS} 步")));
// Only a tool result carries it (a steer the person typed is left as they wrote it).
let mut h = vec![Message::user("改用別台")];
check_in(&mut h, CHECK_IN_STEPS * 2);
assert_eq!(h[0].text, "改用別台");
}
#[test]
fn the_boxs_browser_is_always_the_bots_own() {
// A cloud brain has no Chrome of its own; browser steps for the box still go to the box.
for args in [r#"{}"#, r#"{"url":"https://www.threads.com"}"#, r#"{"profile":"你的瀏覽器"}"#] {
assert!(bots_browser(&BrowserAt::Box, "browser_open", args));
assert!(bots_browser(&BrowserAt::Box, "browser_read", args));
assert!(crate::botbrowser::in_box(|| !web::uses_person_browser(args)));
}
}
/// A made-up model on a local port that answers each request with the next reply: a tool
/// call (name, JSON arguments) or, for an empty name, words.
fn fake_model(replies: Vec<(&'static str, serde_json::Value)>) -> u16 {
use std::io::{Read, Write};
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
std::thread::spawn(move || {
for (i, (name, v)) in replies.into_iter().enumerate() {
let delta = if name.is_empty() {
serde_json::json!({"content": v.as_str().unwrap_or("")})
} else {
serde_json::json!({"tool_calls": [{"index": 0, "id": format!("c{i}"), "function": {"name": name, "arguments": v.to_string()}}]})
};
let body = format!("data: {}\n\ndata: [DONE]\n\n", serde_json::json!({"choices": [{"delta": delta}]}));
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());
}
});
port
}
/// A brain with no Chrome of its own — a cloud brain — browses on its own box: the bot's
/// browser_open and browser_read for its computer reach the box's Chromium, not the
/// person's browser on the server. Run where no Chrome-family browser is installed, against
/// a box: `ANYBOT_DATA_DIR=<scratch> cargo test --release -- --ignored
/// a_brain_without_chrome_browses_on_its_box --nocapture`.
#[test]
#[ignore]
fn a_brain_without_chrome_browses_on_its_box() {
let own = crate::model::BOT_COMPUTER_NAME;
let page = "mkdir -p /tmp/anybot-page && printf '<meta charset=utf-8><title>box page</title><h1>盒子裡的測試頁 42</h1>' > /tmp/anybot-page/index.html && (cd /tmp/anybot-page && setsid python3 -m http.server 8766 --bind 127.0.0.1 >/dev/null 2>&1 &) ; sleep 1; echo served";
let port = fake_model(vec![
("shell", serde_json::json!({"command": page})),
("browser_open", serde_json::json!({"url": "http://127.0.0.1:8766/"})),
("browser_read", serde_json::json!({})),
("", serde_json::json!("看到了")),
]);
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: own.into(), place: tools::Place::Box { dir: crate::cloudbox::bot_dir(1) } }],
default_computer: own.into(),
roles: tools::Roles { own: own.into(), person: None, server: true },
local_execution: false,
direct: Arc::new(AtomicBool::new(true)),
..test_job()
};
let (tx, rx) = channel();
spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
let mut results = vec![];
loop {
match rx.recv_timeout(Duration::from_secs(120)).expect("loop stalled") {
(_, StreamEvent::ToolResult(m)) => {
println!("{}: {}", m.tool_name, m.text);
results.push(m.text)
}
(_, StreamEvent::Done) => break,
(_, StreamEvent::Error(e)) => panic!("{e}"),
_ => {}
}
}
println!("this computer has a Chrome-family browser: {}", crate::botbrowser::engine().is_some());
assert!(results[0].contains("served"), "{results:?}");
assert!(results[1].starts_with(&format!("[{own}]")) && results[1].contains("它桌面上的 Chromium"), "{}", results[1]);
assert!(results[2].contains("盒子裡的測試頁 42"), "{}", results[2]);
}
#[test] #[test]
fn the_same_miss_counts_as_the_same_step() { fn the_same_miss_counts_as_the_same_step() {
// Clicks a few pixels apart on the same strip are one step; another computer or target isn't. // Clicks a few pixels apart on the same strip are one step; another computer or target isn't.

View File

@ -4067,6 +4067,7 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
- 回覆使用者時,說到電腦一律用上面的名字(例如「我在「{own}」打開了 Threads」),不要說「你的電腦」「Bot 的電腦」這種會混淆的說法。\n\ - 回覆使用者時,說到電腦一律用上面的名字(例如「我在「{own}」打開了 Threads」),不要說「你的電腦」「Bot 的電腦」這種會混淆的說法。\n\
- 不確定使用者指的是哪台電腦時(他說的那台不在清單上、有幾台都說得通、或他的說法和你手上的預設不一致),先用一句話問他要哪台,問清楚再動手;不要擅作主張選一台就開始做,也不要把一台說成另一台。\n\ - 不確定使用者指的是哪台電腦時(他說的那台不在清單上、有幾台都說得通、或他的說法和你手上的預設不一致),先用一句話問他要哪台,問清楚再動手;不要擅作主張選一台就開始做,也不要把一台說成另一台。\n\
- 先用 list_dir、search、read_file 看清楚,再動手。\n\ - 先用 list_dir、search、read_file 看清楚,再動手。\n\
- 一回合的步數有限,每一步都要做多一點:互不相依的工具在同一步一起呼叫(例如同時讀幾個檔案、同時查幾件事);查東西的指令合成一條(用 ; 或 && 串起來,一次看完要的資料),不要一條指令一步。要等東西跑完時,把等待寫在同一條指令裡(迴圈等到好了為止),不要一步一步 sleep。\n\
- 需要權限時(電腦工具還沒打開、要用使用者的電腦、會改變東西的操作),直接呼叫工具,App 會自己跳出視窗請使用者允許;不要叫使用者自己去改設定。被拒絕就換個方式或問使用者。\n\ - 需要權限時(電腦工具還沒打開、要用使用者的電腦、會改變東西的操作),直接呼叫工具,App 會自己跳出視窗請使用者允許;不要叫使用者自己去改設定。被拒絕就換個方式或問使用者。\n\
- 跑很久的指令會移到背景,用 read_terminal 追蹤,不要重複執行;它在等輸入時(例如問 y/n),用 write_terminal 回答。\n\ - 跑很久的指令會移到背景,用 read_terminal 追蹤,不要重複執行;它在等輸入時(例如問 y/n),用 write_terminal 回答。\n\
- 只有工具結果證明做完了,才說做完;失敗就照實說。\n\ - 只有工具結果證明做完了,才說做完;失敗就照實說。\n\

View File

@ -736,6 +736,23 @@ fn box_cdp_alive(local: u16) -> bool {
crate::botbrowser::local().get(&format!("http://127.0.0.1:{local}/json/version")).call().map(|r| r.status().as_u16() == 200).unwrap_or(false) crate::botbrowser::local().get(&format!("http://127.0.0.1:{local}/json/version")).call().map(|r| r.status().as_u16() == 200).unwrap_or(false)
} }
/// Pass bytes on as they come. Not std::io::copy: on Linux it splices a socket into the
/// `docker exec` pipe and sits on a short request (a DevTools probe never got its answer, so
/// every Linux brain — the cloud brains — saw the box's browser as "not opening in time").
fn pump(from: &mut impl std::io::Read, to: &mut impl std::io::Write) {
let mut buf = [0u8; 16 * 1024];
loop {
match from.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
if to.write_all(&buf[..n]).is_err() {
break;
}
}
}
}
}
/// A local port that reaches the box's DevTools port. /// A local port that reaches the box's DevTools port.
fn box_cdp_forward() -> Result<u16, String> { fn box_cdp_forward() -> Result<u16, String> {
static PORT: Mutex<u16> = Mutex::new(0); static PORT: Mutex<u16> = Mutex::new(0);
@ -752,10 +769,8 @@ fn box_cdp_forward() -> Result<u16, String> {
let Ok(mut child) = docker().args(["exec", "-i", container(), "bash", "-c", &script]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { return }; let Ok(mut child) = docker().args(["exec", "-i", container(), "bash", "-c", &script]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { return };
let (Some(mut cin), Some(mut cout)) = (child.stdin.take(), child.stdout.take()) else { return }; let (Some(mut cin), Some(mut cout)) = (child.stdin.take(), child.stdout.take()) else { return };
let (Ok(mut r), Ok(mut w)) = (conn.try_clone(), conn.try_clone()) else { return }; let (Ok(mut r), Ok(mut w)) = (conn.try_clone(), conn.try_clone()) else { return };
let up = std::thread::spawn(move || { let up = std::thread::spawn(move || pump(&mut r, &mut cin));
let _ = std::io::copy(&mut r, &mut cin); pump(&mut cout, &mut w);
});
let _ = std::io::copy(&mut cout, &mut w);
let _ = conn.shutdown(std::net::Shutdown::Both); let _ = conn.shutdown(std::net::Shutdown::Both);
let _ = child.kill(); let _ = child.kill();
let _ = up.join(); let _ = up.join();

View File

@ -313,6 +313,29 @@ fn claude_fallbacks(model: &str) -> bool {
["claude-opus-5-5", "claude-opus-5", "claude-fable-5-1", "claude-sonnet-5-5"].contains(&model) ["claude-opus-5-5", "claude-opus-5", "claude-fable-5-1", "claude-sonnet-5-5"].contains(&model)
} }
/// An error body as words: a gateway's HTML error page (「錯誤 502:<!DOCTYPE HTML>…」 filled the
/// chat) becomes its text; anything else is kept as it is.
fn page_words(body: &str) -> String {
if !body.trim_start().starts_with('<') {
return body.to_string();
}
let no_style = body.rsplit("</style>").next().unwrap_or(body);
let mut out = String::new();
let mut in_tag = false;
for c in no_style.chars() {
match c {
'<' => {
in_tag = true;
out.push(' ');
}
'>' => in_tag = false,
_ if !in_tag => out.push(c),
_ => {}
}
}
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn http_error(status: u16, body: &str, retry_after: Option<u64>) -> Failure { fn http_error(status: u16, body: &str, retry_after: Option<u64>) -> Failure {
let detail = serde_json::from_str::<Value>(body) let detail = serde_json::from_str::<Value>(body)
.ok() .ok()
@ -322,7 +345,7 @@ fn http_error(status: u16, body: &str, retry_after: Option<u64>) -> Failure {
.or_else(|| v.get("error")) .or_else(|| v.get("error"))
.map(|m| m.as_str().map(str::to_string).unwrap_or_else(|| m.to_string())) .map(|m| m.as_str().map(str::to_string).unwrap_or_else(|| m.to_string()))
}) })
.unwrap_or_else(|| body.chars().take(300).collect()); .unwrap_or_else(|| page_words(body).chars().take(300).collect());
let hint = match status { let hint = match status {
401 | 403 => "金鑰無效或沒有權限,請到「設定 → AI 供應商」檢查。", 401 | 403 => "金鑰無效或沒有權限,請到「設定 → AI 供應商」檢查。",
404 => "找不到這個模型或網址,請檢查模型名稱。", 404 => "找不到這個模型或網址,請檢查模型名稱。",
@ -1228,6 +1251,16 @@ mod tests {
} }
} }
#[test]
fn a_gateways_error_page_reads_as_words() {
let page = "<!DOCTYPE HTML>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<style type=\"text/css\">\n:root {\ncolor-scheme: light dark;\n}\n</style>\n<title>Error response</title>\n</head>\n<body>\n<h1>Error response</h1>\n<p>Error code: 502</p>\n<p>Message: Bad Gateway.</p>\n</body>\n</html>\n";
let f = http_error(502, page, None);
assert!(f.retryable);
assert!(!f.msg.contains('<') && !f.msg.contains("color-scheme"), "{}", f.msg);
assert!(f.msg.contains("Bad Gateway"), "{}", f.msg);
assert_eq!(page_words("upstream timed out"), "upstream timed out");
}
#[test] #[test]
fn backoff_matches_original() { fn backoff_matches_original() {
assert_eq!([backoff_delay_ms(1), backoff_delay_ms(2), backoff_delay_ms(3)], [1000, 2000, 4000]); assert_eq!([backoff_delay_ms(1), backoff_delay_ms(2), backoff_delay_ms(3)], [1000, 2000, 4000]);

View File

@ -40,6 +40,10 @@ pub fn label(name: &str) -> &'static str {
/// Does this call use the person's own browser ("你的瀏覽器", or the fallback when no /// Does this call use the person's own browser ("你的瀏覽器", or the fallback when no
/// Chrome-family browser is installed)? /// Chrome-family browser is installed)?
pub fn uses_person_browser(args: &str) -> bool { pub fn uses_person_browser(args: &str) -> bool {
// The box has one browser, the bot's own; the person's browser is never in there.
if crate::botbrowser::using_box() {
return false;
}
if !crate::botbrowser::available() { if !crate::botbrowser::available() {
return true; return true;
} }