A finished job closes the tabs it left open, keeping the page it ended on
Every browser_open is a new tab and nothing closed them. When a job ends (answered, stopped or failed) each bot browser it used keeps only its front page; a browser that isn't open isn't started for this, and one another job is still using is left alone. The six-tab limit stays for long jobs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
This commit is contained in:
parent
cbada6bdb1
commit
b480ea16dd
130
src/agent.rs
130
src/agent.rs
|
|
@ -154,6 +154,76 @@ fn target_for(job: &Job, args: &str) -> Result<tools::Target, String> {
|
||||||
job.computers.iter().find(|t| t.name == want).cloned().ok_or_else(|| not_here(job, &want))
|
job.computers.iter().find(|t| t.name == want).cloned().ok_or_else(|| not_here(job, &want))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One of the bot's own browsers (where, and which profile).
|
||||||
|
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
|
||||||
|
enum BotBrowser {
|
||||||
|
Here(String),
|
||||||
|
Box(String),
|
||||||
|
Device(String, String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many running jobs are using each bot browser (bots share the box's Chromium).
|
||||||
|
static BROWSER_JOBS: Mutex<Vec<BotBrowser>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
/// The bot's browsers a job used. When the job ends — answered, stopped or failed — each closes
|
||||||
|
/// the tabs the job left open and keeps the page it ended on (every browser_open is a new tab;
|
||||||
|
/// nothing used to close them, and the box ran out of memory with 19). A browser another job is
|
||||||
|
/// still using is left alone.
|
||||||
|
struct UsedBrowsers {
|
||||||
|
list: Vec<BotBrowser>,
|
||||||
|
via_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A job starts using a bot browser.
|
||||||
|
fn claim(b: &BotBrowser) {
|
||||||
|
BROWSER_JOBS.lock().unwrap().push(b.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A job is done with a bot browser: true when another job is still using it.
|
||||||
|
fn release(b: &BotBrowser) -> bool {
|
||||||
|
let mut jobs = BROWSER_JOBS.lock().unwrap();
|
||||||
|
if let Some(i) = jobs.iter().position(|x| x == b) {
|
||||||
|
jobs.remove(i);
|
||||||
|
}
|
||||||
|
jobs.contains(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsedBrowsers {
|
||||||
|
fn add(&mut self, b: BotBrowser) {
|
||||||
|
if !self.list.contains(&b) {
|
||||||
|
claim(&b);
|
||||||
|
self.list.push(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close_tabs(&mut self) {
|
||||||
|
for b in std::mem::take(&mut self.list) {
|
||||||
|
if release(&b) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let args = |p: &str| if p.is_empty() { r#"{"action":"tidy"}"#.to_string() } else { serde_json::json!({"action": "tidy", "profile": p}).to_string() };
|
||||||
|
let never = AtomicBool::new(false);
|
||||||
|
match &b {
|
||||||
|
BotBrowser::Here(p) => {
|
||||||
|
let _ = web::run_full("browser_tabs", &args(p), &never);
|
||||||
|
}
|
||||||
|
BotBrowser::Box(p) => {
|
||||||
|
let _ = crate::botbrowser::in_box(|| web::run_full("browser_tabs", &args(p), &never));
|
||||||
|
}
|
||||||
|
BotBrowser::Device(host, p) => {
|
||||||
|
let _ = crate::remote::run_on_device(host, "browser_tabs", &args(p), &self.via_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for UsedBrowsers {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.close_tabs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum BrowserAt {
|
enum BrowserAt {
|
||||||
Here,
|
Here,
|
||||||
Box,
|
Box,
|
||||||
|
|
@ -575,6 +645,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
// The screen and browser steps of this turn (to stop the same step being tried over and over).
|
// The screen and browser steps of this turn (to stop the same step being tried over and over).
|
||||||
let mut tried: Vec<String> = Vec::new();
|
let mut tried: Vec<String> = Vec::new();
|
||||||
let mut local_on = job.local_execution;
|
let mut local_on = job.local_execution;
|
||||||
|
let mut browsers = UsedBrowsers { list: vec![], via_name: job.via_name.clone() };
|
||||||
maybe_compact(&job, &mut history, COMPACT_THRESHOLD_PERCENT, &cancel, &send);
|
maybe_compact(&job, &mut history, COMPACT_THRESHOLD_PERCENT, &cancel, &send);
|
||||||
for step in 0..MAX_STEPS {
|
for step in 0..MAX_STEPS {
|
||||||
if step > 0 {
|
if step > 0 {
|
||||||
|
|
@ -626,6 +697,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
// Something typed while it answered keeps the same turn going.
|
// Something typed while it answered keeps the same turn going.
|
||||||
if job.steer.lock().unwrap().is_empty() {
|
if job.steer.lock().unwrap().is_empty() {
|
||||||
send(StreamEvent::Done);
|
send(StreamEvent::Done);
|
||||||
|
browsers.close_tabs();
|
||||||
learn(&job, &history, &cancel, &send);
|
learn(&job, &history, &cancel, &send);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -707,6 +779,14 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
let at = web::is_web_tool(&call.name).then(|| browser_place(&job, &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)) {
|
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));
|
||||||
|
if call.name != "web_fetch" {
|
||||||
|
let profile = tools::args_of(&call.args).get("profile").and_then(|p| p.as_str()).unwrap_or("").trim().to_string();
|
||||||
|
browsers.add(match &place {
|
||||||
|
BrowserAt::Here => BotBrowser::Here(profile),
|
||||||
|
BrowserAt::Box => BotBrowser::Box(profile),
|
||||||
|
BrowserAt::Device(host) => BotBrowser::Device(host.clone(), profile),
|
||||||
|
});
|
||||||
|
}
|
||||||
// 裝置連結: the AnyBot browser of another computer (its own logins).
|
// 裝置連結: the AnyBot browser of another computer (its own logins).
|
||||||
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) {
|
||||||
|
|
@ -1556,6 +1636,46 @@ printf 'a,b\n1,2\n' > report.csv && printf 'hello box' > '/tmp/anybot-up/上傳
|
||||||
assert!(results[17].contains("cleaned"), "{}", results[17]);
|
assert!(results[17].contains("cleaned"), "{}", results[17]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When a job ends, the tabs it left open in the box's browser close; the page it ended on
|
||||||
|
/// stays. Needs the box: `cargo test a_finished_job_closes_its_tabs -- --ignored --nocapture`.
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn a_finished_job_closes_its_tabs() {
|
||||||
|
let own = crate::model::BOT_COMPUTER_NAME;
|
||||||
|
let setup = "mkdir -p /tmp/anybot-tabs && cd /tmp/anybot-tabs && echo '<meta charset=utf-8><title>tab</title>分頁' > index.html && (setsid python3 -m http.server 8770 --bind 127.0.0.1 >/dev/null 2>&1 &) ; sleep 1; echo served";
|
||||||
|
let port = fake_model(vec![
|
||||||
|
("shell", serde_json::json!({"command": setup})),
|
||||||
|
("browser_open", serde_json::json!({"url": "http://127.0.0.1:8770/?n=1"})),
|
||||||
|
("browser_open", serde_json::json!({"url": "http://127.0.0.1:8770/?n=2"})),
|
||||||
|
("browser_open", serde_json::json!({"url": "http://127.0.0.1:8770/?n=3"})),
|
||||||
|
("", 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());
|
||||||
|
loop {
|
||||||
|
match rx.recv_timeout(Duration::from_secs(120)).expect("loop stalled") {
|
||||||
|
(_, StreamEvent::Done) => break,
|
||||||
|
(_, StreamEvent::Error(e)) => panic!("{e}"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_secs(2));
|
||||||
|
let box_sh = |script: &str| String::from_utf8_lossy(&std::process::Command::new("docker").args(["exec", crate::cloudbox::container(), "sh", "-c", script]).output().unwrap().stdout).to_string();
|
||||||
|
let left = box_sh(r#"curl -s 127.0.0.1:9222/json/list | python3 -c 'import json,sys; print("\n".join(t["url"] for t in json.load(sys.stdin) if t["type"] == "page"))'"#);
|
||||||
|
println!("left open:\n{left}");
|
||||||
|
box_sh(r#"pkill -f 'http[.]server 8770'; rm -rf /tmp/anybot-tabs"#);
|
||||||
|
assert_eq!(left.trim(), "http://127.0.0.1:8770/?n=3", "{left}");
|
||||||
|
}
|
||||||
|
|
||||||
/// Words reach the control they name on a form laid out like httpbin's (each label and its
|
/// Words reach the control they name on a form laid out like httpbin's (each label and its
|
||||||
/// input inside a <p>): typing into 「Customer name:」, ticking 「Large」, pressing 「Submit
|
/// input inside a <p>): typing into 「Customer name:」, ticking 「Large」, pressing 「Submit
|
||||||
/// order」. They used to land on the <p> and do nothing. Needs the box:
|
/// order」. They used to land on the <p> and do nothing. Needs the box:
|
||||||
|
|
@ -1622,6 +1742,16 @@ echo '<meta charset=utf-8><title>done</title>送出了' > done.html; (setsid pyt
|
||||||
assert!(results[8].contains("cleaned"), "{}", results[8]);
|
assert!(results[8].contains("cleaned"), "{}", results[8]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_browser_another_job_uses_keeps_its_tabs() {
|
||||||
|
let b = BotBrowser::Device("test-host-tabs".into(), String::new());
|
||||||
|
claim(&b);
|
||||||
|
claim(&b);
|
||||||
|
assert!(release(&b), "the other job still uses it");
|
||||||
|
assert!(!release(&b), "the last one out closes the tabs");
|
||||||
|
assert!(!release(&b));
|
||||||
|
}
|
||||||
|
|
||||||
#[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.
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,14 @@ pub fn using_box() -> bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start this profile's window if it isn't running. Returns (browser name, port, just started).
|
/// Start this profile's window if it isn't running. Returns (browser name, port, just started).
|
||||||
|
/// The debugging port of a bot browser that is already open (None when it isn't; never starts one).
|
||||||
|
pub fn running_port(profile: Option<&str>) -> Option<u16> {
|
||||||
|
if using_box() {
|
||||||
|
return crate::cloudbox::browser_running().then(crate::cloudbox::browser_port).and_then(Result::ok);
|
||||||
|
}
|
||||||
|
live_port(&resolve(profile).ok()?)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn ensure(profile: &str, first_url: Option<&str>) -> Result<(&'static str, u16, bool), String> {
|
pub fn ensure(profile: &str, first_url: Option<&str>) -> Result<(&'static str, u16, bool), String> {
|
||||||
if using_box() {
|
if using_box() {
|
||||||
let fresh = !crate::cloudbox::browser_running();
|
let fresh = !crate::cloudbox::browser_running();
|
||||||
|
|
|
||||||
|
|
@ -823,7 +823,19 @@ fn shrink(b64: &str) -> Option<String> {
|
||||||
Some(base64::engine::general_purpose::STANDARD.encode(out.into_inner()))
|
Some(base64::engine::general_purpose::STANDARD.encode(out.into_inner()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A job is done with this browser: keep the page it ended on, close the rest (the list is
|
||||||
|
/// latest-used first). A browser that isn't open stays closed. Not offered to the model; the
|
||||||
|
/// loop calls it (browser_tabs action "tidy") when a job ends.
|
||||||
|
fn tidy(profile: Option<&str>) -> Result<String, String> {
|
||||||
|
let Some(port) = botbrowser::running_port(profile) else { return Ok("瀏覽器沒有開著。".into()) };
|
||||||
|
let closed = targets(port)?.iter().skip(1).filter(|t| botbrowser::local().get(&format!("http://127.0.0.1:{port}/json/close/{}", t["id"].as_str().unwrap_or(""))).call().is_ok()).count();
|
||||||
|
Ok(format!("關了 {closed} 個用完的分頁。"))
|
||||||
|
}
|
||||||
|
|
||||||
fn tabs(args: &Value, profile: Option<&str>) -> Result<String, String> {
|
fn tabs(args: &Value, profile: Option<&str>) -> Result<String, String> {
|
||||||
|
if s(args, "action") == "tidy" {
|
||||||
|
return tidy(profile);
|
||||||
|
}
|
||||||
let (_, port) = port_of(profile)?;
|
let (_, port) = port_of(profile)?;
|
||||||
let http = |path: &str, put: bool| -> Result<String, String> {
|
let http = |path: &str, put: bool| -> Result<String, String> {
|
||||||
let url = format!("http://127.0.0.1:{port}{path}");
|
let url = format!("http://127.0.0.1:{port}{path}");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue