Skip to content

Commit 5386473

Browse files
committed
fix(control-hub): make browser.wait actually wait
`browser.wait` read only `duration_ms`, so the very plausible `{ "ms": 1800000 }` was dropped and the call fell through to a branch that returned `{ success: true }` instantly. An agent asked to pause 30 minutes got "Wait completed" back in milliseconds and moved straight on. The action documented no parameters at all, so the model had to guess the key, and even a correct guess was silently capped at 30 seconds. - Accept the spellings models emit: `duration_ms` / `ms` / `wait_ms` / `sleep_ms`, plus `seconds` / `secs` variants, numeric strings included. - Reject a `wait` carrying neither duration nor condition with INVALID_PARAMS instead of reporting a success that never waited. - Raise the cap to 60 minutes, in step with AgentWait's MAX_TIMEOUT_MS, and report `ms` / `requested_ms` / `clamped` so a shortened wait says so. - Race the sleep against the turn's cancellation token, and stop `call_impl` from folding Cancelled into an `ok: false` envelope — a stop during a long pause must not wait out the pause, nor look like a tool error the model tries to recover from. - Serve duration waits before session resolution: a pure pause touches no page, and agents pace themselves long before they open a browser. - Resolve `{ condition, timeout_ms }`: the condition always wins and any duration bounds it, rather than sleeping and never looking at the page. Condition waits keep their previous 15s default, now configurable. - Document all of it in the tool description, and point repeating schedules at the Cron tool, which ends the turn instead of pinning it open.
1 parent 47c033b commit 5386473

2 files changed

Lines changed: 461 additions & 14 deletions

File tree

‎src/crates/assembly/core/src/agentic/tools/browser_control/actions.rs‎

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ use serde_json::{json, Value};
77
use std::collections::BTreeMap;
88
use tokio::sync::broadcast;
99

10+
/// Upper bound for an explicit `wait` duration. Pacing waits ("check again in
11+
/// 30 minutes") are a legitimate agent pattern, so the ceiling is generous;
12+
/// it exists only so a nonsense duration cannot wedge the session forever.
13+
/// Kept in step with `AgentWaitTool::MAX_TIMEOUT_MS`.
14+
pub const MAX_WAIT_MS: u64 = 60 * 60 * 1_000;
15+
16+
/// How long a `wait { condition }` runs before giving up when the caller does
17+
/// not say. Matches the previous hard-coded lifecycle and selector budgets.
18+
pub const DEFAULT_CONDITION_TIMEOUT_MS: u64 = 15_000;
19+
1020
/// Result of waiting for a CDP `Page.lifecycleEvent`.
1121
enum LifecycleOutcome {
1222
/// One of the requested lifecycle names fired in time. Carries the name
@@ -1054,17 +1064,35 @@ impl<'a> BrowserActions<'a> {
10541064
}
10551065

10561066
/// Wait for a duration or a condition.
1067+
///
1068+
/// Callers that can observe cancellation should sleep themselves rather
1069+
/// than routing a plain duration through here — see ControlHub's
1070+
/// `browser.wait`, which owns the cancellable, session-free duration path.
1071+
///
1072+
/// `condition_timeout_ms` bounds the condition wait; it defaults to
1073+
/// [`DEFAULT_CONDITION_TIMEOUT_MS`] and is ignored for duration waits.
10571074
pub async fn wait(
10581075
&self,
10591076
duration_ms: Option<u64>,
10601077
condition: Option<&str>,
1078+
condition_timeout_ms: Option<u64>,
10611079
) -> BitFunResult<Value> {
10621080
if let Some(ms) = duration_ms {
1063-
let clamped = ms.min(30_000);
1081+
let clamped = ms.min(MAX_WAIT_MS);
10641082
tokio::time::sleep(std::time::Duration::from_millis(clamped)).await;
1065-
return Ok(json!({ "success": true, "action": "wait", "ms": clamped }));
1083+
return Ok(json!({
1084+
"success": true,
1085+
"action": "wait",
1086+
"ms": clamped,
1087+
"requested_ms": ms,
1088+
"clamped": clamped != ms,
1089+
}));
10661090
}
10671091
if let Some(cond) = condition {
1092+
let timeout_ms = condition_timeout_ms
1093+
.filter(|ms| *ms > 0)
1094+
.unwrap_or(DEFAULT_CONDITION_TIMEOUT_MS)
1095+
.min(MAX_WAIT_MS);
10681096
match cond {
10691097
"networkidle" | "load" | "domcontentloaded" => {
10701098
// Phase 1: replace the previous "sleep 2s and hope" with
@@ -1085,7 +1113,7 @@ impl<'a> BrowserActions<'a> {
10851113
"domcontentloaded" => &["DOMContentLoaded", "load"],
10861114
_ => &["load"],
10871115
};
1088-
let outcome = wait_for_lifecycle(&mut events, None, wanted, 15_000).await;
1116+
let outcome = wait_for_lifecycle(&mut events, None, wanted, timeout_ms).await;
10891117
let (success, lifecycle_event, timed_out) = match outcome {
10901118
LifecycleOutcome::Reached(n) => (true, Some(n), false),
10911119
LifecycleOutcome::Timeout => (false, None, true),
@@ -1097,23 +1125,39 @@ impl<'a> BrowserActions<'a> {
10971125
"condition": cond,
10981126
"lifecycle_event": lifecycle_event,
10991127
"timed_out": timed_out,
1128+
"timeout_ms": timeout_ms,
11001129
}));
11011130
}
11021131
selector => {
1132+
const POLL_INTERVAL_MS: u64 = 500;
11031133
let js = Self::element_exists_js(selector);
1104-
for _ in 0..30 {
1134+
let deadline =
1135+
tokio::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
1136+
loop {
11051137
let result = self.evaluate(&js).await?;
11061138
let found = result
11071139
.get("result")
11081140
.and_then(|r| r.get("value"))
11091141
.and_then(|v| v.as_bool())
11101142
.unwrap_or(false);
11111143
if found {
1112-
return Ok(
1113-
json!({ "success": true, "action": "wait", "condition": cond }),
1114-
);
1144+
return Ok(json!({
1145+
"success": true,
1146+
"action": "wait",
1147+
"condition": cond,
1148+
"timeout_ms": timeout_ms,
1149+
}));
11151150
}
1116-
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1151+
let remaining = deadline
1152+
.saturating_duration_since(tokio::time::Instant::now())
1153+
.as_millis() as u64;
1154+
if remaining == 0 {
1155+
break;
1156+
}
1157+
tokio::time::sleep(std::time::Duration::from_millis(
1158+
remaining.min(POLL_INTERVAL_MS),
1159+
))
1160+
.await;
11171161
}
11181162
return Err(structured_error(
11191163
ErrorCode::Timeout,
@@ -1123,7 +1167,18 @@ impl<'a> BrowserActions<'a> {
11231167
}
11241168
}
11251169
}
1126-
Ok(json!({ "success": true, "action": "wait" }))
1170+
// No duration and no condition: there is nothing to wait for. Reporting
1171+
// success here used to make a mis-keyed duration (`ms` instead of
1172+
// `duration_ms`) look like a completed wait that in fact returned
1173+
// instantly, so the agent silently skipped its pause.
1174+
Err(structured_error(
1175+
ErrorCode::InvalidParams,
1176+
"wait requires a duration or a condition",
1177+
&[
1178+
"Pass `duration_ms` (alias `ms`) to pause, e.g. { \"duration_ms\": 1800000 } for 30 minutes",
1179+
"Or pass `condition`: 'load' | 'domcontentloaded' | 'networkidle' | a CSS/@ref selector",
1180+
],
1181+
))
11271182
}
11281183

11291184
// ── Capture ────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)