From 40512c7f48bd7069824b9f060f24db4816be6eba Mon Sep 17 00:00:00 2001 From: tsubasakong <185121705+tsubasakong@users.noreply.github.com> Date: Sat, 7 Mar 2026 16:55:23 -0800 Subject: [PATCH] Prevent fabricated follow-up after tool failures --- crates/openfang-runtime/src/agent_loop.rs | 77 +++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/crates/openfang-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs index 11fa8ea12..28ed4122f 100644 --- a/crates/openfang-runtime/src/agent_loop.rs +++ b/crates/openfang-runtime/src/agent_loop.rs @@ -51,6 +51,27 @@ const MAX_CONTINUATIONS: u32 = 5; /// Maximum message history size before auto-trimming to prevent context overflow. const MAX_HISTORY_MESSAGES: usize = 20; +/// Extra guidance injected after failed tool calls to prevent fabricated follow-up actions. +const TOOL_ERROR_GUIDANCE: &str = + "[System: One or more tool calls failed. Failed tools did not produce usable data. Do NOT invent missing results, cite nonexistent search results, or pretend failed tools succeeded. If your next steps depend on a failed tool, either retry with a materially different approach or explain the failure to the user and stop. Do not write files, store memory, or take downstream actions based on failed tool outputs.]"; + +fn append_tool_error_guidance(tool_result_blocks: &mut Vec) { + let has_tool_error = tool_result_blocks.iter().any(|block| { + matches!( + block, + ContentBlock::ToolResult { + is_error: true, + .. + } + ) + }); + if has_tool_error { + tool_result_blocks.push(ContentBlock::Text { + text: TOOL_ERROR_GUIDANCE.to_string(), + }); + } +} + /// Strip a provider prefix from a model ID before sending to the API. /// /// Many models are stored as `provider/org/model` (e.g. `openrouter/google/gemini-2.5-flash`) @@ -690,6 +711,8 @@ pub async fn run_agent_loop( }); } + append_tool_error_guidance(&mut tool_result_blocks); + // Detect approval denials and inject guidance to prevent infinite retry loops let denial_count = tool_result_blocks.iter().filter(|b| { matches!(b, ContentBlock::ToolResult { content, is_error: true, .. } @@ -1613,6 +1636,8 @@ pub async fn run_agent_loop_streaming( }); } + append_tool_error_guidance(&mut tool_result_blocks); + // Detect approval denials and inject guidance to prevent infinite retry loops let denial_count = tool_result_blocks.iter().filter(|b| { matches!(b, ContentBlock::ToolResult { content, is_error: true, .. } @@ -2202,6 +2227,58 @@ mod tests { ); } + #[tokio::test] + async fn test_tool_error_injects_no_fabrication_guidance() { + let memory = openfang_memory::MemorySubstrate::open_in_memory(0.01).unwrap(); + let agent_id = openfang_types::agent::AgentId::new(); + let mut session = openfang_memory::session::Session { + id: openfang_types::agent::SessionId::new(), + agent_id, + messages: Vec::new(), + context_window_tokens: 0, + label: None, + }; + let manifest = test_manifest(); + let driver: Arc = Arc::new(EmptyAfterToolUseDriver::new()); + + run_agent_loop( + &manifest, + "Do something with tools", + &mut session, + &memory, + driver, + &[], // no tools registered — the tool call will fail, which is fine + None, + None, + None, + None, + None, + None, + None, + None, // on_phase + None, // media_engine + None, // tts_engine + None, // docker_config + None, // hooks + None, // context_window_tokens + None, // process_manager + ) + .await + .expect("Loop should complete without error"); + + let guidance_seen = session.messages.iter().any(|msg| match &msg.content { + MessageContent::Blocks(blocks) => blocks.iter().any(|block| { + matches!(block, ContentBlock::Text { text } if text == TOOL_ERROR_GUIDANCE) + }), + _ => false, + }); + + assert!( + guidance_seen, + "Expected tool error guidance in session messages after failed tool call" + ); + } + #[tokio::test] async fn test_empty_response_max_tokens_returns_fallback() { let memory = openfang_memory::MemorySubstrate::open_in_memory(0.01).unwrap();