Skip to content

Commit 8e3c97a

Browse files
committed
fix(agent): keep standalone greetings tool-free
1 parent 88fba0a commit 8e3c97a

6 files changed

Lines changed: 134 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- Kept standalone conversational greetings tool-free and prevented them from
13+
triggering synthetic continuation turns, while retaining the normal tool
14+
surface for greetings that also contain an action request.
15+
1016
## [5.3.5] - 2026-07-17
1117

1218
### Added

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,10 @@ object-only backend that cannot execute it.
271271
| MCP | `mcp__<server>__<tool>` | Namespaced tools owned by their source manager |
272272
| Dynamic workflows | `dynamic_workflow` | Explicitly registered A3S Flow-backed, replayable per-turn workflows |
273273

274+
Standalone greetings are conversational turns: the model receives no tool
275+
definitions and a friendly response is not converted into a synthetic
276+
continuation. A greeting that also asks for work keeps the normal tool surface.
277+
274278
The built-in skill registry starts empty; skills come from configured
275279
directories, `AgentDir`, inline host input, or live registration. The
276280
model-visible `program` tool executes JavaScript in QuickJS, not arbitrary

core/src/agent/completion_runtime.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ impl AgentLoop {
6464
candidate_text
6565
};
6666

67-
if !force_terminal && self.inject_continuation_if_needed(state, turn, &candidate_text) {
67+
if !force_terminal
68+
&& self.inject_continuation_if_needed(state, turn, &candidate_text, effective_prompt)
69+
{
6870
return CompletionFlow::Continue;
6971
}
7072

@@ -152,7 +154,12 @@ impl AgentLoop {
152154
state: &mut ExecutionLoopState,
153155
turn: usize,
154156
candidate_text: &str,
157+
effective_prompt: &str,
155158
) -> bool {
159+
if crate::tools::is_standalone_conversation(effective_prompt) {
160+
return false;
161+
}
162+
156163
let looks_incomplete = Self::looks_incomplete(candidate_text);
157164
if looks_incomplete && state.repeated_incomplete_response(candidate_text) {
158165
tracing::warn!(

core/src/agent/tests.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3302,6 +3302,29 @@ async fn test_repeated_incomplete_text_converges_after_one_continuation() {
33023302
assert_eq!(mock_client.call_count.load(Ordering::SeqCst), 2);
33033303
}
33043304

3305+
#[tokio::test]
3306+
async fn test_standalone_greeting_does_not_trigger_continuation() {
3307+
let greeting = "I'll be happy to help. What would you like to work on?";
3308+
let mock_client = Arc::new(MockLlmClient::new(vec![
3309+
MockLlmClient::text_response(greeting),
3310+
MockLlmClient::text_response("This response must not be consumed."),
3311+
]));
3312+
let agent = AgentLoop::new(
3313+
mock_client.clone(),
3314+
Arc::new(ToolExecutor::new("/tmp".to_string())),
3315+
test_tool_context(),
3316+
AgentConfig {
3317+
max_continuation_turns: 20,
3318+
max_tool_rounds: 100,
3319+
..Default::default()
3320+
},
3321+
);
3322+
3323+
let result = agent.execute(&[], "你好", None).await.unwrap();
3324+
assert_eq!(result.text, greeting);
3325+
assert_eq!(mock_client.call_count.load(Ordering::SeqCst), 1);
3326+
}
3327+
33053328
#[tokio::test]
33063329
async fn test_agent_multiple_tools_single_turn() {
33073330
// LLM returns 2 tool calls in one response

core/src/tools/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ pub(crate) use invocation::{
3535
};
3636
pub use program_tool::{ProgramTool, MAX_PROGRAM_SCRIPT_SOURCE_BYTES};
3737
pub use registry::ToolRegistry;
38+
pub(crate) use selector::is_standalone_conversation;
3839
pub use selector::{select_tools_for_messages, select_tools_for_prompt};
3940
pub use task::{
4041
parallel_task_params_schema, task_params_schema, ParallelTaskParams, ParallelTaskTool,

core/src/tools/selector.rs

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,39 @@ const PROGRAM_TERMS: &[&str] = &[
6464

6565
const MCP_TERMS: &[&str] = &["mcp", "external tool", "external server", "外部工具"];
6666

67+
const STANDALONE_CONVERSATION: &[&str] = &[
68+
"hi",
69+
"hi there",
70+
"hello",
71+
"hello there",
72+
"hey",
73+
"greetings",
74+
"good morning",
75+
"good afternoon",
76+
"good evening",
77+
"how are you",
78+
"how's it going",
79+
"hows it going",
80+
"what's up",
81+
"whats up",
82+
"thanks",
83+
"thank you",
84+
"你好",
85+
"您好",
86+
"嗨",
87+
"哈喽",
88+
"哈啰",
89+
"早",
90+
"早上好",
91+
"上午好",
92+
"下午好",
93+
"晚上好",
94+
"在吗",
95+
"你好吗",
96+
"谢谢",
97+
"多谢",
98+
];
99+
67100
/// Select the tools that should be exposed to the model for this turn.
68101
///
69102
/// The executor still owns every registered tool. This function only trims the
@@ -78,7 +111,7 @@ pub fn select_tools_for_messages(
78111
}
79112

80113
pub fn select_tools_for_prompt(tools: &[ToolDefinition], prompt: &str) -> Vec<ToolDefinition> {
81-
if tools.is_empty() {
114+
if tools.is_empty() || is_standalone_conversation(prompt) {
82115
return Vec::new();
83116
}
84117

@@ -114,6 +147,32 @@ pub fn select_tools_for_prompt(tools: &[ToolDefinition], prompt: &str) -> Vec<To
114147
selected
115148
}
116149

150+
/// Return whether a prompt is only a short conversational acknowledgement.
151+
///
152+
/// This is deliberately exact after whitespace and terminal-punctuation
153+
/// normalization. A greeting that also contains an action must retain the
154+
/// ordinary tool surface.
155+
pub(crate) fn is_standalone_conversation(prompt: &str) -> bool {
156+
let normalized = prompt
157+
.trim()
158+
.trim_matches(is_conversational_boundary)
159+
.split_whitespace()
160+
.collect::<Vec<_>>()
161+
.join(" ")
162+
.to_lowercase();
163+
164+
STANDALONE_CONVERSATION.contains(&normalized.as_str())
165+
}
166+
167+
fn is_conversational_boundary(character: char) -> bool {
168+
character.is_ascii_punctuation()
169+
|| character.is_whitespace()
170+
|| matches!(
171+
character,
172+
'。' | ',' | '、' | '!' | '?' | '…' | '~' | '👋'
173+
)
174+
}
175+
117176
fn should_include_mcp_tool(
118177
name: &str,
119178
name_lower: &str,
@@ -264,6 +323,38 @@ mod tests {
264323
assert!(!names.contains(&"mcp__github__create_issue"));
265324
}
266325

326+
#[test]
327+
fn standalone_greetings_do_not_expose_tools() {
328+
let tools = defs(&["read", "grep", "bash", "web_search", "task"]);
329+
330+
for prompt in [
331+
"hi",
332+
"Hello!",
333+
"how are you?",
334+
"你好",
335+
"您好!",
336+
"在吗?",
337+
"谢谢",
338+
] {
339+
assert!(
340+
select_tools_for_prompt(&tools, prompt).is_empty(),
341+
"standalone greeting exposed tools: {prompt}"
342+
);
343+
}
344+
}
345+
346+
#[test]
347+
fn greeting_with_an_action_keeps_relevant_tools() {
348+
let selected = select_tools_for_prompt(
349+
&defs(&["read", "grep", "web_search"]),
350+
"Hello! Inspect this repository for the parser implementation.",
351+
);
352+
let names: Vec<_> = selected.iter().map(|tool| tool.name.as_str()).collect();
353+
354+
assert!(names.contains(&"read"));
355+
assert!(names.contains(&"grep"));
356+
}
357+
267358
#[test]
268359
fn program_terms_enable_program_tool() {
269360
let selected = select_tools_for_prompt(

0 commit comments

Comments
 (0)