Skip to content

Commit 275ddcb

Browse files
committed
fix(agent): suppress tool protocol when no tools are available
Treat empty effective tool sets as a no-tools turn across prompt assembly, provider request shape, and parser execution. Preserve reasoning-tag stripping while avoiding execution of tool-like output when no tools are available. Add focused regressions for native request shape, XML text preservation, prompt scaffolding, and channel protocol prompt behavior.
1 parent 28afeee commit 275ddcb

7 files changed

Lines changed: 350 additions & 44 deletions

File tree

β€Žcrates/zeroclaw-channels/src/orchestrator/mod.rsβ€Ž

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,9 @@ use zeroclaw_memory::{self, MEMORY_CONTEXT_CLOSE, MEMORY_CONTEXT_OPEN, Memory};
9393
use zeroclaw_providers::reliable::{scope_provider_fallback, take_last_provider_fallback};
9494
use zeroclaw_providers::{self, ChatMessage, Provider};
9595
use zeroclaw_runtime::agent::loop_::{
96-
clear_model_switch_request, get_model_switch_state, is_model_switch_requested,
97-
run_tool_call_loop, scope_session_key, scope_thread_id, scrub_credentials,
96+
build_tool_instructions_for_names, clear_model_switch_request, get_model_switch_state,
97+
is_model_switch_requested, run_tool_call_loop, scope_session_key, scope_thread_id,
98+
scrub_credentials,
9899
};
99100
use zeroclaw_runtime::approval::ApprovalManager;
100101
#[cfg(not(feature = "whatsapp-web"))]
@@ -5745,6 +5746,15 @@ pub async fn start_channels(
57455746
if !excluded.is_empty() && config.autonomy.level != AutonomyLevel::Full {
57465747
tool_descs.retain(|(name, _)| !excluded.iter().any(|ex| ex == name));
57475748
}
5749+
let effective_tool_names: HashSet<&str> = tools_registry
5750+
.iter()
5751+
.map(|tool| tool.name())
5752+
.filter(|name| {
5753+
config.autonomy.level == AutonomyLevel::Full
5754+
|| !excluded.iter().any(|excluded| excluded.as_str() == *name)
5755+
})
5756+
.collect();
5757+
tool_descs.retain(|(name, _)| effective_tool_names.contains(name));
57485758

57495759
let bootstrap_max_chars = if config.agent.compact_context {
57505760
Some(6000)
@@ -5766,8 +5776,9 @@ pub async fn start_channels(
57665776
config.agent.max_system_prompt_chars,
57675777
);
57685778
if !native_tools {
5769-
system_prompt.push_str(&zeroclaw_runtime::agent::loop_::build_tool_instructions(
5779+
system_prompt.push_str(&build_tool_instructions_for_names(
57705780
tools_registry.as_ref(),
5781+
&effective_tool_names,
57715782
));
57725783
}
57735784

@@ -6244,6 +6255,7 @@ mod tests {
62446255
use tempfile::TempDir;
62456256
use zeroclaw_memory::{Memory, MemoryCategory, SqliteMemory};
62466257
use zeroclaw_providers::{ChatMessage, Provider};
6258+
use zeroclaw_runtime::agent::loop_::build_tool_instructions;
62476259
use zeroclaw_runtime::observability::NoopObserver;
62486260
use zeroclaw_runtime::tools::{Tool, ToolResult};
62496261

@@ -10104,7 +10116,8 @@ BTC is currently around $65,000 based on latest tool output."#
1010410116
"build_system_prompt should not emit protocol block directly"
1010510117
);
1010610118

10107-
prompt.push_str(&zeroclaw_runtime::agent::loop_::build_tool_instructions(&[]));
10119+
let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
10120+
prompt.push_str(&build_tool_instructions(&tools_registry));
1010810121

1010910122
assert_eq!(
1011010123
prompt.matches("## Tool Use Protocol").count(),

β€Žcrates/zeroclaw-runtime/src/agent/agent.rsβ€Ž

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use std::time::Instant;
1919
use zeroclaw_config::schema::Config;
2020
use zeroclaw_memory::{self, Memory, MemoryCategory};
2121
use zeroclaw_providers::{self, ChatMessage, ChatRequest, ConversationMessage, Provider};
22+
use zeroclaw_tool_call_parser::strip_think_tags;
2223

2324
// Re-export TurnEvent from zeroclaw-types for backwards compatibility.
2425
pub use zeroclaw_api::agent::TurnEvent;
@@ -421,6 +422,24 @@ impl Agent {
421422
self.history.clear();
422423
}
423424

425+
fn should_send_tool_specs(&self) -> bool {
426+
self.tool_dispatcher.should_send_tool_specs() && !self.tool_specs.is_empty()
427+
}
428+
429+
fn parse_response_for_effective_tools(
430+
&self,
431+
response: &zeroclaw_providers::ChatResponse,
432+
) -> (String, Vec<ParsedToolCall>) {
433+
if self.tool_specs.is_empty() {
434+
return (
435+
strip_think_tags(&response.text.clone().unwrap_or_default()),
436+
Vec::new(),
437+
);
438+
}
439+
440+
self.tool_dispatcher.parse_response(response)
441+
}
442+
424443
pub fn set_memory_session_id(&mut self, session_id: Option<String>) {
425444
self.memory_session_id = session_id;
426445
}
@@ -1168,7 +1187,7 @@ impl Agent {
11681187
.chat(
11691188
ChatRequest {
11701189
messages: &messages,
1171-
tools: if self.tool_dispatcher.should_send_tool_specs() {
1190+
tools: if self.should_send_tool_specs() {
11721191
Some(&self.tool_specs)
11731192
} else {
11741193
None
@@ -1183,9 +1202,9 @@ impl Agent {
11831202
Err(err) => return Err(err),
11841203
};
11851204

1186-
let (text, calls) = self.tool_dispatcher.parse_response(&response);
1205+
let (text, calls) = self.parse_response_for_effective_tools(&response);
11871206
if calls.is_empty() {
1188-
let final_text = if text.is_empty() {
1207+
let final_text = if text.is_empty() && !self.tool_specs.is_empty() {
11891208
response.text.unwrap_or_default()
11901209
} else {
11911210
text
@@ -1353,7 +1372,7 @@ impl Agent {
13531372
let mut stream = self.provider.stream_chat(
13541373
zeroclaw_providers::ChatRequest {
13551374
messages: &messages,
1356-
tools: if self.tool_dispatcher.should_send_tool_specs() {
1375+
tools: if self.should_send_tool_specs() {
13571376
Some(&self.tool_specs)
13581377
} else {
13591378
None
@@ -1491,7 +1510,7 @@ impl Agent {
14911510
let chat_fut = self.provider.chat(
14921511
ChatRequest {
14931512
messages: &messages,
1494-
tools: if self.tool_dispatcher.should_send_tool_specs() {
1513+
tools: if self.should_send_tool_specs() {
14951514
Some(&self.tool_specs)
14961515
} else {
14971516
None
@@ -1531,9 +1550,9 @@ impl Agent {
15311550
.await;
15321551
}
15331552

1534-
let (text, mut calls) = self.tool_dispatcher.parse_response(&response);
1553+
let (text, mut calls) = self.parse_response_for_effective_tools(&response);
15351554
if calls.is_empty() {
1536-
let final_text = if text.is_empty() {
1555+
let final_text = if text.is_empty() && !self.tool_specs.is_empty() {
15371556
response.text.unwrap_or_default()
15381557
} else {
15391558
text

β€Žcrates/zeroclaw-runtime/src/agent/dispatcher.rsβ€Ž

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,11 @@ impl ToolDispatcher for XmlToolDispatcher {
128128
ConversationMessage::Chat(ChatMessage::user(format!("[Tool results]\n{content}")))
129129
}
130130

131-
fn prompt_instructions(&self, _tools: &[Box<dyn Tool>]) -> String {
131+
fn prompt_instructions(&self, tools: &[Box<dyn Tool>]) -> String {
132+
if tools.is_empty() {
133+
return String::new();
134+
}
135+
132136
let mut instructions = String::new();
133137
instructions.push_str("## Tool Use Protocol\n\n");
134138
instructions

β€Žcrates/zeroclaw-runtime/src/agent/loop_.rsβ€Ž

Lines changed: 129 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1276,32 +1276,45 @@ pub async fn run_tool_call_loop(
12761276
.as_ref()
12771277
.and_then(|usage| record_tool_loop_cost_usage(provider_name, model, usage));
12781278

1279-
let response_text = resp.text_or_empty().to_string();
1279+
let response_text = if tool_specs.is_empty() {
1280+
strip_think_tags(resp.text_or_empty())
1281+
} else {
1282+
resp.text_or_empty().to_string()
1283+
};
12801284
// First try native structured tool calls (OpenAI-format).
12811285
// Fall back to text-based parsing (XML tags, markdown blocks,
12821286
// GLM format) only if the provider returned no native calls β€”
12831287
// this ensures we support both native and prompt-guided models.
1284-
let mut calls: Vec<ParsedToolCall> = resp
1285-
.tool_calls
1286-
.iter()
1287-
.map(|call| ParsedToolCall {
1288-
name: call.name.clone(),
1289-
arguments: serde_json::from_str::<serde_json::Value>(&call.arguments)
1290-
.unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())),
1291-
tool_call_id: Some(call.id.clone()),
1292-
})
1293-
.collect();
1288+
let mut calls: Vec<ParsedToolCall> = if tool_specs.is_empty() {
1289+
Vec::new()
1290+
} else {
1291+
resp.tool_calls
1292+
.iter()
1293+
.map(|call| ParsedToolCall {
1294+
name: call.name.clone(),
1295+
arguments: serde_json::from_str::<serde_json::Value>(&call.arguments)
1296+
.unwrap_or_else(|_| {
1297+
serde_json::Value::Object(serde_json::Map::new())
1298+
}),
1299+
tool_call_id: Some(call.id.clone()),
1300+
})
1301+
.collect()
1302+
};
12941303
let mut parsed_text = String::new();
12951304

1296-
if calls.is_empty() {
1305+
if calls.is_empty() && !tool_specs.is_empty() {
12971306
let (fallback_text, fallback_calls) = parse_tool_calls(&response_text);
12981307
if !fallback_text.is_empty() {
12991308
parsed_text = fallback_text;
13001309
}
13011310
calls = fallback_calls;
13021311
}
13031312

1304-
let parse_issue = detect_tool_call_parse_issue(&response_text, &calls);
1313+
let parse_issue = if tool_specs.is_empty() {
1314+
None
1315+
} else {
1316+
detect_tool_call_parse_issue(&response_text, &calls)
1317+
};
13051318
if let Some(ref issue) = parse_issue {
13061319
runtime_trace::record_event(
13071320
"tool_call_parse_issue",
@@ -2060,6 +2073,29 @@ pub async fn run_tool_call_loop(
20602073
/// Build the tool instruction block for the system prompt so the LLM knows
20612074
/// how to invoke tools.
20622075
pub fn build_tool_instructions(tools_registry: &[Box<dyn Tool>]) -> String {
2076+
build_tool_instructions_for_tools(tools_registry.iter().map(|tool| tool.as_ref()))
2077+
}
2078+
2079+
/// Build tool instructions for the subset of registered tools that are
2080+
/// effective for the current prompt.
2081+
pub fn build_tool_instructions_for_names(
2082+
tools_registry: &[Box<dyn Tool>],
2083+
effective_tool_names: &HashSet<&str>,
2084+
) -> String {
2085+
build_tool_instructions_for_tools(
2086+
tools_registry
2087+
.iter()
2088+
.map(|tool| tool.as_ref())
2089+
.filter(|tool| effective_tool_names.contains(tool.name())),
2090+
)
2091+
}
2092+
2093+
fn build_tool_instructions_for_tools<'a>(tools: impl IntoIterator<Item = &'a dyn Tool>) -> String {
2094+
let tools: Vec<&dyn Tool> = tools.into_iter().collect();
2095+
if tools.is_empty() {
2096+
return String::new();
2097+
}
2098+
20632099
let mut instructions = String::new();
20642100
instructions.push_str("\n## Tool Use Protocol\n\n");
20652101
instructions.push_str("To use a tool, wrap a JSON object in <tool_call></tool_call> tags:\n\n");
@@ -2074,7 +2110,7 @@ pub fn build_tool_instructions(tools_registry: &[Box<dyn Tool>]) -> String {
20742110
.push_str("Continue reasoning with the results until you can give a final answer.\n\n");
20752111
instructions.push_str("### Available Tools\n\n");
20762112

2077-
for tool in tools_registry {
2113+
for tool in tools {
20782114
let desc = tool.description();
20792115
let _ = writeln!(
20802116
instructions,
@@ -2088,6 +2124,15 @@ pub fn build_tool_instructions(tools_registry: &[Box<dyn Tool>]) -> String {
20882124
instructions
20892125
}
20902126

2127+
fn retain_registered_tool_descriptions(
2128+
tool_descs: &mut Vec<(&str, &str)>,
2129+
tools_registry: &[Box<dyn Tool>],
2130+
) {
2131+
let registered_tool_names: HashSet<&str> =
2132+
tools_registry.iter().map(|tool| tool.name()).collect();
2133+
tool_descs.retain(|(name, _)| registered_tool_names.contains(name));
2134+
}
2135+
20912136
// ── CLI Entrypoint ───────────────────────────────────────────────────────
20922137
// Wires up all subsystems (observer, runtime, security, memory, tools,
20932138
// provider, hardware RAG, peripherals) and enters either single-shot or
@@ -2452,6 +2497,7 @@ pub async fn run(
24522497
"Query connected hardware for reported GPIO pins and LED pin. Use when: user asks what pins are available.",
24532498
));
24542499
}
2500+
retain_registered_tool_descriptions(&mut tool_descs, &tools_registry);
24552501
let bootstrap_max_chars = if config.agent.compact_context {
24562502
Some(6000)
24572503
} else {
@@ -3377,6 +3423,19 @@ pub async fn process_message(
33773423
tool_descs.retain(|(name, _)| !excluded.iter().any(|ex| ex == name));
33783424
}
33793425
}
3426+
let effective_tool_names: HashSet<&str> = tools_registry
3427+
.iter()
3428+
.map(|tool| tool.name())
3429+
.filter(|name| {
3430+
config.autonomy.level == AutonomyLevel::Full
3431+
|| !config
3432+
.autonomy
3433+
.non_cli_excluded_tools
3434+
.iter()
3435+
.any(|excluded| excluded.as_str() == *name)
3436+
})
3437+
.collect();
3438+
tool_descs.retain(|(name, _)| effective_tool_names.contains(name));
33803439

33813440
let bootstrap_max_chars = if config.agent.compact_context {
33823441
Some(6000)
@@ -3398,7 +3457,10 @@ pub async fn process_message(
33983457
config.agent.max_system_prompt_chars,
33993458
);
34003459
if !native_tools {
3401-
system_prompt.push_str(&build_tool_instructions(&tools_registry));
3460+
system_prompt.push_str(&build_tool_instructions_for_names(
3461+
&tools_registry,
3462+
&effective_tool_names,
3463+
));
34023464
}
34033465
if !deferred_section.is_empty() {
34043466
system_prompt.push('\n');
@@ -6301,6 +6363,14 @@ mod tests {
63016363
assert!(instructions.contains("file_write"));
63026364
}
63036365

6366+
#[test]
6367+
fn build_tool_instructions_empty_registry_returns_empty() {
6368+
let tools: Vec<Box<dyn Tool>> = vec![];
6369+
let instructions = build_tool_instructions(&tools);
6370+
6371+
assert!(instructions.is_empty());
6372+
}
6373+
63046374
#[test]
63056375
fn tools_to_openai_format_produces_valid_schema() {
63066376
use crate::security::SecurityPolicy;
@@ -6942,6 +7012,50 @@ Let me check the result."#;
69427012
);
69437013
}
69447014

7015+
#[test]
7016+
fn non_native_system_prompt_with_no_tools_contains_zero_tool_protocol() {
7017+
use crate::agent::system_prompt::build_system_prompt_with_mode;
7018+
7019+
let tool_summaries: Vec<(&str, &str)> = vec![];
7020+
7021+
let system_prompt = build_system_prompt_with_mode(
7022+
std::path::Path::new("/tmp"),
7023+
"test-model",
7024+
&tool_summaries,
7025+
&[],
7026+
None,
7027+
None,
7028+
false,
7029+
zeroclaw_config::schema::SkillsPromptInjectionMode::Full,
7030+
crate::security::AutonomyLevel::default(),
7031+
);
7032+
7033+
assert!(
7034+
!system_prompt.contains("## Tools"),
7035+
"No-tools prompt must not include a Tools section"
7036+
);
7037+
assert!(
7038+
!system_prompt.contains("## Tool Use Protocol"),
7039+
"No-tools prompt must not include tool protocol"
7040+
);
7041+
assert!(
7042+
!system_prompt.contains("<tool_call>"),
7043+
"No-tools prompt must not mention XML tool calls"
7044+
);
7045+
assert!(
7046+
!system_prompt.contains("<tool_result>"),
7047+
"No-tools prompt must not mention XML tool results"
7048+
);
7049+
assert!(
7050+
!system_prompt.contains("Use the tools"),
7051+
"No-tools prompt must not instruct the model to use unavailable tools"
7052+
);
7053+
assert!(
7054+
system_prompt.contains("No tools are available for this turn"),
7055+
"No-tools prompt should explicitly describe the current capability boundary"
7056+
);
7057+
}
7058+
69457059
// ── Cross-Alias & GLM Shortened Body Tests ──────────────────────────
69467060

69477061
#[test]

0 commit comments

Comments
Β (0)