From 8e3c97ae9c632593aba03d630201b5f4bdd803ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 11:58:46 +0800 Subject: [PATCH] fix(agent): keep standalone greetings tool-free --- CHANGELOG.md | 6 ++ README.md | 4 ++ core/src/agent/completion_runtime.rs | 9 ++- core/src/agent/tests.rs | 23 +++++++ core/src/tools/mod.rs | 1 + core/src/tools/selector.rs | 93 +++++++++++++++++++++++++++- 6 files changed, 134 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e01a17aa..528deef6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Kept standalone conversational greetings tool-free and prevented them from + triggering synthetic continuation turns, while retaining the normal tool + surface for greetings that also contain an action request. + ## [5.3.5] - 2026-07-17 ### Added diff --git a/README.md b/README.md index 892452ad..382a5396 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,10 @@ object-only backend that cannot execute it. | MCP | `mcp____` | Namespaced tools owned by their source manager | | Dynamic workflows | `dynamic_workflow` | Explicitly registered A3S Flow-backed, replayable per-turn workflows | +Standalone greetings are conversational turns: the model receives no tool +definitions and a friendly response is not converted into a synthetic +continuation. A greeting that also asks for work keeps the normal tool surface. + The built-in skill registry starts empty; skills come from configured directories, `AgentDir`, inline host input, or live registration. The model-visible `program` tool executes JavaScript in QuickJS, not arbitrary diff --git a/core/src/agent/completion_runtime.rs b/core/src/agent/completion_runtime.rs index aca3b005..d8610b0e 100644 --- a/core/src/agent/completion_runtime.rs +++ b/core/src/agent/completion_runtime.rs @@ -64,7 +64,9 @@ impl AgentLoop { candidate_text }; - if !force_terminal && self.inject_continuation_if_needed(state, turn, &candidate_text) { + if !force_terminal + && self.inject_continuation_if_needed(state, turn, &candidate_text, effective_prompt) + { return CompletionFlow::Continue; } @@ -152,7 +154,12 @@ impl AgentLoop { state: &mut ExecutionLoopState, turn: usize, candidate_text: &str, + effective_prompt: &str, ) -> bool { + if crate::tools::is_standalone_conversation(effective_prompt) { + return false; + } + let looks_incomplete = Self::looks_incomplete(candidate_text); if looks_incomplete && state.repeated_incomplete_response(candidate_text) { tracing::warn!( diff --git a/core/src/agent/tests.rs b/core/src/agent/tests.rs index ae4a4de8..21d6a03d 100644 --- a/core/src/agent/tests.rs +++ b/core/src/agent/tests.rs @@ -3302,6 +3302,29 @@ async fn test_repeated_incomplete_text_converges_after_one_continuation() { assert_eq!(mock_client.call_count.load(Ordering::SeqCst), 2); } +#[tokio::test] +async fn test_standalone_greeting_does_not_trigger_continuation() { + let greeting = "I'll be happy to help. What would you like to work on?"; + let mock_client = Arc::new(MockLlmClient::new(vec![ + MockLlmClient::text_response(greeting), + MockLlmClient::text_response("This response must not be consumed."), + ])); + let agent = AgentLoop::new( + mock_client.clone(), + Arc::new(ToolExecutor::new("/tmp".to_string())), + test_tool_context(), + AgentConfig { + max_continuation_turns: 20, + max_tool_rounds: 100, + ..Default::default() + }, + ); + + let result = agent.execute(&[], "你好", None).await.unwrap(); + assert_eq!(result.text, greeting); + assert_eq!(mock_client.call_count.load(Ordering::SeqCst), 1); +} + #[tokio::test] async fn test_agent_multiple_tools_single_turn() { // LLM returns 2 tool calls in one response diff --git a/core/src/tools/mod.rs b/core/src/tools/mod.rs index ddf7a3d8..ee1e3656 100644 --- a/core/src/tools/mod.rs +++ b/core/src/tools/mod.rs @@ -35,6 +35,7 @@ pub(crate) use invocation::{ }; pub use program_tool::{ProgramTool, MAX_PROGRAM_SCRIPT_SOURCE_BYTES}; pub use registry::ToolRegistry; +pub(crate) use selector::is_standalone_conversation; pub use selector::{select_tools_for_messages, select_tools_for_prompt}; pub use task::{ parallel_task_params_schema, task_params_schema, ParallelTaskParams, ParallelTaskTool, diff --git a/core/src/tools/selector.rs b/core/src/tools/selector.rs index 36ed676c..5be8cff4 100644 --- a/core/src/tools/selector.rs +++ b/core/src/tools/selector.rs @@ -64,6 +64,39 @@ const PROGRAM_TERMS: &[&str] = &[ const MCP_TERMS: &[&str] = &["mcp", "external tool", "external server", "外部工具"]; +const STANDALONE_CONVERSATION: &[&str] = &[ + "hi", + "hi there", + "hello", + "hello there", + "hey", + "greetings", + "good morning", + "good afternoon", + "good evening", + "how are you", + "how's it going", + "hows it going", + "what's up", + "whats up", + "thanks", + "thank you", + "你好", + "您好", + "嗨", + "哈喽", + "哈啰", + "早", + "早上好", + "上午好", + "下午好", + "晚上好", + "在吗", + "你好吗", + "谢谢", + "多谢", +]; + /// Select the tools that should be exposed to the model for this turn. /// /// The executor still owns every registered tool. This function only trims the @@ -78,7 +111,7 @@ pub fn select_tools_for_messages( } pub fn select_tools_for_prompt(tools: &[ToolDefinition], prompt: &str) -> Vec { - if tools.is_empty() { + if tools.is_empty() || is_standalone_conversation(prompt) { return Vec::new(); } @@ -114,6 +147,32 @@ pub fn select_tools_for_prompt(tools: &[ToolDefinition], prompt: &str) -> Vec bool { + let normalized = prompt + .trim() + .trim_matches(is_conversational_boundary) + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase(); + + STANDALONE_CONVERSATION.contains(&normalized.as_str()) +} + +fn is_conversational_boundary(character: char) -> bool { + character.is_ascii_punctuation() + || character.is_whitespace() + || matches!( + character, + '。' | ',' | '、' | '!' | '?' | '…' | '~' | '👋' + ) +} + fn should_include_mcp_tool( name: &str, name_lower: &str, @@ -264,6 +323,38 @@ mod tests { assert!(!names.contains(&"mcp__github__create_issue")); } + #[test] + fn standalone_greetings_do_not_expose_tools() { + let tools = defs(&["read", "grep", "bash", "web_search", "task"]); + + for prompt in [ + "hi", + "Hello!", + "how are you?", + "你好", + "您好!", + "在吗?", + "谢谢", + ] { + assert!( + select_tools_for_prompt(&tools, prompt).is_empty(), + "standalone greeting exposed tools: {prompt}" + ); + } + } + + #[test] + fn greeting_with_an_action_keeps_relevant_tools() { + let selected = select_tools_for_prompt( + &defs(&["read", "grep", "web_search"]), + "Hello! Inspect this repository for the parser implementation.", + ); + let names: Vec<_> = selected.iter().map(|tool| tool.name.as_str()).collect(); + + assert!(names.contains(&"read")); + assert!(names.contains(&"grep")); + } + #[test] fn program_terms_enable_program_tool() { let selected = select_tools_for_prompt(