diff --git a/Cargo.lock b/Cargo.lock index 8fd8eac..d26a8f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1465,7 +1465,7 @@ dependencies = [ [[package]] name = "ras-agent" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "chrono", @@ -1497,7 +1497,7 @@ dependencies = [ [[package]] name = "ras-browser" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-cdp", @@ -1515,7 +1515,7 @@ dependencies = [ [[package]] name = "ras-cdp" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "chromiumoxide", @@ -1534,7 +1534,7 @@ dependencies = [ [[package]] name = "ras-cli" -version = "3.3.0" +version = "3.4.0" dependencies = [ "anyhow", "clap", @@ -1564,7 +1564,7 @@ dependencies = [ [[package]] name = "ras-cloud" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-errors", @@ -1581,7 +1581,7 @@ dependencies = [ [[package]] name = "ras-config" -version = "3.3.0" +version = "3.4.0" dependencies = [ "dotenvy", "once_cell", @@ -1595,7 +1595,7 @@ dependencies = [ [[package]] name = "ras-cosmium" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-cdp", @@ -1612,7 +1612,7 @@ dependencies = [ [[package]] name = "ras-daemon" -version = "3.3.0" +version = "3.4.0" dependencies = [ "anyhow", "dotenvy", @@ -1632,7 +1632,7 @@ dependencies = [ [[package]] name = "ras-dom" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "chromiumoxide", @@ -1652,7 +1652,7 @@ dependencies = [ [[package]] name = "ras-errors" -version = "3.3.0" +version = "3.4.0" dependencies = [ "serde", "thiserror", @@ -1660,7 +1660,7 @@ dependencies = [ [[package]] name = "ras-events" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-broadcast", "async-trait", @@ -1678,7 +1678,7 @@ dependencies = [ [[package]] name = "ras-filesystem" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-errors", @@ -1693,7 +1693,7 @@ dependencies = [ [[package]] name = "ras-judge" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "image", @@ -1708,7 +1708,7 @@ dependencies = [ [[package]] name = "ras-llm" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-errors", @@ -1722,7 +1722,7 @@ dependencies = [ [[package]] name = "ras-llm-anthropic" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "chrono", @@ -1745,7 +1745,7 @@ dependencies = [ [[package]] name = "ras-llm-bedrock" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-errors", @@ -1762,7 +1762,7 @@ dependencies = [ [[package]] name = "ras-llm-cerebras" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-errors", @@ -1780,7 +1780,7 @@ dependencies = [ [[package]] name = "ras-llm-cloud" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-errors", @@ -1797,7 +1797,7 @@ dependencies = [ [[package]] name = "ras-llm-deepseek" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-errors", @@ -1815,7 +1815,7 @@ dependencies = [ [[package]] name = "ras-llm-google" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-errors", @@ -1832,7 +1832,7 @@ dependencies = [ [[package]] name = "ras-llm-groq" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-errors", @@ -1850,7 +1850,7 @@ dependencies = [ [[package]] name = "ras-llm-langchain" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-errors", @@ -1867,7 +1867,7 @@ dependencies = [ [[package]] name = "ras-llm-mistral" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-errors", @@ -1885,7 +1885,7 @@ dependencies = [ [[package]] name = "ras-llm-oci" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-errors", @@ -1902,7 +1902,7 @@ dependencies = [ [[package]] name = "ras-llm-ollama" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-errors", @@ -1919,7 +1919,7 @@ dependencies = [ [[package]] name = "ras-llm-openai" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-errors", @@ -1936,7 +1936,7 @@ dependencies = [ [[package]] name = "ras-llm-openrouter" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-errors", @@ -1954,7 +1954,7 @@ dependencies = [ [[package]] name = "ras-llm-vercel" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-errors", @@ -1972,7 +1972,7 @@ dependencies = [ [[package]] name = "ras-mcp" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-errors", @@ -1989,7 +1989,7 @@ dependencies = [ [[package]] name = "ras-recording" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "image", @@ -2004,7 +2004,7 @@ dependencies = [ [[package]] name = "ras-sandbox" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-errors", @@ -2017,7 +2017,7 @@ dependencies = [ [[package]] name = "ras-skills" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-errors", @@ -2034,7 +2034,7 @@ dependencies = [ [[package]] name = "ras-telemetry" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-errors", @@ -2048,7 +2048,7 @@ dependencies = [ [[package]] name = "ras-tokens" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "chrono", @@ -2066,7 +2066,7 @@ dependencies = [ [[package]] name = "ras-tools" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "indexmap", @@ -2088,7 +2088,7 @@ dependencies = [ [[package]] name = "ras-types" -version = "3.3.0" +version = "3.4.0" dependencies = [ "chrono", "indexmap", @@ -2104,7 +2104,7 @@ dependencies = [ [[package]] name = "ras-validation" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-errors", @@ -2119,7 +2119,7 @@ dependencies = [ [[package]] name = "ras-watchdogs" -version = "3.3.0" +version = "3.4.0" dependencies = [ "async-trait", "ras-browser", diff --git a/Cargo.toml b/Cargo.toml index d9705c5..30e9b30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,7 @@ exclude = ["examples", "tests", "docs", "scripts"] default-members = ["ras-cli", "ras-daemon"] [workspace.package] -version = "3.3.0" +version = "3.4.0" edition = "2024" rust-version = "1.95.0" license = "MIT" diff --git a/docs/superpowers/plans/2026-05-30-multitenant-phase3-target-binding.md b/docs/superpowers/plans/2026-05-30-multitenant-phase3-target-binding.md new file mode 100644 index 0000000..1b5bb71 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-multitenant-phase3-target-binding.md @@ -0,0 +1,14 @@ +# Multi-Tenant Phase 3 — Agent Target Binding (AR-3) + +**Goal:** Agents drive an explicitly bound tab, not "the last focused tab" — removing the cross-session race. Builtins read the target from `ToolContext`; `RunAgent` can bind a target; single-user keeps the `focused_target` fallback (only in `run_step`, never in builtins). + +**Repo rules:** ≤200 LOC/file, no `//` comments, no `.unwrap()` in non-test code, conventional commits. + +## One cohesive change (interdependent — implement together) +- `ras-tools/src/domain/registry.rs`: add `pub target: Option` to `ToolContext`. +- 7 builtins (navigate, click ×2, type_text, scroll, screenshot, press_and_hold ×2): replace `let target = ctx.browser.focused_target().await?;` with `let target = ctx.target.clone().ok_or_else(|| AppError::NotFound("no active target".into()))?;`. +- `ras-agent/src/application/run_agent.rs`: add `bound_target: Option` field (default None) + `with_target(mut self, t: TargetId) -> Self`. +- `ras-agent/src/application/run_step.rs`: source target from the bound target first, else `focused_target().ok()`; pass it into `ToolContext.target`. +- Fix the 2 test `ToolContext` construction sites to set `target`. + +**Scope note:** AR-3's "required target / no fallback" is satisfied at the builtin layer (no builtin calls `focused_target`). The single-user `focused_target` fallback survives only in `run_step`; the multi-tenant `SessionManager` (Phase 5) always binds, so MT never hits the fallback. diff --git a/ras-agent/src/application/run_agent.rs b/ras-agent/src/application/run_agent.rs index 87a1d31..99deb7b 100644 --- a/ras-agent/src/application/run_agent.rs +++ b/ras-agent/src/application/run_agent.rs @@ -6,7 +6,7 @@ use ras_errors::AppError; use ras_events::EventBus; use ras_llm::{ChatMessage, LlmClient}; use ras_tools::domain::registry::ActionRegistry; -use ras_types::{AgentId, StepId}; +use ras_types::{AgentId, StepId, TargetId}; use crate::application::render_step_message::render_step_message; use crate::application::run_step::RunStep; @@ -19,6 +19,7 @@ pub struct RunAgent { pub agent: AgentId, pub task: String, pub max_steps: u32, + pub bound_target: Option, pub primary_llm: Arc, pub fallback_llm: Option>, pub registry: Arc, @@ -39,6 +40,7 @@ impl RunAgent { agent: AgentId::new(), task: task.into(), max_steps: 25, + bound_target: None, primary_llm: llm, fallback_llm: None, registry, @@ -48,6 +50,12 @@ impl RunAgent { } } + #[must_use] + pub fn with_target(mut self, target: TargetId) -> Self { + self.bound_target = Some(target); + self + } + #[must_use] pub fn with_dom_extractor(mut self, extractor: Arc) -> Self { self.dom_extractor = Some(extractor); @@ -74,6 +82,7 @@ impl RunAgent { self.browser.clone(), self.events.clone(), self.dom_extractor.clone(), + self.bound_target.clone(), ); let mut detector = ActionLoopDetector::new(); let mut history = AgentHistory { diff --git a/ras-agent/src/application/run_step.rs b/ras-agent/src/application/run_step.rs index 1b54a1d..ee8121f 100644 --- a/ras-agent/src/application/run_step.rs +++ b/ras-agent/src/application/run_step.rs @@ -8,7 +8,7 @@ use ras_errors::AppError; use ras_events::EventBus; use ras_llm::{ChatMessage, ChatResponse, InvokeOptions, LlmClient}; use ras_tools::domain::registry::{ActionRegistry, ToolContext}; -use ras_types::{ActionResult, StepId}; +use ras_types::{ActionResult, StepId, TargetId}; use url::Url; use crate::application::compute_action_hash::compute_action_hash; @@ -27,6 +27,7 @@ pub struct RunStep { browser: Arc, events: Arc, dom_extractor: Option>, + bound_target: Option, } impl RunStep { @@ -38,6 +39,7 @@ impl RunStep { browser: Arc, events: Arc, dom_extractor: Option>, + bound_target: Option, ) -> Self { Self { primary_llm: primary, @@ -46,6 +48,7 @@ impl RunStep { browser, events, dom_extractor, + bound_target, } } @@ -68,7 +71,10 @@ impl RunStep { let output = parse_agent_output(&response)?; log_decision(step.0, &output); - let target = self.browser.focused_target().await.ok(); + let target = match &self.bound_target { + Some(t) => Some(t.clone()), + None => self.browser.focused_target().await.ok(), + }; let page_url = match &target { Some(t) => self .browser @@ -101,6 +107,7 @@ impl RunStep { break; }; let ctx = ToolContext { + target: target.clone(), browser: self.browser.clone(), events: self.events.clone(), page_url: page_url.clone(), diff --git a/ras-tools/src/domain/registry.rs b/ras-tools/src/domain/registry.rs index 0331e20..754ca33 100644 --- a/ras-tools/src/domain/registry.rs +++ b/ras-tools/src/domain/registry.rs @@ -5,13 +5,14 @@ use ras_cdp::BrowserPort; use ras_dom::ClickableElement; use ras_errors::AppError; use ras_events::EventBus; -use ras_types::ActionName; +use ras_types::{ActionName, TargetId}; use url::Url; use crate::domain::action::{RegisteredAction, ToolHandler}; #[derive(Clone)] pub struct ToolContext { + pub target: Option, pub browser: Arc, pub events: Arc, pub page_url: Option, @@ -22,6 +23,7 @@ pub struct ToolContext { impl std::fmt::Debug for ToolContext { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ToolContext") + .field("target", &self.target) .field("page_url", &self.page_url) .finish() } diff --git a/ras-tools/src/infrastructure/builtin/click.rs b/ras-tools/src/infrastructure/builtin/click.rs index 396bd30..c34661c 100644 --- a/ras-tools/src/infrastructure/builtin/click.rs +++ b/ras-tools/src/infrastructure/builtin/click.rs @@ -57,7 +57,10 @@ impl ToolHandler for ClickElementAction { xpath = %element.xpath, "click_element resolved" ); - let target = ctx.browser.focused_target().await?; + let target = ctx + .target + .clone() + .ok_or_else(|| AppError::NotFound("no active target".into()))?; ctx.browser .click_node(&target, element.backend_node_id) .await?; @@ -101,7 +104,10 @@ impl ToolHandler for ClickCoordinateAction { ) -> Result { let p: CoordParams = serde_json::from_value(params) .map_err(|e| AppError::ValidationError(format!("click_coord params: {e}")))?; - let target = ctx.browser.focused_target().await?; + let target = ctx + .target + .clone() + .ok_or_else(|| AppError::NotFound("no active target".into()))?; ctx.browser.click_at(&target, p.x, p.y).await?; Ok(ActionResult::ok(format!("clicked at ({}, {})", p.x, p.y))) } diff --git a/ras-tools/src/infrastructure/builtin/navigate.rs b/ras-tools/src/infrastructure/builtin/navigate.rs index 70e59ec..5e42a24 100644 --- a/ras-tools/src/infrastructure/builtin/navigate.rs +++ b/ras-tools/src/infrastructure/builtin/navigate.rs @@ -50,7 +50,10 @@ impl ToolHandler for NavigateAction { let p: Params = serde_json::from_value(params) .map_err(|e| AppError::ValidationError(format!("navigate params: {e}")))?; let url = parse_and_check(&p.url)?; - let target = ctx.browser.focused_target().await?; + let target = ctx + .target + .clone() + .ok_or_else(|| AppError::NotFound("no active target".into()))?; ctx.browser.navigate(&target, &url).await?; Ok(ActionResult::ok(format!("navigated to {url}"))) } diff --git a/ras-tools/src/infrastructure/builtin/press_and_hold.rs b/ras-tools/src/infrastructure/builtin/press_and_hold.rs index 77ef187..1af63df 100644 --- a/ras-tools/src/infrastructure/builtin/press_and_hold.rs +++ b/ras-tools/src/infrastructure/builtin/press_and_hold.rs @@ -58,7 +58,10 @@ impl ToolHandler for PressAndHoldCoordinateAction { let p: CoordParams = serde_json::from_value(params) .map_err(|e| AppError::ValidationError(format!("press_and_hold_coordinate: {e}")))?; let hold = clamp_hold(p.ms.unwrap_or(DEFAULT_HOLD_MS)); - let target = ctx.browser.focused_target().await?; + let target = ctx + .target + .clone() + .ok_or_else(|| AppError::NotFound("no active target".into()))?; ctx.browser.mouse_hold(&target, p.x, p.y, hold).await?; Ok(ActionResult::ok(format!( "held ({}, {}) for {} ms (humanized: approach + jitter)", @@ -132,7 +135,10 @@ impl ToolHandler for PressAndHoldElementAction { ms = hold, "press_and_hold_element resolved" ); - let target = ctx.browser.focused_target().await?; + let target = ctx + .target + .clone() + .ok_or_else(|| AppError::NotFound("no active target".into()))?; ctx.browser.mouse_hold(&target, cx, cy, hold).await?; Ok(ActionResult::ok(format!( "held element {} at ({}, {}) for {} ms (humanized: approach + jitter)", diff --git a/ras-tools/src/infrastructure/builtin/screenshot.rs b/ras-tools/src/infrastructure/builtin/screenshot.rs index f51d0fd..d38a728 100644 --- a/ras-tools/src/infrastructure/builtin/screenshot.rs +++ b/ras-tools/src/infrastructure/builtin/screenshot.rs @@ -28,7 +28,10 @@ impl ToolHandler for ScreenshotAction { _params: serde_json::Value, ctx: ToolContext, ) -> Result { - let target = ctx.browser.focused_target().await?; + let target = ctx + .target + .clone() + .ok_or_else(|| AppError::NotFound("no active target".into()))?; let bytes = ctx .browser .screenshot(&target, ScreenshotFormat::Png) diff --git a/ras-tools/src/infrastructure/builtin/scroll.rs b/ras-tools/src/infrastructure/builtin/scroll.rs index d01a650..c001e75 100644 --- a/ras-tools/src/infrastructure/builtin/scroll.rs +++ b/ras-tools/src/infrastructure/builtin/scroll.rs @@ -50,7 +50,10 @@ impl ToolHandler for ScrollAction { .map_err(|e| AppError::ValidationError(format!("scroll params: {e}")))?; let dir = if p.direction == "up" { -1 } else { 1 }; let dy = p.amount * dir; - let target = ctx.browser.focused_target().await?; + let target = ctx + .target + .clone() + .ok_or_else(|| AppError::NotFound("no active target".into()))?; let _ = ctx .browser .evaluate(&target, &format!("window.scrollBy(0, {dy});")) diff --git a/ras-tools/src/infrastructure/builtin/type_text.rs b/ras-tools/src/infrastructure/builtin/type_text.rs index 39ff562..8d671c5 100644 --- a/ras-tools/src/infrastructure/builtin/type_text.rs +++ b/ras-tools/src/infrastructure/builtin/type_text.rs @@ -47,7 +47,10 @@ impl ToolHandler for TypeTextAction { ) -> Result { let p: Params = serde_json::from_value(params) .map_err(|e| AppError::ValidationError(format!("type params: {e}")))?; - let target = ctx.browser.focused_target().await?; + let target = ctx + .target + .clone() + .ok_or_else(|| AppError::NotFound("no active target".into()))?; if let Some(idx) = p.index { let element = ctx .clickables diff --git a/ras-tools/tests/click_resolves_clickable_index.rs b/ras-tools/tests/click_resolves_clickable_index.rs index 9059cc8..62c9f4b 100644 --- a/ras-tools/tests/click_resolves_clickable_index.rs +++ b/ras-tools/tests/click_resolves_clickable_index.rs @@ -99,6 +99,7 @@ fn clickable(index: u32, backend: i64) -> ClickableElement { fn ctx_with(browser: Arc, clickables: Vec) -> ToolContext { ToolContext { + target: Some(TargetId("t-1".into())), browser, events: Arc::new(BroadcastBus::default()), page_url: None, diff --git a/ras-tools/tests/e2e_grounding.rs b/ras-tools/tests/e2e_grounding.rs index 38c16ce..017dc75 100644 --- a/ras-tools/tests/e2e_grounding.rs +++ b/ras-tools/tests/e2e_grounding.rs @@ -62,6 +62,7 @@ async fn click_and_type_resolve_through_clickable_map() { .expect("button clickable"); let ctx = ToolContext { + target: Some(target.clone()), browser: browser.clone(), events: Arc::new(BroadcastBus::default()), page_url: None,