From 8e9736a7db826ae7b745903e7704294625b82b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Fri, 6 Nov 2026 16:51:25 +0800 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4 --- FEATURES.md | 5 +- src/agent.rs | 139 +++++++++++++++++++++++++++++++++++++++++++++-- src/app.rs | 1 + src/cloudbox.rs | 23 ++++++-- src/providers.rs | 35 +++++++++++- src/web.rs | 4 ++ 6 files changed, 197 insertions(+), 10 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 6056a1a..494ef5e 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -583,7 +583,10 @@ AnyBot 是一個桌面聊天 App,介面和運作方式照 Grok Bot 重做。 - **一個回合**:從你送出一則訊息(或 Bot 被喚醒)開始,到模型用純文字回答、不再要求工具為止。 - 中間可以來回很多步:模型要用工具,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%)。 2. 把你中途打的話加進去。 diff --git a/src/agent.rs b/src/agent.rs index d34d826..06553ad 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -22,8 +22,13 @@ 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; +/// Most model calls in one turn: a guard against a runaway turn, not a budget. Grok Bot leaves +/// 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). pub const KEEP_RECENT_MESSAGES: usize = 6; /// 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 /// "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 @@ -260,6 +273,17 @@ fn step_key(name: &str, args: &str) -> Option { 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. fn noted(moved: &std::collections::HashMap, id: &str, text: &str) -> String { 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 { maybe_compact(&job, &mut history, COMPACT_THRESHOLD_PERCENT + COMPACT_TOLERANCE_PERCENT, &cancel, &send); drain_steer(&job, &mut history, &send); + check_in(&mut history, step); send(StreamEvent::NextStep(job.via.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. // …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)); // 裝置連結: 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 { 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)), @@ -1195,6 +1220,112 @@ mod tests { 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::().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= 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 'box page

盒子裡的測試頁 42

' > /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] 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. diff --git a/src/app.rs b/src/app.rs index 516d3fc..7f2bb5d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4067,6 +4067,7 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu - 回覆使用者時,說到電腦一律用上面的名字(例如「我在「{own}」打開了 Threads」),不要說「你的電腦」「Bot 的電腦」這種會混淆的說法。\n\ - 不確定使用者指的是哪台電腦時(他說的那台不在清單上、有幾台都說得通、或他的說法和你手上的預設不一致),先用一句話問他要哪台,問清楚再動手;不要擅作主張選一台就開始做,也不要把一台說成另一台。\n\ - 先用 list_dir、search、read_file 看清楚,再動手。\n\ + - 一回合的步數有限,每一步都要做多一點:互不相依的工具在同一步一起呼叫(例如同時讀幾個檔案、同時查幾件事);查東西的指令合成一條(用 ; 或 && 串起來,一次看完要的資料),不要一條指令一步。要等東西跑完時,把等待寫在同一條指令裡(迴圈等到好了為止),不要一步一步 sleep。\n\ - 需要權限時(電腦工具還沒打開、要用使用者的電腦、會改變東西的操作),直接呼叫工具,App 會自己跳出視窗請使用者允許;不要叫使用者自己去改設定。被拒絕就換個方式或問使用者。\n\ - 跑很久的指令會移到背景,用 read_terminal 追蹤,不要重複執行;它在等輸入時(例如問 y/n),用 write_terminal 回答。\n\ - 只有工具結果證明做完了,才說做完;失敗就照實說。\n\ diff --git a/src/cloudbox.rs b/src/cloudbox.rs index 5abf7f4..7fa230f 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -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) } +/// 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. fn box_cdp_forward() -> Result { static PORT: Mutex = Mutex::new(0); @@ -752,10 +769,8 @@ fn box_cdp_forward() -> Result { 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 (Ok(mut r), Ok(mut w)) = (conn.try_clone(), conn.try_clone()) else { return }; - let up = std::thread::spawn(move || { - let _ = std::io::copy(&mut r, &mut cin); - }); - let _ = std::io::copy(&mut cout, &mut w); + let up = std::thread::spawn(move || pump(&mut r, &mut cin)); + pump(&mut cout, &mut w); let _ = conn.shutdown(std::net::Shutdown::Both); let _ = child.kill(); let _ = up.join(); diff --git a/src/providers.rs b/src/providers.rs index 2d04023..537797e 100644 --- a/src/providers.rs +++ b/src/providers.rs @@ -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) } +/// An error body as words: a gateway's HTML error page (「錯誤 502:…」 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("").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::>().join(" ") +} + fn http_error(status: u16, body: &str, retry_after: Option) -> Failure { let detail = serde_json::from_str::(body) .ok() @@ -322,7 +345,7 @@ fn http_error(status: u16, body: &str, retry_after: Option) -> Failure { .or_else(|| v.get("error")) .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 { 401 | 403 => "金鑰無效或沒有權限,請到「設定 → AI 供應商」檢查。", 404 => "找不到這個模型或網址,請檢查模型名稱。", @@ -1228,6 +1251,16 @@ mod tests { } } + #[test] + fn a_gateways_error_page_reads_as_words() { + let page = "\n\n\n\n\nError response\n\n\n

Error response

\n

Error code: 502

\n

Message: Bad Gateway.

\n\n\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] fn backoff_matches_original() { assert_eq!([backoff_delay_ms(1), backoff_delay_ms(2), backoff_delay_ms(3)], [1000, 2000, 4000]); diff --git a/src/web.rs b/src/web.rs index e564069..a5aba47 100644 --- a/src/web.rs +++ b/src/web.rs @@ -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 /// Chrome-family browser is installed)? 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() { return true; }