@@ -60,6 +60,7 @@ use crate::guardian::approval::GuardianApprover;
6060use crate :: guardian:: reviewer:: { GuardianReviewer , StaticReviewer } ;
6161use crate :: guardian:: Guardian ;
6262use crate :: mcp:: McpConnectionManager ;
63+ use crate :: prompts:: CollaborationModeKind ;
6364use crate :: session:: { SessionId , SharedStore } ;
6465use crate :: subagents:: display_agent_path_for_session;
6566use 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) ]
688697fn 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)`.
16931775fn 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