Skip to content

Commit 7f2ef3c

Browse files
Merge pull request #29 from browser-use/decodex-goal-parity-main
Implement goal runtime parity
2 parents ee8a366 + b003ccd commit 7f2ef3c

14 files changed

Lines changed: 2717 additions & 221 deletions

File tree

crates/browser-use-agent/src/entrypoint/provider.rs

Lines changed: 216 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ use crate::guardian::approval::GuardianApprover;
6060
use crate::guardian::reviewer::{GuardianReviewer, StaticReviewer};
6161
use crate::guardian::Guardian;
6262
use crate::mcp::McpConnectionManager;
63+
use crate::prompts::CollaborationModeKind;
6364
use crate::session::{SessionId, SharedStore};
6465
use crate::subagents::display_agent_path_for_session;
6566
use crate::subagents::mailbox::Mailbox;
@@ -595,14 +596,21 @@ fn resolve_provider_with_python(
595596
// It yields the text-only sampler; we then attach the FUSED dispatch path so a
596597
// model tool-call actually EXECUTES (through the registry + orchestrator) and
597598
// its output re-enters the prompt via `recorder`, and the loop re-samples.
598-
let driver = build_sampling_driver(transport, Arc::clone(&sink), ctx, max_retries).with_fusion(
599-
build_tool_dispatcher_with_cwd(
599+
let goal_store = build_goal_store(&user_input);
600+
let goals_enabled = goal_runtime_enabled(config, &user_input);
601+
let mut driver = build_sampling_driver(transport, Arc::clone(&sink), ctx, max_retries);
602+
if goals_enabled {
603+
driver = driver.with_goal_store(goal_store.clone());
604+
}
605+
let driver = driver.with_fusion(
606+
build_tool_dispatcher_with_cwd_and_goal_store(
600607
python_backend,
601608
config,
602609
user_input,
603610
tool_cwd,
604611
tool_artifact_root,
605612
sink,
613+
goal_store,
606614
),
607615
recorder,
608616
);
@@ -685,13 +693,35 @@ fn build_tool_dispatcher(
685693
)
686694
}
687695

696+
#[cfg(test)]
688697
fn build_tool_dispatcher_with_cwd(
689698
python_backend: Arc<dyn PythonBackend>,
690699
config: &ProviderRunConfig,
691700
user_input: Option<(SharedStore, SessionId)>,
692701
tool_cwd: std::path::PathBuf,
693702
tool_artifact_root: std::path::PathBuf,
694703
event_sink: Arc<dyn EventSink>,
704+
) -> Arc<RealToolDispatcher> {
705+
let goal_store = build_goal_store(&user_input);
706+
build_tool_dispatcher_with_cwd_and_goal_store(
707+
python_backend,
708+
config,
709+
user_input,
710+
tool_cwd,
711+
tool_artifact_root,
712+
event_sink,
713+
goal_store,
714+
)
715+
}
716+
717+
fn build_tool_dispatcher_with_cwd_and_goal_store(
718+
python_backend: Arc<dyn PythonBackend>,
719+
config: &ProviderRunConfig,
720+
user_input: Option<(SharedStore, SessionId)>,
721+
tool_cwd: std::path::PathBuf,
722+
tool_artifact_root: std::path::PathBuf,
723+
event_sink: Arc<dyn EventSink>,
724+
goal_store: Arc<crate::tools::handlers::goal::GoalStore>,
695725
) -> Arc<RealToolDispatcher> {
696726
use crate::tools::handlers::apply_patch::{ApplyPatchRequest, ApplyPatchTool};
697727
use crate::tools::handlers::browser::{BrowserRequest, BrowserTool};
@@ -860,9 +890,11 @@ fn build_tool_dispatcher_with_cwd(
860890
// Goal tools (`get_goal` / `create_goal` / `update_goal`). All three share ONE
861891
// `GoalStore` (the event-sourced `GoalManager` + its durable `goal.*` event
862892
// sink), registered behind the same registry seam so a `create_goal` is
863-
// visible to a later `get_goal`/`update_goal`. The model always SEES the tools
864-
// (no config gate).
865-
register_goal_tools(&mut reg, &user_input);
893+
// visible to a later `get_goal`/`update_goal`. Codex only exposes these for
894+
// persisted, non-plan, non-review turns; mirror that gate here.
895+
if goal_runtime_enabled(config, &user_input) {
896+
register_goal_tools(&mut reg, goal_store);
897+
}
866898

867899
// `mcp`: register the MCP bridge ONLY when servers are configured. An empty
868900
// `mcp_servers` map (the default) registers nothing, preserving prior
@@ -1676,6 +1708,56 @@ fn agent_path_depth(agent_path: &str) -> i32 {
16761708
trimmed.split('/').count().saturating_sub(1) as i32
16771709
}
16781710

1711+
fn build_goal_store(
1712+
user_input: &Option<(SharedStore, SessionId)>,
1713+
) -> Arc<crate::tools::handlers::goal::GoalStore> {
1714+
use crate::tools::handlers::goal::GoalStore;
1715+
1716+
match user_input {
1717+
Some((store, sid)) => {
1718+
let sink: Arc<dyn EventSink> = Arc::new(SubagentStoreSink {
1719+
store: store.clone(),
1720+
});
1721+
Arc::new(GoalStore::from_shared_store(
1722+
sid.as_str().to_string(),
1723+
sink,
1724+
store.clone(),
1725+
))
1726+
}
1727+
None => Arc::new(GoalStore::new(String::new(), Arc::new(NoopSubagentSink))),
1728+
}
1729+
}
1730+
1731+
fn goal_runtime_enabled(
1732+
config: &ProviderRunConfig,
1733+
user_input: &Option<(SharedStore, SessionId)>,
1734+
) -> bool {
1735+
if config.options.collaboration_mode == CollaborationModeKind::Plan {
1736+
return false;
1737+
}
1738+
let Some((store, sid)) = user_input else {
1739+
return false;
1740+
};
1741+
session_allows_goal_tools(store, sid.as_str())
1742+
}
1743+
1744+
fn session_allows_goal_tools(store: &SharedStore, session_id: &str) -> bool {
1745+
let events = store
1746+
.lock()
1747+
.unwrap()
1748+
.events_for_session(session_id)
1749+
.unwrap_or_default();
1750+
!events.iter().any(|event| {
1751+
event.event_type == "session.review_mode"
1752+
&& event
1753+
.payload
1754+
.get("review_tool_restrictions")
1755+
.and_then(|restrictions| restrictions.get("goals"))
1756+
.and_then(serde_json::Value::as_bool)
1757+
== Some(false)
1758+
})
1759+
}
1760+
16791761
/// Register the goal tool family (`get_goal`, `create_goal`, `update_goal`) into
16801762
/// `reg`, all sharing ONE [`GoalStore`].
16811763
///
@@ -1692,34 +1774,17 @@ fn agent_path_depth(agent_path: &str) -> i32 {
16921774
/// + the session id from the threaded `(SharedStore, SessionId)`.
16931775
fn register_goal_tools<S, A>(
16941776
reg: &mut crate::tools::registry::ToolRegistry<S, A>,
1695-
user_input: &Option<(SharedStore, SessionId)>,
1777+
store: Arc<crate::tools::handlers::goal::GoalStore>,
16961778
) where
16971779
S: crate::tools::sandbox::SandboxProvider,
16981780
A: crate::tools::runtime::Approver,
16991781
{
17001782
use crate::tools::handlers::goal::{
1701-
CreateGoalRequest, CreateGoalTool, GetGoalRequest, GetGoalTool, GoalStore,
1702-
UpdateGoalRequest, UpdateGoalTool,
1783+
CreateGoalRequest, CreateGoalTool, GetGoalRequest, GetGoalTool, UpdateGoalRequest,
1784+
UpdateGoalTool,
17031785
};
17041786
use crate::tools::registry::definitions;
17051787

1706-
// Durable event sink + session scope: the store-backed sink on the live run
1707-
// path (durable `goal.*` events appended to the session log), a no-op when no
1708-
// session store is wired (tests/headless). Reuses the subagent tools' sinks
1709-
// (both are `crate::events::EventSink`).
1710-
let (sink, session_id): (Arc<dyn EventSink>, String) = match user_input {
1711-
Some((store, sid)) => (
1712-
Arc::new(SubagentStoreSink {
1713-
store: store.clone(),
1714-
}),
1715-
sid.as_str().to_string(),
1716-
),
1717-
None => (Arc::new(NoopSubagentSink), String::new()),
1718-
};
1719-
1720-
// One shared store so create_goal is visible to a later get/update_goal.
1721-
let store = Arc::new(GoalStore::new(session_id, sink));
1722-
17231788
// get_goal: read-only snapshot (parallel-safe).
17241789
reg.register::<_, GetGoalRequest>(
17251790
"get_goal",
@@ -2887,14 +2952,29 @@ mod tests {
28872952
assert!(parent_mail[0].content.contains("<subagent_notification>"));
28882953
}
28892954

2890-
/// The production dispatcher ALWAYS advertises the three goal tools
2891-
/// (`get_goal` / `create_goal` / `update_goal`) — the "engine B matches rival
2892-
/// A on the goals row" registration proof. Passes ONLY because
2893-
/// `register_goal_tools` is invoked inside `build_tool_dispatcher`.
2955+
/// The production dispatcher advertises the three goal tools
2956+
/// (`get_goal` / `create_goal` / `update_goal`) for persisted non-plan,
2957+
/// non-review sessions.
28942958
#[test]
2895-
fn goal_tools_are_registered_in_the_dispatcher() {
2959+
fn goal_tools_are_registered_for_persisted_default_session() {
2960+
use browser_use_store::Store;
2961+
28962962
let config = ProviderRunConfig::new(ProviderBackend::Fake, "fake-model");
2897-
let dispatcher = build_tool_dispatcher(Arc::new(MarkerPythonBackend), &config, None);
2963+
let dir = tempfile::tempdir().expect("tempdir");
2964+
let store: SharedStore = Arc::new(std::sync::Mutex::new(
2965+
Store::open(dir.path()).expect("open store"),
2966+
));
2967+
let session_id = store
2968+
.lock()
2969+
.unwrap()
2970+
.create_session(None, dir.path())
2971+
.expect("create session row")
2972+
.id;
2973+
let dispatcher = build_tool_dispatcher(
2974+
Arc::new(MarkerPythonBackend),
2975+
&config,
2976+
Some((store, SessionId(session_id))),
2977+
);
28982978
let names: Vec<&str> = dispatcher
28992979
.tool_specs()
29002980
.iter()
@@ -2947,6 +3027,104 @@ mod tests {
29473027
assert!(!layer.can_write);
29483028
}
29493029

3030+
#[test]
3031+
fn goal_tools_are_hidden_without_persisted_session() {
3032+
let config = ProviderRunConfig::new(ProviderBackend::Fake, "fake-model");
3033+
let dispatcher = build_tool_dispatcher(Arc::new(MarkerPythonBackend), &config, None);
3034+
let names: Vec<&str> = dispatcher
3035+
.tool_specs()
3036+
.iter()
3037+
.map(|s| s.name.as_str())
3038+
.collect();
3039+
for tool in ["get_goal", "create_goal", "update_goal"] {
3040+
assert!(
3041+
!names.contains(&tool),
3042+
"{tool} must not be registered without a persisted session; got {names:?}"
3043+
);
3044+
}
3045+
}
3046+
3047+
#[test]
3048+
fn goal_tools_are_hidden_in_plan_mode() {
3049+
use browser_use_store::Store;
3050+
3051+
let options = crate::config_overrides::AgentRunOptions::default()
3052+
.with_collaboration_mode(CollaborationModeKind::Plan);
3053+
let config =
3054+
ProviderRunConfig::new(ProviderBackend::Fake, "fake-model").with_options(options);
3055+
let dir = tempfile::tempdir().expect("tempdir");
3056+
let store: SharedStore = Arc::new(std::sync::Mutex::new(
3057+
Store::open(dir.path()).expect("open store"),
3058+
));
3059+
let session_id = store
3060+
.lock()
3061+
.unwrap()
3062+
.create_session(None, dir.path())
3063+
.expect("create session row")
3064+
.id;
3065+
let dispatcher = build_tool_dispatcher(
3066+
Arc::new(MarkerPythonBackend),
3067+
&config,
3068+
Some((store, SessionId(session_id))),
3069+
);
3070+
let names: Vec<&str> = dispatcher
3071+
.tool_specs()
3072+
.iter()
3073+
.map(|s| s.name.as_str())
3074+
.collect();
3075+
for tool in ["get_goal", "create_goal", "update_goal"] {
3076+
assert!(
3077+
!names.contains(&tool),
3078+
"{tool} must not be registered in plan mode; got {names:?}"
3079+
);
3080+
}
3081+
}
3082+
3083+
#[test]
3084+
fn goal_tools_are_hidden_for_review_restricted_sessions() {
3085+
use browser_use_store::Store;
3086+
3087+
let config = ProviderRunConfig::new(ProviderBackend::Fake, "fake-model");
3088+
let dir = tempfile::tempdir().expect("tempdir");
3089+
let store: SharedStore = Arc::new(std::sync::Mutex::new(
3090+
Store::open(dir.path()).expect("open store"),
3091+
));
3092+
let session_id = store
3093+
.lock()
3094+
.unwrap()
3095+
.create_session(None, dir.path())
3096+
.expect("create session row")
3097+
.id;
3098+
store
3099+
.lock()
3100+
.unwrap()
3101+
.append_event(
3102+
&session_id,
3103+
"session.review_mode",
3104+
serde_json::json!({
3105+
"kind": "review",
3106+
"review_tool_restrictions": { "goals": false },
3107+
}),
3108+
)
3109+
.expect("append review marker");
3110+
let dispatcher = build_tool_dispatcher(
3111+
Arc::new(MarkerPythonBackend),
3112+
&config,
3113+
Some((store, SessionId(session_id))),
3114+
);
3115+
let names: Vec<&str> = dispatcher
3116+
.tool_specs()
3117+
.iter()
3118+
.map(|s| s.name.as_str())
3119+
.collect();
3120+
for tool in ["get_goal", "create_goal", "update_goal"] {
3121+
assert!(
3122+
!names.contains(&tool),
3123+
"{tool} must not be registered for review-restricted sessions; got {names:?}"
3124+
);
3125+
}
3126+
}
3127+
29503128
/// A `create_goal` call routes from the PRODUCTION registration through the
29513129
/// dispatcher (BY NAME via `dispatch_ordered`, the same path the turn loop
29523130
/// uses for a model tool-call) into the shared `GoalStore`: a follow-up
@@ -3036,17 +3214,17 @@ mod tests {
30363214
let created = dispatch_one(
30373215
&dispatcher,
30383216
"create_goal",
3039-
serde_json::json!({"text": "ship the goals row", "token_budget": 1000}),
3217+
serde_json::json!({"objective": "ship the goals row", "token_budget": 1000}),
30403218
)
30413219
.await;
3042-
assert_eq!(created["active"], true);
3043-
assert_eq!(created["text"], "ship the goals row");
3044-
assert_eq!(created["token_budget"], 1000);
3220+
assert_eq!(created["goal"]["objective"], "ship the goals row");
3221+
assert_eq!(created["goal"]["status"], "active");
3222+
assert_eq!(created["goal"]["tokenBudget"], 1000);
30453223

30463224
// get_goal observes the SAME shared state (the store is shared).
30473225
let fetched = dispatch_one(&dispatcher, "get_goal", serde_json::json!({})).await;
3048-
assert_eq!(fetched["text"], "ship the goals row");
3049-
assert_eq!(fetched["active"], true);
3226+
assert_eq!(fetched["goal"]["objective"], "ship the goals row");
3227+
assert_eq!(fetched["goal"]["status"], "active");
30503228

30513229
// A durable `goal.created` event landed for this session (proving the
30523230
// store-backed sink — not just the tool listing — is wired).
@@ -3063,7 +3241,7 @@ mod tests {
30633241
serde_json::json!({"status": "complete"}),
30643242
)
30653243
.await;
3066-
assert_eq!(updated["status"], "complete");
3244+
assert_eq!(updated["goal"]["status"], "complete");
30673245
let kinds = event_types(&store, &session_id);
30683246
assert!(
30693247
kinds.iter().any(|k| k == "goal.updated"),

0 commit comments

Comments
 (0)