Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,10 @@ object-only backend that cannot execute it.
| MCP | `mcp__<server>__<tool>` | 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
Expand Down
9 changes: 8 additions & 1 deletion core/src/agent/completion_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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!(
Expand Down
23 changes: 23 additions & 0 deletions core/src/agent/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions core/src/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
93 changes: 92 additions & 1 deletion core/src/tools/selector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -78,7 +111,7 @@ pub fn select_tools_for_messages(
}

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

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

/// Return whether a prompt is only a short conversational acknowledgement.
///
/// This is deliberately exact after whitespace and terminal-punctuation
/// normalization. A greeting that also contains an action must retain the
/// ordinary tool surface.
pub(crate) fn is_standalone_conversation(prompt: &str) -> bool {
let normalized = prompt
.trim()
.trim_matches(is_conversational_boundary)
.split_whitespace()
.collect::<Vec<_>>()
.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,
Expand Down Expand Up @@ -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(
Expand Down
Loading