Skip to content

Commit 845adc4

Browse files
committed
runtime: stop fabricating tool-call success on backend-less runners (honest UnavailableToolExecutor)
1 parent 4cafa9c commit 845adc4

3 files changed

Lines changed: 103 additions & 7 deletions

File tree

crates/repl-core/src/dsl/reasoning_builtins.rs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ pub async fn builtin_reason(args: &[DslValue], ctx: &ReasoningBuiltinContext) ->
6464
use symbi_runtime::reasoning::circuit_breaker::CircuitBreakerRegistry;
6565
use symbi_runtime::reasoning::context_manager::DefaultContextManager;
6666
use symbi_runtime::reasoning::conversation::{Conversation, ConversationMessage};
67-
use symbi_runtime::reasoning::executor::DefaultActionExecutor;
67+
use symbi_runtime::reasoning::executor::UnavailableToolExecutor;
6868
use symbi_runtime::reasoning::loop_types::{BufferedJournal, LoopConfig};
6969
use symbi_runtime::reasoning::policy_bridge::DefaultPolicyGate;
7070
use symbi_runtime::reasoning::reasoning_loop::ReasoningLoopRunner;
@@ -83,7 +83,7 @@ pub async fn builtin_reason(args: &[DslValue], ctx: &ReasoningBuiltinContext) ->
8383
let runner = ReasoningLoopRunner {
8484
provider: Arc::clone(provider),
8585
policy_gate,
86-
executor: Arc::new(DefaultActionExecutor::default()),
86+
executor: Arc::new(UnavailableToolExecutor),
8787
context_manager: Arc::new(DefaultContextManager::default()),
8888
circuit_breakers: Arc::new(CircuitBreakerRegistry::default()),
8989
journal: Arc::new(BufferedJournal::new(1000)),
@@ -221,14 +221,23 @@ pub async fn builtin_tool_call(
221221
}
222222
};
223223

224-
// In a full setup, this would go through ToolInvocationEnforcer.
225-
// For now, return a structured result indicating the tool call was made.
224+
// There is no tool backend wired into the DSL runtime yet (MCP-backed
225+
// execution via ToolInvocationEnforcer is a planned feature). Return an
226+
// honest `not_executed` result rather than fabricating a success — callers
227+
// must not assume the tool actually ran.
226228
let mut result = HashMap::new();
227229
result.insert("tool".to_string(), DslValue::String(name));
228230
result.insert("arguments".to_string(), DslValue::String(arguments));
229231
result.insert(
230232
"status".to_string(),
231-
DslValue::String("executed".to_string()),
233+
DslValue::String("not_executed".to_string()),
234+
);
235+
result.insert(
236+
"reason".to_string(),
237+
DslValue::String(
238+
"no tool backend configured (MCP-backed tool execution is not yet available)"
239+
.to_string(),
240+
),
232241
);
233242

234243
Ok(DslValue::Map(result))

crates/runtime/src/reasoning/executor.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
178226
pub 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();

src/commands/run.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ pub async fn run(matches: &ArgMatches) {
8888
use symbi_runtime::reasoning::circuit_breaker::CircuitBreakerRegistry;
8989
use symbi_runtime::reasoning::context_manager::DefaultContextManager;
9090
use symbi_runtime::reasoning::conversation::{Conversation, ConversationMessage};
91-
use symbi_runtime::reasoning::executor::DefaultActionExecutor;
91+
use symbi_runtime::reasoning::executor::UnavailableToolExecutor;
9292
use symbi_runtime::reasoning::loop_types::{BufferedJournal, LoopConfig};
9393
use symbi_runtime::reasoning::policy_bridge::DefaultPolicyGate;
9494
use symbi_runtime::reasoning::reasoning_loop::ReasoningLoopRunner;
@@ -122,7 +122,10 @@ pub async fn run(matches: &ArgMatches) {
122122
let runner = ReasoningLoopRunner {
123123
provider,
124124
policy_gate,
125-
executor: Arc::new(DefaultActionExecutor::default()),
125+
// `symbi run` has no tool backend wired, so use the honest executor
126+
// that surfaces tool calls as errors rather than fabricating success.
127+
// (MCP-backed execution is a separate, planned feature.)
128+
executor: Arc::new(UnavailableToolExecutor),
126129
context_manager: Arc::new(DefaultContextManager::default()),
127130
circuit_breakers: Arc::new(CircuitBreakerRegistry::default()),
128131
journal: Arc::new(BufferedJournal::new(1000)),

0 commit comments

Comments
 (0)