From e6029e8cec4edf7085fd10ae449979cfea5d4ba9 Mon Sep 17 00:00:00 2001 From: Chummy Date: Wed, 18 Feb 2026 22:40:22 +0800 Subject: [PATCH] test(channels): guard max_tool_iterations wiring for channel runtime (#817) * test(channels): add regression coverage for configured tool iteration limits * chore(ci): refresh checks after first-interaction workflow fix * test(channels): reconcile merged runtime-route and iteration tests --- docs/config-reference.md | 11 +++ src/channels/mod.rs | 147 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) diff --git a/docs/config-reference.md b/docs/config-reference.md index 1ead83f..dbc5221 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -16,6 +16,17 @@ Config file path: | `default_model` | `anthropic/claude-sonnet-4-6` | model routed through selected provider | | `default_temperature` | `0.7` | model temperature | +## `[agent]` + +| Key | Default | Purpose | +|---|---|---| +| `max_tool_iterations` | `10` | Maximum tool-call loop turns per user message across CLI, gateway, and channels | + +Notes: + +- Setting `max_tool_iterations = 0` falls back to safe default `10`. +- If a channel message exceeds this value, the runtime returns: `Agent exceeded maximum tool iterations ()`. + ## `[gateway]` | Key | Default | Purpose | diff --git a/src/channels/mod.rs b/src/channels/mod.rs index b189139..ab2e8cf 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -2044,6 +2044,48 @@ mod tests { } } + struct IterativeToolProvider { + required_tool_iterations: usize, + } + + impl IterativeToolProvider { + fn completed_tool_iterations(messages: &[ChatMessage]) -> usize { + messages + .iter() + .filter(|msg| msg.role == "user" && msg.content.contains("[Tool results]")) + .count() + } + } + + #[async_trait::async_trait] + impl Provider for IterativeToolProvider { + async fn chat_with_system( + &self, + _system_prompt: Option<&str>, + _message: &str, + _model: &str, + _temperature: f64, + ) -> anyhow::Result { + Ok(tool_call_payload()) + } + + async fn chat_with_history( + &self, + messages: &[ChatMessage], + _model: &str, + _temperature: f64, + ) -> anyhow::Result { + let completed_iterations = Self::completed_tool_iterations(messages); + if completed_iterations >= self.required_tool_iterations { + Ok(format!( + "Completed after {completed_iterations} tool iterations." + )) + } else { + Ok(tool_call_payload()) + } + } + } + #[derive(Default)] struct HistoryCaptureProvider { calls: std::sync::Mutex>>, @@ -2401,6 +2443,111 @@ mod tests { ); } + #[tokio::test] + async fn process_channel_message_respects_configured_max_tool_iterations_above_default() { + let channel_impl = Arc::new(RecordingChannel::default()); + let channel: Arc = channel_impl.clone(); + + let mut channels_by_name = HashMap::new(); + channels_by_name.insert(channel.name().to_string(), channel); + + let runtime_ctx = Arc::new(ChannelRuntimeContext { + channels_by_name: Arc::new(channels_by_name), + provider: Arc::new(IterativeToolProvider { + required_tool_iterations: 11, + }), + default_provider: Arc::new("test-provider".to_string()), + memory: Arc::new(NoopMemory), + tools_registry: Arc::new(vec![Box::new(MockPriceTool)]), + observer: Arc::new(NoopObserver), + system_prompt: Arc::new("test-system-prompt".to_string()), + model: Arc::new("test-model".to_string()), + temperature: 0.0, + auto_save_memory: false, + max_tool_iterations: 12, + min_relevance_score: 0.0, + conversation_histories: Arc::new(Mutex::new(HashMap::new())), + provider_cache: Arc::new(Mutex::new(HashMap::new())), + route_overrides: Arc::new(Mutex::new(HashMap::new())), + api_key: None, + api_url: None, + reliability: Arc::new(crate::config::ReliabilityConfig::default()), + provider_runtime_options: providers::ProviderRuntimeOptions::default(), + workspace_dir: Arc::new(std::env::temp_dir()), + }); + + process_channel_message( + runtime_ctx, + traits::ChannelMessage { + id: "msg-iter-success".to_string(), + sender: "alice".to_string(), + reply_target: "chat-iter-success".to_string(), + content: "Loop until done".to_string(), + channel: "test-channel".to_string(), + timestamp: 1, + }, + ) + .await; + + let sent_messages = channel_impl.sent_messages.lock().await; + assert_eq!(sent_messages.len(), 1); + assert!(sent_messages[0].starts_with("chat-iter-success:")); + assert!(sent_messages[0].contains("Completed after 11 tool iterations.")); + assert!(!sent_messages[0].contains("⚠️ Error:")); + } + + #[tokio::test] + async fn process_channel_message_reports_configured_max_tool_iterations_limit() { + let channel_impl = Arc::new(RecordingChannel::default()); + let channel: Arc = channel_impl.clone(); + + let mut channels_by_name = HashMap::new(); + channels_by_name.insert(channel.name().to_string(), channel); + + let runtime_ctx = Arc::new(ChannelRuntimeContext { + channels_by_name: Arc::new(channels_by_name), + provider: Arc::new(IterativeToolProvider { + required_tool_iterations: 20, + }), + default_provider: Arc::new("test-provider".to_string()), + memory: Arc::new(NoopMemory), + tools_registry: Arc::new(vec![Box::new(MockPriceTool)]), + observer: Arc::new(NoopObserver), + system_prompt: Arc::new("test-system-prompt".to_string()), + model: Arc::new("test-model".to_string()), + temperature: 0.0, + auto_save_memory: false, + max_tool_iterations: 3, + min_relevance_score: 0.0, + conversation_histories: Arc::new(Mutex::new(HashMap::new())), + provider_cache: Arc::new(Mutex::new(HashMap::new())), + route_overrides: Arc::new(Mutex::new(HashMap::new())), + api_key: None, + api_url: None, + reliability: Arc::new(crate::config::ReliabilityConfig::default()), + provider_runtime_options: providers::ProviderRuntimeOptions::default(), + workspace_dir: Arc::new(std::env::temp_dir()), + }); + + process_channel_message( + runtime_ctx, + traits::ChannelMessage { + id: "msg-iter-fail".to_string(), + sender: "bob".to_string(), + reply_target: "chat-iter-fail".to_string(), + content: "Loop forever".to_string(), + channel: "test-channel".to_string(), + timestamp: 2, + }, + ) + .await; + + let sent_messages = channel_impl.sent_messages.lock().await; + assert_eq!(sent_messages.len(), 1); + assert!(sent_messages[0].starts_with("chat-iter-fail:")); + assert!(sent_messages[0].contains("⚠️ Error: Agent exceeded maximum tool iterations (3)")); + } + struct NoopMemory; #[async_trait::async_trait]