@@ -174,6 +174,54 @@ async fn execute_tool_call(name: &str, arguments: &str) -> Result<String, String
174174 ) )
175175}
176176
177+ /// An [`ActionExecutor`] for runners that have **no tool backend wired**.
178+ ///
179+ /// It advertises no tools (so the model is not offered any) and, if a tool
180+ /// call is nonetheless proposed, returns a clear `is_error` observation
181+ /// instead of fabricating a success. This is the honest default for entry
182+ /// points like `symbi run` and the DSL `reason()` builtin, which do not yet
183+ /// construct an MCP client / [`EnforcedActionExecutor`]. It exists so those
184+ /// paths never tell the model a tool "executed successfully" when nothing ran.
185+ ///
186+ /// Contrast: [`DefaultActionExecutor`] is a parallel-dispatch executor whose
187+ /// per-call result is an echo placeholder (useful as a test double, not for
188+ /// production tool execution); [`EnforcedActionExecutor`] performs real,
189+ /// enforcer-gated execution when a tool backend is available.
190+ #[ derive( Default ) ]
191+ pub struct UnavailableToolExecutor ;
192+
193+ #[ async_trait]
194+ impl ActionExecutor for UnavailableToolExecutor {
195+ async fn execute_actions (
196+ & self ,
197+ actions : & [ ProposedAction ] ,
198+ _config : & LoopConfig ,
199+ _circuit_breakers : & CircuitBreakerRegistry ,
200+ ) -> Vec < Observation > {
201+ actions
202+ . iter ( )
203+ . filter_map ( |action| match action {
204+ ProposedAction :: ToolCall { call_id, name, .. } => Some ( Observation {
205+ source : name. clone ( ) ,
206+ content : format ! (
207+ "Tool '{}' was not executed: this runner has no tool backend \
208+ configured (MCP-backed tool execution is not yet available). \
209+ Reason about the task and respond without tool results.",
210+ name
211+ ) ,
212+ is_error : true ,
213+ call_id : Some ( call_id. clone ( ) ) ,
214+ metadata : Default :: default ( ) ,
215+ } ) ,
216+ _ => None ,
217+ } )
218+ . collect ( )
219+ }
220+
221+ // tool_definitions() falls back to the trait default (empty) — advertise
222+ // no tools, so the model isn't offered capabilities the runner can't run.
223+ }
224+
177225/// An executor that delegates to a real ToolInvocationEnforcer.
178226pub struct EnforcedActionExecutor {
179227 enforcer : std:: sync:: Arc < dyn crate :: integrations:: tool_invocation:: ToolInvocationEnforcer > ,
@@ -351,6 +399,42 @@ mod tests {
351399 assert_eq ! ( obs[ 0 ] . call_id. as_deref( ) , Some ( "c1" ) ) ;
352400 }
353401
402+ #[ tokio:: test]
403+ async fn test_unavailable_tool_executor_reports_error_not_fake_success ( ) {
404+ let executor = UnavailableToolExecutor ;
405+ let config = LoopConfig :: default ( ) ;
406+ let circuit_breakers = CircuitBreakerRegistry :: default ( ) ;
407+
408+ // Advertises no tools.
409+ assert ! ( executor. tool_definitions( ) . is_empty( ) ) ;
410+
411+ let actions = vec ! [ ProposedAction :: ToolCall {
412+ call_id: "c1" . into( ) ,
413+ name: "search" . into( ) ,
414+ arguments: r#"{"q": "test"}"# . into( ) ,
415+ } ] ;
416+ let obs = executor
417+ . execute_actions ( & actions, & config, & circuit_breakers)
418+ . await ;
419+
420+ assert_eq ! ( obs. len( ) , 1 ) ;
421+ // The key property: a call is surfaced as an error, never as a
422+ // fabricated success.
423+ assert ! ( obs[ 0 ] . is_error) ;
424+ assert_eq ! ( obs[ 0 ] . source, "search" ) ;
425+ assert_eq ! ( obs[ 0 ] . call_id. as_deref( ) , Some ( "c1" ) ) ;
426+ assert ! ( obs[ 0 ] . content. contains( "not executed" ) ) ;
427+
428+ // Non-tool actions produce no observations.
429+ let non_tool = vec ! [ ProposedAction :: Respond {
430+ content: "hi" . into( ) ,
431+ } ] ;
432+ assert ! ( executor
433+ . execute_actions( & non_tool, & config, & circuit_breakers)
434+ . await
435+ . is_empty( ) ) ;
436+ }
437+
354438 #[ tokio:: test]
355439 async fn test_default_executor_parallel_dispatch ( ) {
356440 let executor = DefaultActionExecutor :: default ( ) ;
0 commit comments